chore: import deployable mai-bot source tree

This commit is contained in:
2026-05-11 00:51:12 +00:00
parent 4813699b3e
commit 7a54015f94
1009 changed files with 312999 additions and 16 deletions

8
dashboard/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}

661
dashboard/LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<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 Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

377
dashboard/README.md Normal file
View File

@@ -0,0 +1,377 @@
# MaiBot Dashboard
> MaiBot 的现代化 Web 管理面板 - 基于 React 19 + TypeScript + Vite 构建
<div align="center">
[![React](https://img.shields.io/badge/React-19.2-61DAFB?logo=react&logoColor=white)](https://react.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/Vite-7.2-646CFF?logo=vite&logoColor=white)](https://vitejs.dev/)
[![TailwindCSS](https://img.shields.io/badge/TailwindCSS-4.2-38B2AC?logo=tailwind-css&logoColor=white)](https://tailwindcss.com/)
</div>
## 📖 项目简介
MaiBot Dashboard 是 MaiBot 聊天机器人的 Web 管理界面,提供了直观的配置管理、实时监控、插件管理、资源管理等功能。通过自动解析后端配置类,动态生成表单,实现了配置的可视化编辑。
<div align="center">
<img src="docs/main.png" alt="MaiBot Dashboard 界面预览" width="800" />
</div>
### ✨ 核心特性
- 🎨 **现代化 UI** - 基于 shadcn/ui 组件库,支持亮色/暗色主题切换
-**高性能** - 使用 Vite 7.2 构建React 19 最新特性
- 🔐 **安全认证** - Token 认证机制,支持自定义和自动生成 Token
- 📝 **智能配置** - 自动解析 Python dataclass生成配置表单
- 🎯 **类型安全** - 完整的 TypeScript 类型定义
- 🔄 **实时更新** - WebSocket 实时日志流、配置自动保存
- 📱 **响应式设计** - 完美适配桌面和移动设备
- 💬 **本地对话** - 直接在 WebUI 与麦麦对话,无需外部平台
## 🎯 功能模块
### 📊 仪表盘(首页)
- **实时统计** - 总请求数、Token 消耗、费用统计、在线时长
- **模型统计** - 各模型的使用次数、费用、平均响应时间
- **趋势图表** - 每小时请求量、Token 消耗、费用趋势折线图
- **模型分布** - 饼图展示模型使用占比
- **最近活动** - 实时刷新的请求活动列表
### 💬 本地聊天室
- **WebSocket 实时通信** - 与麦麦直接对话
- **消息历史** - 自动加载 SQLite 存储的历史消息
- **连接状态** - 实时显示 WebSocket 连接状态
- **自定义昵称** - 可自定义用户身份
- **移动端适配** - 完整的响应式聊天界面
### ⚙️ 配置管理
#### 麦麦主程序配置
- **分组展示** - 配置项按功能分组(基础设置、功能开关等)
- **智能表单** - 根据配置类型自动生成对应控件
- **自动保存** - 2秒防抖自动保存无需手动操作
- **一键重启** - 保存并重启麦麦,使配置生效
#### AI 模型厂商配置
- **提供商管理** - 添加、编辑、删除 API 提供商
- **模板选择** - 预设常用厂商模板OpenAI、DeepSeek、硅基流动等
- **连接测试** - ⚡ 测试提供商连接状态和 API Key 有效性
- **批量操作** - 批量删除、批量测试所有提供商
- **搜索过滤** - 按名称、URL、类型快速筛选
#### 模型管理与分配
- **模型列表** - 管理可用的模型配置
- **使用状态** - 显示模型是否被任务使用
- **任务分配** - 为不同功能分配模型回复、工具调用、VLM 等)
- **参数调整** - 温度、最大 Token 等参数配置
- **新手引导** - 交互式引导教程
#### 适配器配置
- **NapCat 配置** - 管理 QQ 机器人适配器
- **Docker 支持** - 支持容器模式配置
- **配置导入导出** - 跨环境迁移配置
### 📋 实时日志
- **WebSocket 流式传输** - 实时接收后端日志
- **虚拟滚动** - 高性能处理大量日志
- **多级过滤** - 按日志级别DEBUG/INFO/WARNING/ERROR过滤
- **模块过滤** - 按日志来源模块筛选
- **时间范围** - 日期选择器筛选日志
- **搜索高亮** - 关键字搜索并高亮显示
- **字号调整** - 自定义日志显示字号和行间距
- **日志导出** - 导出过滤后的日志
### 🔌 插件管理
- **插件市场** - 浏览和搜索可用插件
- **分类筛选** - 按类别、状态筛选插件
- **一键安装** - 自动处理依赖并安装插件
- **版本兼容** - 检查插件与 MaiBot 版本兼容性
- **进度显示** - WebSocket 实时显示安装进度
- **插件统计** - 下载量、更新时间等信息
- **卸载更新** - 管理已安装插件
### 👤 人物关系管理
- **人物列表** - 查看所有已知用户信息
- **详情编辑** - 编辑用户昵称、备注等信息
- **关系统计** - 查看消息数、互动频率等统计
- **批量操作** - 批量删除用户记录
### 📦 资源管理
#### 表情包管理
- **预览管理** - 图片/GIF 预览
- **分类过滤** - 按注册状态、描述筛选
- **编辑标签** - 修改表情包描述和属性
- **批量禁用** - 启用/禁用表情包
#### 表达方式管理
- **表达列表** - 查看麦麦学习的表达方式
- **来源追踪** - 记录表达来源群组和用户
- **编辑创建** - 手动添加或编辑表达
#### 知识图谱
- **可视化展示** - ReactFlow 交互式图谱
- **节点搜索** - 搜索实体和关系
- **布局算法** - 自动布局优化
- **详情查看** - 点击节点查看详细信息
### ⚙️ 系统设置
- **主题切换** - 亮色/暗色/跟随系统
- **动画控制** - 开启/关闭界面动画
- **Token 管理** - 查看、复制、重新生成认证 Token
- **版本信息** - 查看前端和后端版本
## 🏗️ 技术架构
### 前端技术栈
```
React 19.2.0 # UI 框架
├── TypeScript 5.9 # 类型系统
├── Vite 7.2 # 构建工具
├── TanStack Router # 路由管理
├── TanStack Virtual # 虚拟滚动
├── Jotai # 状态管理
├── Tailwind CSS 4.2 # 样式框架
├── ReactFlow # 知识图谱可视化
├── Recharts # 数据图表
└── shadcn/ui # 组件库
├── Radix UI # 无障碍组件
└── lucide-react # 图标库
```
### 后端集成
```
FastAPI # Python 后端框架
├── WebSocket # 实时日志、聊天
├── config_schema.py # 配置架构生成器
├── config_routes.py # 配置管理 API
├── model_routes.py # 模型管理 API
├── chat_routes.py # 本地聊天 API
├── plugin_routes.py # 插件管理 API
├── person_routes.py # 人物管理 API
├── emoji_routes.py # 表情包管理 API
├── expression_routes.py # 表达管理 API
├── knowledge_routes.py # 知识图谱 API
├── logs_routes.py # 日志 API
└── tomlkit # TOML 文件处理
```
## 📁 项目结构
```
MaiBot-Dashboard/
├── src/
│ ├── components/ # 组件目录
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── layout.tsx # 布局组件(侧边栏+导航)
│ │ ├── tour/ # 新手引导组件
│ │ ├── plugin-stats.tsx # 插件统计组件
│ │ ├── RestartingOverlay.tsx # 重启遮罩
│ │ └── use-theme.tsx # 主题管理
│ ├── routes/ # 路由页面
│ │ ├── index.tsx # 仪表盘首页
│ │ ├── auth.tsx # 登录页
│ │ ├── chat.tsx # 本地聊天室
│ │ ├── logs.tsx # 日志查看
│ │ ├── plugins.tsx # 插件管理
│ │ ├── person.tsx # 人物管理
│ │ ├── settings.tsx # 系统设置
│ │ ├── config/ # 配置管理页面
│ │ │ ├── bot.tsx # 麦麦主程序配置
│ │ │ ├── modelProvider.tsx # 模型提供商
│ │ │ ├── model.tsx # 模型管理
│ │ │ └── adapter.tsx # 适配器配置
│ │ └── resource/ # 资源管理页面
│ │ ├── emoji.tsx # 表情包管理
│ │ ├── expression.tsx # 表达方式管理
│ │ └── knowledge-graph.tsx # 知识图谱
│ ├── lib/ # 工具库
│ │ ├── config-api.ts # 配置 API 客户端
│ │ ├── plugin-api.ts # 插件 API 客户端
│ │ ├── person-api.ts # 人物 API 客户端
│ │ ├── expression-api.ts # 表达 API 客户端
│ │ ├── log-websocket.ts # 日志 WebSocket
│ │ ├── fetch-with-auth.ts # 认证请求封装
│ │ └── utils.ts # 通用工具函数
│ ├── types/ # 类型定义
│ │ ├── config-schema.ts # 配置架构类型
│ │ ├── plugin.ts # 插件类型
│ │ ├── person.ts # 人物类型
│ │ └── expression.ts # 表达类型
│ ├── hooks/ # React Hooks
│ │ ├── use-auth.ts # 认证逻辑
│ │ ├── use-animation.ts # 动画控制
│ │ └── use-toast.ts # 消息提示
│ ├── store/ # 全局状态
│ │ └── auth.ts # 认证状态
│ ├── router.tsx # 路由配置
│ ├── main.tsx # 应用入口
│ └── index.css # 全局样式
├── public/ # 静态资源
├── vite.config.ts # Vite 配置
├── tailwind.config.js # Tailwind v4 兼容占位配置
├── tsconfig.json # TypeScript 配置
└── package.json # 依赖管理
```
## 🚀 快速开始
### 环境要求
- Node.js >= 18.0.0
- Bun >= 1.0.0 (推荐) 或 npm/yarn/pnpm
### 安装依赖
```bash
# 使用 Bun推荐
bun install
# 或使用 npm
npm install
```
### 开发模式
```bash
# 启动开发服务器 (默认端口: 7999)
bun run dev
# 或
npm run dev
```
访问 http://localhost:7999 查看应用。
### 生产构建
```bash
# 构建生产版本
bun run build
# 预览生产构建
bun run preview
```
构建产物会输出到 `dist/` 目录,由 MaiBot 后端静态服务。
### 代码格式化
```bash
# 格式化代码
bun run format
```
## 🔧 开发配置
### Vite 代理配置
开发模式下Vite 会将 API 请求代理到后端:
```typescript
// vite.config.ts
proxy: {
'/api': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
ws: true, // WebSocket 支持
},
},
```
### 环境变量
开发环境默认使用 `http://localhost:7999`,生产环境使用相对路径。
## 📸 界面预览
### 仪表盘
实时统计、模型使用分布、趋势图表
### 本地聊天
直接与麦麦对话,消息实时同步
### 配置管理
分组配置项,自动生成表单,自动保存
### 模型提供商
一键测试连接状态,模板快速添加
### 日志查看
实时日志流,多级过滤,虚拟滚动
## 📦 依赖说明
### 核心依赖
| 包名 | 版本 | 用途 |
|------|------|------|
| react | ^19.2.0 | UI 框架 |
| react-dom | ^19.2.0 | React DOM 渲染 |
| typescript | ~5.9.3 | 类型系统 |
| vite | ^7.2.2 | 构建工具 |
| @tanstack/react-router | ^1.136.1 | 路由管理 |
| @tanstack/react-virtual | ^3.x | 虚拟滚动 |
| jotai | ^2.15.1 | 状态管理 |
| axios | ^1.13.2 | HTTP 客户端 |
| recharts | ^2.x | 数据图表 |
| reactflow | ^11.x | 知识图谱可视化 |
| dagre | ^0.8.x | 图布局算法 |
### UI 组件库
| 包名 | 版本 | 用途 |
|------|------|------|
| @radix-ui/react-* | ^1.x | 无障碍组件基础 |
| lucide-react | ^0.553.0 | 图标库 |
| tailwindcss | ^4.2.1 | CSS 框架 |
| class-variance-authority | ^0.7.1 | 类名管理 |
| tailwind-merge | ^3.4.0 | Tailwind 类合并 |
| date-fns | ^3.x | 日期处理 |
## 🤝 贡献指南
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
### 代码规范
- 使用 TypeScript 严格模式
- 遵循 ESLint 规则
- 使用 Prettier 格式化代码
- 组件使用函数式编写
- 优先使用 Hooks
- 响应式设计优先(移动端适配)
## 📄 开源协议
本项目基于 GPLv3 协议开源,详见 [LICENSE](./LICENSE) 文件。
## 👥 作者
**MotricSeven** - [GitHub](https://github.com/DrSmoothl)
## 🙏 致谢
- [React](https://react.dev/) - UI 框架
- [shadcn/ui](https://ui.shadcn.com/) - 组件库
- [Radix UI](https://www.radix-ui.com/) - 无障碍组件
- [TanStack Router](https://tanstack.com/router) - 路由解决方案
- [TanStack Virtual](https://tanstack.com/virtual) - 虚拟滚动
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
- [ReactFlow](https://reactflow.dev/) - 流程图/知识图谱
- [Recharts](https://recharts.org/) - React 图表库
---
<div align="center">
Made with ❤️ by MotricSeven and Mai-with-u
</div>

2502
dashboard/bun.lock Normal file

File diff suppressed because it is too large Load Diff

6
dashboard/bunfig.toml Normal file
View File

@@ -0,0 +1,6 @@
[install]
registry = "https://mirrors.cloud.tencent.com/npm/"
linker = "hoisted"
[install.cache]
disableManifest = true

20
dashboard/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/components",
"utils": "src/lib/utils",
"ui": "src/components/ui",
"lib": "src/lib",
"hooks": "src/hooks"
}
}

View File

@@ -0,0 +1,12 @@
maibot.example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy core:8001
}

View File

@@ -0,0 +1,12 @@
maibot.example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy 127.0.0.1:8001
}

BIN
dashboard/docs/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

View File

@@ -0,0 +1,203 @@
# MaiBot WebUI Compose TLS/SSL 教程
本文档专门说明 Docker Compose 部署下如何通过 Caddy 为 MaiBot WebUI 提供 HTTPS。
## 1. 目标结构
启用后,网络结构应为:
```text
浏览器
-> https://maibot.example.com
-> Caddy 容器 :80/:443
-> core 容器 :8001
-> MaiBot WebUI
```
这意味着:
1. core 不再直接对公网暴露 8001
2. Caddy 统一接管 80 和 443
3. Caddy 通过 Docker 网络访问 core:8001
## 2. 仓库里已经补了什么
本仓库已补充以下内容:
1. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 示例块
2. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 数据卷定义
3. dashboard/docs/Caddyfile.docker.example 提供了 Docker Compose 专用配置模板
4. dashboard/docs/Caddyfile.host.example 提供了非 Docker 宿主机专用配置模板
## 3. 需要手动注释或启用的段落
本文档按默认保持注释状态进行说明,下面明确列出需要操作的段落。
### 3.1 需要注释掉的现有段落
启用 Caddy 以后,请注释掉根目录 docker-compose.yml 中 core 服务下这一段端口映射:
```yaml
ports:
- "18001:8001"
```
原因很简单:
1. 这段会把 WebUI 的明文 HTTP 直接暴露到宿主机
2. 启用 HTTPS 以后,应由 Caddy 对外暴露 80 和 443
3. 避免出现“HTTPS 入口和 HTTP 入口同时暴露”的混乱状态
### 3.2 需要取消注释并启用的段落
启用时,需要在根目录 docker-compose.yml 中取消注释这两部分:
1. caddy 服务块
2. volumes 里的 caddy_data 和 caddy_config
## 4. 启用前需要准备什么
1. 域名已经解析到服务器公网 IP
2. 宿主机的 80 和 443 未被占用
3. 防火墙和云安全组已放行 80 和 443
4. WebUI 当前可以通过 compose 正常启动
5. 已准备修改 dashboard/docs/Caddyfile.docker.example 里的域名
## 5. Caddy 配置文件如何写
Docker Compose 模式请使用dashboard/docs/Caddyfile.docker.example
非 Docker 宿主机模式请使用dashboard/docs/Caddyfile.host.example
最小可用配置如下:
```caddyfile
maibot.example.com {
reverse_proxy core:8001
}
```
建议至少做这两处修改:
1. 把 maibot.example.com 改成实际使用的域名
2. 如果有额外安全要求,再按需增加 header 配置
## 6. compose 启用步骤
### 6.1 修改 WebUI 配置
先在 config/bot_config.toml 中确认:
```toml
[webui]
mode = "production"
secure_cookie = true
trust_xff = true
```
trusted_proxies 的建议值取决于实际网络环境。
如果 Caddy 和 core 在同一个 Docker 网络里,建议先按实际来源地址或网段填写。不要为了省事直接把范围开得过大。
### 6.2 修改 Caddyfile
编辑 dashboard/docs/Caddyfile.docker.example将域名替换为真实值。
### 6.3 修改 compose
1. 注释掉 core 服务里对外暴露 WebUI 的 ports 段
2. 取消注释 caddy 服务块
3. 取消注释底部 volumes 里的 caddy_data 和 caddy_config
### 6.4 启动服务
```bash
docker compose up -d
```
### 6.5 查看日志
```bash
docker compose logs -f caddy
docker compose logs -f core
```
## 7. Let's Encrypt 申请与续期
### 7.1 证书申请触发条件
Caddy 容器启动后,满足以下条件时会自动申请证书:
1. 域名已解析到当前服务器
2. 80 和 443 对公网开放
3. Caddy 能成功接收到针对该域名的请求
### 7.2 自动续期说明
Caddy 会自动续期,通常不需要编写 crontab也不需要手工执行 certbot。
只需要确保:
1. caddy_data 卷被持久化
2. 容器会长期运行
3. 域名长期指向同一台服务器或新服务器已同步迁移数据
4. 80 和 443 没被防火墙阻断
### 7.3 续期检查建议
建议定期执行:
```bash
docker compose logs --tail=200 caddy
docker compose ps
```
重点关注:
1. ACME 申请失败
2. 证书续期失败
3. 端口绑定失败
4. 域名解析不一致
## 8. 常见错误与排查
### 8.1 证书申请失败
优先检查:
1. 域名是否指向服务器公网 IP
2. 是否已经开启 CDN 代理但未正确放通验证流量
3. 80 和 443 是否被云厂商安全组拦截
4. 宿主机是否还有别的程序占用了 80 或 443
### 8.2 登录失败
优先检查:
1. webui.secure_cookie 是否已启用
2. 请求是否真正走 https:// 域名
3. 代理是否正确传递了 X-Forwarded-Proto
### 8.3 WebSocket 连接失败
优先检查:
1. Caddy 是否已正确反向代理到 core:8001
2. 页面是否通过 HTTPS 打开
3. 浏览器开发者工具里是否出现混合内容报错
## 9. 迁移建议
如果当前已经在使用:
```yaml
ports:
- "18001:8001"
```
那说明当前还是“宿主机明文 HTTP 暴露 WebUI”模式。迁移到 HTTPS 时建议:
1. 先准备好域名
2. 先改好 Caddyfile
3. 再切换 compose 暴露方式
4. 切换后直接以 https://域名 访问,不再继续使用 http://服务器IP:18001

View File

@@ -0,0 +1,465 @@
# MaiBot WebUI TLS/SSL 配置指南
本文档基于当前仓库实现整理,目标是让 WebUI 通过 HTTPS 提供访问能力并保持登录、Cookie、WebSocket 和 Let's Encrypt 续期正常工作。
## 1. 先说结论
MaiBot 当前最合适的 TLS/SSL 方案是让反向代理终止 HTTPS然后把请求转发到 WebUI 的 HTTP 服务。
推荐顺序如下:
1. Caddy 反向代理 + Let's Encrypt 自动签发与续期
2. 宝塔面板反向代理 + Let's Encrypt
3. 1Panel 反向代理 + Let's Encrypt
4. 不建议直接让 WebUI 自己监听 HTTPS当前仓库没有现成的 WebUI 原生 TLS 配置入口
## 2. 当前项目的部署特征
当前仓库里WebUI 的前后端是同源部署思路:
1. 后端是独立的 FastAPI WebUI 服务,默认监听 127.0.0.1:8001
2. 前端构建产物由这个 FastAPI 服务直接托管
3. 浏览器生产模式下默认按同源访问 API
4. 页面如果通过 HTTPS 打开,前端会自动把 WebSocket 协议切到 WSS
这意味着最稳妥的方式是:
1. MaiBot WebUI 继续在本机或容器内网跑 HTTP
2. 让 Caddy、宝塔 Nginx 或 1Panel OpenResty 对外暴露 443
3. 由代理把所有请求和 WebSocket 都转发到 WebUI
## 3. 配置前的准备工作
正式启用 HTTPS 之前,先确认下面几项:
1. 已准备一个已经解析到服务器公网 IP 的域名,例如 maibot.example.com
2. 80 和 443 端口可以从公网访问
3. 服务器没有其他程序占用 80 和 443
4. WebUI 可以在本机正常打开,例如 http://127.0.0.1:8001
如果采用 Docker Compose 部署,还要确认:
1. 容器已经能正常启动
2. 根目录的 docker-compose.yml 当前可以正常运行
3. HTTPS 入口将统一由反向代理接管
## 4. WebUI 自身配置
无论采用 Caddy、宝塔还是 1Panel都建议先把 WebUI 配成生产模式。
修改 config/bot_config.toml 里的 webui 配置段,建议值如下:
```toml
[webui]
enabled = true
mode = "production"
anti_crawler_mode = "loose"
allowed_ips = "127.0.0.1"
trusted_proxies = "127.0.0.1"
trust_xff = true
secure_cookie = true
enable_paragraph_content = false
```
各项的意义:
1. mode = "production"
让 WebUI 按生产环境运行,并倾向启用更严格的安全行为。
2. secure_cookie = true
让登录 Cookie 仅在 HTTPS 下传输。
3. trust_xff = true
允许从反向代理传入的 X-Forwarded-For 获取真实来源 IP。
4. trusted_proxies = "127.0.0.1"
表示只有来自本机反向代理的 X-Forwarded-For 才被信任。
注意:
1. 如果使用 Docker 内部的反向代理trusted_proxies 不应固定写 127.0.0.1,而应填写反向代理容器到 MaiBot 的实际来源地址或所在网段。
2. 如果尚未切换到 HTTPS不要提前开启 secure_cookie = true否则可能出现登录 Cookie 不生效或握手异常的问题。
## 5. 直接部署方式如何配置 TLS/SSL
这里的“直接部署”指的是:
1. MaiBot 直接跑在宿主机上
2. WebUI 监听本机 127.0.0.1:8001
3. 宿主机安装 Caddy
4. 由 Caddy 负责申请证书和 HTTPS 反代
### 5.1 推荐的网络结构
```text
浏览器
-> https://maibot.example.com
-> Caddy :443
-> 127.0.0.1:8001
-> MaiBot WebUI
```
### 5.2 宿主机直装 Caddy
以 Debian 或 Ubuntu 为例,参考步骤如下:
```bash
sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
```
macOS Homebrew 参考:
```bash
brew install caddy
```
### 5.3 Caddyfile 示例
仓库已提供两份可复制的示例文件,请按部署方式选择:
1. 非 Docker 宿主机部署dashboard/docs/Caddyfile.host.example
2. Docker Compose 部署dashboard/docs/Caddyfile.docker.example
宿主机直连部署可使用以下最简配置:
```caddyfile
maibot.example.com {
reverse_proxy 127.0.0.1:8001
}
```
如需显式添加安全头,可以使用增强版:
```caddyfile
maibot.example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy 127.0.0.1:8001
}
```
非 Docker 直接部署建议直接从 dashboard/docs/Caddyfile.host.example 开始修改域名并投入使用。
### 5.4 HSTS 是否启用
可以启用,而且当前推荐由反向代理统一下发 HSTS 响应头,而不是让 WebUI 自己在 FastAPI 层单独处理。
当前仓库提供的两份 Caddy 示例都已经带了 HSTS
1. dashboard/docs/Caddyfile.host.example
2. dashboard/docs/Caddyfile.docker.example
示例配置中的这一行就是 HSTS
```caddyfile
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
```
这行配置的含义如下:
1. max-age=31536000
浏览器在 1 年内记住该站点只能使用 HTTPS。
2. includeSubDomains
所有子域名也必须强制使用 HTTPS。
3. preload
表示该域名计划提交到浏览器内置的 HSTS preload 列表。
HSTS 建议按下面的节奏启用:
1. 初次上线 HTTPS 时,可以先使用不带 preload 的版本。
2. 确认主域名和所有相关子域名都长期稳定支持 HTTPS 后,再考虑是否加入 preload。
3. 如果无法确认所有子域名都支持 HTTPS不要轻易保留 includeSubDomains。
更稳妥的起步版本如下:
```caddyfile
Strict-Transport-Security "max-age=31536000"
```
如果所有子域名都已经稳定支持 HTTPS可以使用
```caddyfile
Strict-Transport-Security "max-age=31536000; includeSubDomains"
```
只有在满足下面条件时,才建议使用 preload
1. 主域名始终可通过 HTTPS 访问。
2. 所有子域名都始终可通过 HTTPS 访问。
3. 已明确理解 preload 是长期约束,而不是临时开关。
HSTS 的风险点主要有这些:
1. 一旦浏览器记住该域名只能用 HTTPS后续临时切回 HTTP 会直接失败。
2. 如果开启 includeSubDomains而某个子域名并没有部署 HTTPS该子域名会被浏览器直接拦截。
3. 如果开启 preload 并提交到浏览器列表,撤销成本会比较高,生效和移除都不是即时的。
因此,本文档里的 Caddy 示例更适合作为“完整增强版示例”参考。首次部署时,建议先按实际域名情况,将 HSTS 调整成更合适的版本后再正式上线。
### 5.5 启动与验证
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
sudo systemctl status caddy
```
检查项:
1. 浏览器访问 https://maibot.example.com 能正常打开登录页
2. 登录后 Cookie 正常写入
3. 日志页和聊天页的 WebSocket 可以正常连接
4. 证书是 Let's Encrypt 或所选颁发机构签发的有效证书
### 5.6 直接部署方式的 Let's Encrypt 申请与续期
Caddy 默认会自动处理证书签发和续期,前提如下:
1. 域名已正确解析到服务器
2. 80 和 443 可从公网访问
3. 没有 CDN、WAF 或安全组拦截 ACME 验证请求
Caddy 的自动续期通常无需手工干预,只需确保:
1. 保持 Caddy 常驻运行
2. 不要阻断 80 和 443
3. 定期关注 Caddy 日志是否存在 ACME 失败记录
常用检查命令:
```bash
sudo journalctl -u caddy -n 200 --no-pager
sudo journalctl -u caddy -f
```
如果续期失败,优先检查:
1. 域名是否仍然解析到当前服务器
2. 80 和 443 是否被防火墙、面板或云安全组拦截
3. 是否存在另一个程序抢占了 80 或 443
## 6. 宝塔面板如何配置 SSL
宝塔适合已经习惯图形化管理 Nginx 站点的部署方式。思路仍然是:由宝塔的站点反向代理到 MaiBot WebUI。
### 6.1 推荐网络结构
```text
浏览器
-> 宝塔站点 HTTPS
-> 宝塔 Nginx/OpenResty 反向代理
-> 127.0.0.1:8001
-> MaiBot WebUI
```
### 6.2 宝塔站点创建步骤
1. 登录宝塔面板。
2. 进入网站。
3. 添加站点。
4. 域名填写实际使用的 WebUI 域名,例如 maibot.example.com。
5. PHP 版本可以选纯静态或关闭运行环境,重点是站点存在即可。
### 6.3 反向代理配置步骤
1. 进入对应站点。
2. 打开反向代理。
3. 新增反向代理。
4. 目标 URL 填写 http://127.0.0.1:8001。
5. 发送域名通常保持目标域或原域名即可。
如果使用的是宝塔站点配置文件,也可以手动补这一段:
```nginx
location / {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
```
如果宝塔环境没有现成的 connection_upgrade 变量,可以改成:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
### 6.4 宝塔中申请 Let's Encrypt 证书
1. 进入站点设置。
2. 打开 SSL。
3. 选择 Let's Encrypt。
4. 勾选对应域名。
5. 申请证书。
6. 开启强制 HTTPS。
### 6.5 宝塔中续期证书
宝塔一般会自动续期,但仍然需要检查:
1. 面板计划任务是否正常运行
2. 80 端口是否在验证时可达
3. 域名解析是否未被改动
建议定期查看:
1. 宝塔站点 SSL 到期时间
2. 宝塔计划任务执行日志
3. 站点错误日志和 Nginx 错误日志
### 6.6 宝塔模式下 WebUI 配置建议
建议保持:
```toml
[webui]
mode = "production"
secure_cookie = true
trust_xff = true
trusted_proxies = "127.0.0.1"
```
如果宝塔和 MaiBot 不在同一台机器上trusted_proxies 需要换成宝塔所在服务器到 MaiBot 的来源地址。
## 7. 1Panel 如何配置 SSL
1Panel 的逻辑和宝塔类似,本质上也是由面板管理的网关或站点反向代理到 MaiBot WebUI。
### 7.1 推荐网络结构
```text
浏览器
-> 1Panel 网站/反向代理 HTTPS
-> OpenResty/Nginx 反向代理
-> 127.0.0.1:8001 或 core:8001
-> MaiBot WebUI
```
### 7.2 1Panel 配置步骤
1. 登录 1Panel。
2. 打开网站或反向代理管理。
3. 新建网站,域名填 maibot.example.com。
4. 添加反向代理规则,目标地址指向 http://127.0.0.1:8001。
5. 开启 WebSocket 支持。
6. 保存并重载站点配置。
如果是在 Docker 环境里通过 1Panel 管理容器,目标地址也可以填写容器服务名,例如 http://core:8001但前提是 1Panel 管理的网关容器与 MaiBot 在同一个 Docker 网络内。
### 7.3 在 1Panel 申请 Let's Encrypt 证书
1. 打开证书管理。
2. 选择 Let's Encrypt。
3. 绑定域名。
4. 选择 HTTP-01 或面板默认验证方式。
5. 完成签发后,把证书绑定到对应网站。
6. 启用 HTTPS。
### 7.4 1Panel 中续期证书
1Panel 通常会自动续期,但需要确认:
1. 自动续期开关处于启用状态
2. 面板的任务调度正常
3. 80 和 443 端口验证时不被拦截
4. 域名始终指向正确服务器
### 7.5 1Panel 模式下的反代头
请确认面板生成的配置会向后端传递:
1. Host
2. X-Real-IP
3. X-Forwarded-For
4. X-Forwarded-Proto
5. Upgrade
6. Connection
缺少 X-Forwarded-Proto 时WebUI 可能误判为 HTTP进而影响 secure cookie 与登录行为。
## 8. Docker Compose 下如何配置 TLS/SSL
根目录 docker-compose.yml 已补充默认注释的 Caddy 示例块,用于容器化部署时启用 HTTPS。
Docker 模式下请使用dashboard/docs/Caddyfile.docker.example
非 Docker 宿主机模式下请使用dashboard/docs/Caddyfile.host.example
详细步骤请看另一份专项文档dashboard/docs/webui-tls-ssl-compose.md
这里只先给结论:
1. 启用 Caddy 反向代理时,不应再把 core 的 8001 直接映射到公网
2. 应由 Caddy 容器暴露 80 和 443
3. Caddy 通过容器网络访问 core:8001
## 9. 常见问题
### 9.1 开了 HTTPS 后无法登录
优先检查:
1. webui.secure_cookie 是否在 HTTPS 环境下开启
2. 代理是否正确传递 X-Forwarded-Proto
3. 浏览器访问的是否确实是 https:// 域名而不是 http:// IP
4. Cookie 是否被浏览器策略、扩展或跨站配置拦截
### 9.2 页面能打开,但日志页或聊天页 WebSocket 失败
优先检查:
1. 代理是否支持 WebSocket Upgrade
2. 是否使用了 HTTPS 页面去连接 ws:// 明文地址
3. Caddy、Nginx、宝塔、1Panel 是否有单独的 WebSocket 开关或升级头配置
### 9.3 Let's Encrypt 申请失败
优先检查:
1. 域名解析是否正确
2. 80 端口是否可访问
3. 是否开启了 CDN 代理但没有正确放通验证流量
4. 面板或防火墙是否拦截 ACME 请求
### 9.4 是否能直接用 IP 申请 Let's Encrypt
不能。Let's Encrypt 只为域名签发公开可信证书,不为裸 IP 签发。
### 9.5 内网环境如何测试 HTTPS
可以使用 Caddy 的 tls internal 进行测试,但客户端必须手工信任内部 CA 根证书。正式对外服务仍建议使用有效公网域名和 Let's Encrypt。
## 10. 推荐实践
普通 Linux 服务器部署的推荐顺序如下:
1. 宿主机直装 Caddy
2. WebUI 绑定 127.0.0.1:8001
3. 域名指向服务器
4. 用 Caddy 反代并自动管理 Let's Encrypt
如果已经使用面板管理服务器,则:
1. 宝塔用户直接用宝塔反向代理和 Let's Encrypt
2. 1Panel 用户直接用 1Panel 网站或网关反代和证书管理
如果采用 Docker Compose 部署,则:
1. 使用根目录 compose 中提供的默认注释 Caddy 示例块
2. 注释掉 core 服务里直接暴露 WebUI 的端口映射
3. 由 Caddy 统一对外暴露 80 和 443

View File

@@ -0,0 +1,137 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'electron-vite'
import path from 'path'
export default defineConfig({
main: {
build: {
target: 'node18',
rollupOptions: {
input: {
index: path.resolve(__dirname, 'electron/main/index.ts'),
},
output: {
format: 'cjs',
},
external: ['electron', 'electron-store'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
preload: {
build: {
target: 'node18',
rollupOptions: {
input: {
index: path.resolve(__dirname, 'electron/preload/index.ts'),
},
output: {
entryFileNames: '[name].js',
format: 'cjs',
},
},
},
},
renderer: {
root: '.',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [tailwindcss(), react()],
server: {
port: 7999,
proxy: {
'/api': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
ws: true,
cookieDomainRewrite: '',
cookiePathRewrite: '/',
},
},
},
build: {
rollupOptions: {
input: path.resolve(__dirname, 'index.html'),
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
router: ['@tanstack/react-router', '@tanstack/react-virtual'],
radix: [
'@radix-ui/react-dialog',
'@radix-ui/react-select',
'@radix-ui/react-checkbox',
'@radix-ui/react-label',
'@radix-ui/react-slot',
'@radix-ui/react-toast',
'@radix-ui/react-tooltip',
'@radix-ui/react-alert-dialog',
'@radix-ui/react-avatar',
'@radix-ui/react-collapsible',
'@radix-ui/react-context-menu',
'@radix-ui/react-popover',
'@radix-ui/react-progress',
'@radix-ui/react-scroll-area',
'@radix-ui/react-separator',
'@radix-ui/react-slider',
'@radix-ui/react-switch',
'@radix-ui/react-tabs',
],
icons: ['lucide-react'],
charts: ['recharts'],
codemirror: [
'@uiw/react-codemirror',
'@codemirror/lang-javascript',
'@codemirror/lang-json',
'@codemirror/lang-python',
'@codemirror/lint',
'@codemirror/theme-one-dark',
],
reactflow: ['reactflow', 'dagre'],
markdown: [
'react-markdown',
'remark-gfm',
'remark-math',
'rehype-katex',
'katex',
],
uppy: [
'@uppy/core',
'@uppy/dashboard',
'@uppy/react',
'@uppy/xhr-upload',
],
dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'],
utils: [
'date-fns',
'clsx',
'tailwind-merge',
'class-variance-authority',
'axios',
],
misc: ['react-joyride', 'react-day-picker', 'cmdk'],
},
},
},
chunkSizeWarningLimit: 500,
},
},
})

View File

@@ -0,0 +1,203 @@
import { app, BrowserWindow, ipcMain, protocol, session } from 'electron'
import path from 'path'
import { fileURLToPath } from 'url'
import { registerAppProtocol } from './protocol'
import {
addBackend,
getActiveBackend,
getBackends,
getWindowBounds,
isFirstLaunch,
markFirstLaunchComplete,
removeBackend,
setActiveBackend,
setWindowBounds,
updateBackend,
} from './store'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
let mainWindow: BrowserWindow | null = null
/**
* Register app:// custom protocol BEFORE app.whenReady()
* This is critical for electron-vite to work correctly
*/
function registerAppScheme() {
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
corsEnabled: true,
secure: true,
allowServiceWorkers: true,
standard: true,
supportFetchAPI: true,
stream: true,
},
},
])
}
/**
* Register all IPC handlers for window control and store CRUD
*/
function registerIpcHandlers() {
// ── Window control ───────────────────────────────────────────────────────
ipcMain.handle('electron:minimize-window', () => mainWindow?.minimize())
ipcMain.handle('electron:maximize-window', () => {
if (mainWindow?.isMaximized()) mainWindow.unmaximize()
else mainWindow?.maximize()
})
ipcMain.handle('electron:close-window', () => mainWindow?.close())
ipcMain.handle('electron:is-maximized', () => mainWindow?.isMaximized() ?? false)
// ── Backend CRUD ─────────────────────────────────────────────────────────
ipcMain.handle('electron:get-backends', () => getBackends())
ipcMain.handle('electron:add-backend', (_e, conn) => addBackend(conn))
ipcMain.handle('electron:update-backend', (_e, id, patch) => updateBackend(id, patch))
ipcMain.handle('electron:remove-backend', (_e, id) => removeBackend(id))
ipcMain.handle('electron:set-active-backend', (_e, id) => {
setActiveBackend(id)
const backend = getActiveBackend()
mainWindow?.webContents.send('electron:backend-changed', backend)
})
ipcMain.handle('electron:get-active-backend', () => getActiveBackend())
ipcMain.handle('electron:get-active-url', () => getActiveBackend()?.url ?? null)
// ── App state ────────────────────────────────────────────────────────────
ipcMain.handle('electron:is-first-launch', () => isFirstLaunch())
ipcMain.handle('electron:mark-first-launch-complete', () => markFirstLaunchComplete())
ipcMain.handle('electron:get-app-version', () => app.getVersion())
}
/**
* Create the main application window
*/
function createWindow() {
const isMac = process.platform === 'darwin'
// Restore window bounds from store
const bounds = getWindowBounds()
mainWindow = new BrowserWindow({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
minWidth: 800,
minHeight: 600,
// macOS: hide native title bar but keep traffic light buttons
...(isMac
? {
titleBarStyle: 'hidden' as const,
trafficLightPosition: { x: 12, y: 8 },
}
: {}),
// Windows/Linux: overlay title bar (custom title bar integrated)
...(!isMac
? {
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 32,
},
}
: {}),
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
})
// Load the app using app:// protocol
// electron-vite will handle serving the renderer from app://host/index.html
if (process.env.ELECTRON_RENDERER_URL) {
// Development: load from electron-vite dev server
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
// Production: load from bundled renderer
mainWindow.loadURL('app://host/index.html')
}
// Persist window size/position on close
mainWindow.on('close', () => {
if (mainWindow) {
const { x, y, width, height } = mainWindow.getBounds()
setWindowBounds({ x, y, width, height })
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Push maximize/unmaximize events to renderer
mainWindow.on('maximize', () => {
mainWindow?.webContents.send('electron:window-maximized')
})
mainWindow.on('unmaximize', () => {
mainWindow?.webContents.send('electron:window-unmaximized')
})
// 窗口获得焦点时确保焦点传递到 webContents支持屏幕阅读器正确工作
mainWindow.on('focus', () => {
mainWindow?.webContents.focus()
})
}
/**
* App event: when app is ready
*/
app.whenReady().then(() => {
// 确保 Chromium a11y tree 始终激活(供屏幕阅读器使用)
app.setAccessibilitySupportEnabled(true)
registerAppProtocol()
// Set Content Security Policy
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self' app:; " +
"script-src 'self' 'unsafe-inline' app:; " +
"style-src 'self' 'unsafe-inline' app:; " +
"img-src 'self' app: data: blob:; " +
"font-src 'self' app: data:; " +
"connect-src 'self' app: ws: wss: http: https:; " +
"worker-src 'self' blob:;"
],
},
})
})
registerIpcHandlers()
createWindow()
})
/**
* App event: when all windows are closed (non-macOS behavior)
*/
app.on('window-all-closed', () => {
// On macOS, applications typically stay open until the user quits
if (process.platform !== 'darwin') {
app.quit()
}
})
/**
* App event: when app is activated (macOS)
*/
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
registerAppScheme()

View File

@@ -0,0 +1,89 @@
import { net, protocol } from 'electron'
import { readFile } from 'fs/promises'
import { dirname, extname, join } from 'path'
import { fileURLToPath } from 'url'
import { getActiveBackend } from './store'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.cjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.txt': 'text/plain',
'.webp': 'image/webp',
}
export function registerAppProtocol(): void {
protocol.handle('app', async (request) => {
const url = new URL(request.url)
const pathname = url.pathname
if (pathname.startsWith('/api/')) {
const backend = getActiveBackend()
const targetUrl = backend
? `${backend.url.replace(/\/$/, '')}${pathname}${url.search}`
: null
if (!targetUrl) {
return new Response(JSON.stringify({ error: 'No backend configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
}
const headers = new Headers(request.headers)
headers.delete('host')
return net.fetch(targetUrl, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
duplex: 'half',
})
}
// Dev mode: renderer is served by vite dev server, not app:// protocol
if (process.env.ELECTRON_RENDERER_URL) {
return new Response(null, { status: 204 })
}
const rendererDir = join(__dirname, '../renderer')
const safePath = decodeURIComponent(pathname)
.replace(/\.\./g, '')
.replace(/^\/+/, '')
const resolvedPath = safePath === '' ? 'index.html' : safePath
const filePath = resolvedPath.endsWith('/')
? join(rendererDir, resolvedPath, 'index.html')
: join(rendererDir, resolvedPath)
const tryReadFile = async (path: string) => {
const ext = extname(path)
const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream'
const data = await readFile(path)
return new Response(data, { headers: { 'Content-Type': mimeType } })
}
try {
return await tryReadFile(filePath)
} catch {
const indexPath = join(rendererDir, 'index.html')
return tryReadFile(indexPath)
}
})
}

View File

@@ -0,0 +1,215 @@
import { randomUUID } from 'crypto'
import Store, { type Schema } from 'electron-store'
/**
* Backend connection data model
*/
export interface BackendConnection {
id: string
name: string
url: string
isDefault: boolean
lastConnected?: number
}
/**
* Application settings data model
*/
export interface AppSettings {
backends: BackendConnection[]
activeBackendId: string | null
windowBounds: {
x: number
y: number
width: number
height: number
}
firstLaunchComplete: boolean
}
/**
* JSON Schema for validating store contents
*/
const SCHEMA: Schema<AppSettings> = {
backends: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
url: { type: 'string' },
isDefault: { type: 'boolean' },
lastConnected: { type: 'number' },
},
required: ['id', 'name', 'url', 'isDefault'],
},
},
activeBackendId: { type: ['string', 'null'] },
windowBounds: {
type: 'object',
properties: {
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
},
required: ['x', 'y', 'width', 'height'],
},
firstLaunchComplete: { type: 'boolean' },
}
/**
* Default settings
*/
const DEFAULTS: AppSettings = {
backends: [],
activeBackendId: null,
windowBounds: {
x: 100,
y: 100,
width: 1280,
height: 800,
},
firstLaunchComplete: false,
}
/**
* Initialize electron-store with encryption and schema validation
*/
const store = new Store<AppSettings>({
schema: SCHEMA,
defaults: DEFAULTS,
encryptionKey: process.env.MAIBOT_STORE_KEY,
})
/**
* Get all backends
*/
export function getBackends(): BackendConnection[] {
return store.get('backends', [])
}
/**
* Add a new backend connection
* Generates UUID for new backend
*/
export function addBackend(
conn: Omit<BackendConnection, 'id'>,
): BackendConnection {
const newBackend: BackendConnection = {
...conn,
id: randomUUID(),
}
const backends = getBackends()
backends.push(newBackend)
store.set('backends', backends)
return newBackend
}
/**
* Update an existing backend connection
*/
export function updateBackend(
id: string,
patch: Partial<Omit<BackendConnection, 'id'>>,
): void {
const backends = getBackends()
const index = backends.findIndex((b) => b.id === id)
if (index === -1) {
throw new Error(`Backend with id ${id} not found`)
}
backends[index] = {
...backends[index],
...patch,
}
store.set('backends', backends)
}
/**
* Remove a backend connection by id
*/
export function removeBackend(id: string): void {
const backends = getBackends()
const filtered = backends.filter((b) => b.id !== id)
store.set('backends', filtered)
// Clear active backend if it was the removed one
if (store.get('activeBackendId') === id) {
store.set('activeBackendId', null)
}
}
/**
* Set the active backend
*/
export function setActiveBackend(id: string): void {
const backends = getBackends()
if (!backends.find((b) => b.id === id)) {
throw new Error(`Backend with id ${id} not found`)
}
store.set('activeBackendId', id)
}
/**
* Get the currently active backend connection
*/
export function getActiveBackend(): BackendConnection | null {
const activeId = store.get('activeBackendId')
if (!activeId) {
return null
}
const backends = getBackends()
return backends.find((b) => b.id === activeId) || null
}
/**
* Get window bounds
*/
export function getWindowBounds(): AppSettings['windowBounds'] {
return store.get('windowBounds', DEFAULTS.windowBounds)
}
/**
* Set window bounds
*/
export function setWindowBounds(bounds: AppSettings['windowBounds']): void {
store.set('windowBounds', bounds)
}
/**
* Check if this is the first launch
*/
export function isFirstLaunch(): boolean {
return !store.get('firstLaunchComplete', false)
}
/**
* Mark first launch as complete
*/
export function markFirstLaunchComplete(): void {
store.set('firstLaunchComplete', true)
}
/**
* Get complete app settings
*/
export function getSettings(): AppSettings {
return {
backends: getBackends(),
activeBackendId: store.get('activeBackendId', null),
windowBounds: getWindowBounds(),
firstLaunchComplete: store.get('firstLaunchComplete', false),
}
}

View File

@@ -0,0 +1,56 @@
import { contextBridge, ipcRenderer } from 'electron'
// Write __RUNTIME__ tag into the isolated world so renderer can detect Electron
contextBridge.exposeInMainWorld('__RUNTIME__', {
kind: 'electron' as const,
versions: process.versions as unknown as Record<string, string>,
source: 'tag' as const,
})
// Expose the full ElectronAPI surface to the renderer process
contextBridge.exposeInMainWorld('electronAPI', {
// ── Platform detection ──────────────────────────────────────────────────
getPlatform: () => process.platform,
// ── Window control ──────────────────────────────────────────────────────
minimizeWindow: () => ipcRenderer.invoke('electron:minimize-window'),
maximizeWindow: () => ipcRenderer.invoke('electron:maximize-window'),
closeWindow: () => ipcRenderer.invoke('electron:close-window'),
isMaximized: () => ipcRenderer.invoke('electron:is-maximized'),
// ── Window event listeners ───────────────────────────────────────────────
onWindowMaximized: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('electron:window-maximized', listener)
return () => ipcRenderer.removeListener('electron:window-maximized', listener)
},
onWindowUnmaximized: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('electron:window-unmaximized', listener)
return () => ipcRenderer.removeListener('electron:window-unmaximized', listener)
},
// ── Backend CRUD ─────────────────────────────────────────────────────────
getBackends: () => ipcRenderer.invoke('electron:get-backends'),
addBackend: (conn: object) => ipcRenderer.invoke('electron:add-backend', conn),
updateBackend: (id: string, patch: object) =>
ipcRenderer.invoke('electron:update-backend', id, patch),
removeBackend: (id: string) => ipcRenderer.invoke('electron:remove-backend', id),
setActiveBackend: (id: string) =>
ipcRenderer.invoke('electron:set-active-backend', id),
getActiveBackend: () => ipcRenderer.invoke('electron:get-active-backend'),
getActiveBackendUrl: () => ipcRenderer.invoke('electron:get-active-url'),
// ── App state ───────────────────────────────────────────────────────────
isFirstLaunch: () => ipcRenderer.invoke('electron:is-first-launch'),
markFirstLaunchComplete: () =>
ipcRenderer.invoke('electron:mark-first-launch-complete'),
getAppVersion: () => ipcRenderer.invoke('electron:get-app-version'),
// ── Backend event listener ──────────────────────────────────────────────
onBackendChanged: (callback: (backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => void) => {
const listener = (_event: unknown, backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => callback(backend)
ipcRenderer.on('electron:backend-changed', listener)
return () => ipcRenderer.removeListener('electron:backend-changed', listener)
},
})

View File

View File

@@ -0,0 +1,44 @@
import js from '@eslint/js'
import globals from 'globals'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'out'] },
jsxA11y.flatConfigs.recommended,
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
// 将所有 React Hooks 推荐规则降级为警告
...Object.keys(reactHooks.configs.recommended.rules).reduce((acc, key) => {
acc[key] = 'warn'
return acc
}, {}),
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// 关闭或降级其他规则
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
// jsx-a11y: 降级为 warn 避免阻塞构建,后续 Task 17 逐步修复
'jsx-a11y/anchor-ambiguous-text': 'warn',
'jsx-a11y/no-autofocus': 'warn',
},
},
{
files: ['**/*.d.ts'],
rules: {
// Ambient global declarations use `var` in TypeScript declaration files.
'no-var': 'off',
},
}
)

30
dashboard/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="zh-CN" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="google" content="notranslate" />
<meta http-equiv="content-language" content="zh-CN" />
<!-- 防止搜索引擎索引 -->
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="googlebot" content="noindex, nofollow" />
<meta name="bingbot" content="noindex, nofollow" />
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaiBot Dashboard</title>
<script>
(function() {
const mode = localStorage.getItem('maibot-theme-mode')
|| localStorage.getItem('ui-theme')
|| localStorage.getItem('maibot-ui-theme');
const theme = mode === 'system' || !mode
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: mode;
document.documentElement.classList.add(theme);
})();
</script>
</head>
<body>
<div id="root" class="notranslate"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17778
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

192
dashboard/package.json Normal file
View File

@@ -0,0 +1,192 @@
{
"name": "maibot-dashboard",
"private": true,
"version": "1.0.10",
"type": "module",
"main": "./out/main/index.js",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"test": "vitest",
"test:ui": "vitest --ui",
"electron:dev": "electron-vite dev",
"electron:build": "electron-vite build",
"electron:preview": "electron-vite preview",
"electron:dist": "electron-vite build && electron-builder",
"electron:dist:mac": "electron-vite build && electron-builder --mac",
"electron:dist:win": "electron-vite build && electron-builder --win",
"electron:dist:linux": "electron-vite build && electron-builder --linux"
},
"build": {
"appId": "org.maibot.dashboard",
"productName": "MaiBot Dashboard",
"directories": {
"output": "dist-electron",
"buildResources": "electron/resources"
},
"files": [
"out/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.utilities",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
}
],
"icon": "electron/resources/icon.icns",
"darkModeSupport": true
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "electron/resources/icon.ico"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
}
],
"icon": "electron/resources/icon.png",
"category": "Utility"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
}
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "10.0.3",
"@tanstack/react-router": "^1.140.0",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.140.0",
"@types/dagre": "^0.7.53",
"@uiw/react-codemirror": "^4.25.3",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.0",
"@uppy/react": "^5.1.1",
"@uppy/xhr-upload": "^5.1.1",
"@use-gesture/react": "^10.3.1",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"katex": "^0.16.27",
"lucide-react": "^0.556.0",
"motion": "^12.38.0",
"react": "^19.2.1",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.1",
"react-i18next": "^16.5.4",
"react-joyride": "3.0.0-7",
"react-markdown": "^10.1.0",
"reactflow": "^11.11.4",
"recharts": "3.5.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"smol-toml": "^1.5.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@eslint/js": "^9.39.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.18",
"electron": "^40.6.1",
"electron-builder": "^26.8.1",
"electron-store": "11.0.2",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.49.0",
"vite": "^7.2.7",
"vitest": "^4.0.18",
"eslint-plugin-jsx-a11y": "^6.10.2"
}
}

Binary file not shown.

BIN
dashboard/public/maimai.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -0,0 +1,406 @@
const { app, BrowserWindow } = require('electron')
const fs = require('fs')
const path = require('path')
const DASHBOARD_URL = process.env.MAIBOT_DASHBOARD_URL || 'http://127.0.0.1:7999'
const OUTPUT_DIR = process.env.MAIBOT_UI_SNAPSHOT_DIR
|| path.resolve(__dirname, '..', '..', 'tmp', 'ui-snapshots', 'a_memorix-electron')
const TOKEN_PATH = process.env.MAIBOT_WEBUI_TOKEN_PATH
|| path.resolve(__dirname, '..', '..', 'data', 'webui.json')
const sampleStamp = String(Date.now())
const sampleSource = process.env.MAIBOT_UI_SAMPLE_SOURCE || `webui-demo:a_memorix-json-${sampleStamp}`
const sampleName = process.env.MAIBOT_UI_SAMPLE_NAME || `webui-json-validation-${sampleStamp}.json`
const DEFAULT_SAMPLE = {
paragraphs: [
{
content: 'Alice 在杭州西湖与 Bob 讨论 A_Memorix 的前端接入与 embedding 调优方案。',
source: sampleSource,
entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix'],
relations: [
{ subject: 'Alice', predicate: '在', object: '杭州西湖' },
{ subject: 'Alice', predicate: '讨论', object: 'A_Memorix' },
{ subject: 'Bob', predicate: '讨论', object: 'A_Memorix' },
{ subject: 'Bob', predicate: '负责', object: 'embedding 调优' },
],
knowledge_type: 'factual',
},
],
entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix', 'embedding 调优'],
relations: [{ subject: 'Alice', predicate: '认识', object: 'Bob' }],
}
function loadSampleJson() {
const customPath = String(process.env.MAIBOT_UI_IMPORT_JSON_PATH || '').trim()
if (!customPath) {
return JSON.stringify(DEFAULT_SAMPLE, null, 2)
}
return fs.readFileSync(customPath, 'utf8')
}
const sampleJson = loadSampleJson()
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function exec(win, code) {
return win.webContents.executeJavaScript(code, true)
}
async function waitFor(win, predicateCode, label, timeout = 30000, interval = 300) {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const ok = await exec(win, predicateCode)
if (ok) {
return ok
}
} catch {
// keep polling
}
await wait(interval)
}
throw new Error(`Timeout waiting for ${label}`)
}
async function sendClick(win, x, y) {
win.webContents.sendInputEvent({ type: 'mouseMove', x, y, movementX: 0, movementY: 0 })
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 })
}
async function capture(win, name) {
const image = await win.webContents.capturePage()
fs.writeFileSync(path.join(OUTPUT_DIR, name), image.toPNG())
const text = await exec(win, 'document.body ? document.body.innerText : ""')
fs.writeFileSync(path.join(OUTPUT_DIR, name.replace(/\.png$/, '.txt')), text || '')
}
async function getJson(win, relativePath) {
return exec(
win,
`fetch(${JSON.stringify(relativePath)}, { credentials: 'include' }).then((r) => r.json())`,
)
}
async function setSessionCookie(win) {
const raw = fs.readFileSync(TOKEN_PATH, 'utf8')
const config = JSON.parse(raw)
const token = String(config.access_token || '').trim()
if (!token) {
throw new Error(`No access token found in ${TOKEN_PATH}`)
}
const payload = await exec(
win,
`fetch('/api/webui/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ token: ${JSON.stringify(token)} }),
}).then(async (response) => ({
ok: response.ok,
status: response.status,
body: await response.json(),
}))`,
)
if (!payload?.ok || !payload?.body?.valid) {
throw new Error(`Failed to authenticate WebUI token via /auth/verify: ${JSON.stringify(payload)}`)
}
}
async function openImportTab(win) {
await exec(win, `(() => {
const tab = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').trim() === '导入')
if (!tab) return false
tab.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true }))
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }))
tab.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))
tab.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }))
return true
})()`)
await waitFor(
win,
`document.body && document.body.innerText.includes('粘贴导入') && document.body.innerText.includes('创建导入任务')`,
'import panel',
)
}
async function setJsonMode(win) {
const trigger = await exec(win, `(() => {
const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式'))
const root = label?.closest('div')?.parentElement || label?.parentElement
const button = root?.querySelector('button')
if (!button) return null
const rect = button.getBoundingClientRect()
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
})()`)
if (!trigger) {
throw new Error('select trigger not found')
}
await sendClick(win, trigger.x, trigger.y)
await waitFor(win, `document.querySelectorAll('[role="option"]').length > 0`, 'select options', 5000, 200)
const option = await exec(win, `(() => {
const item = Array.from(document.querySelectorAll('[role="option"]')).find((el) => (el.textContent || '').trim() === 'json')
if (!item) return null
const rect = item.getBoundingClientRect()
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
})()`)
if (!option) {
throw new Error('json option not found')
}
await sendClick(win, option.x, option.y)
await waitFor(
win,
`(() => {
const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式'))
const root = label?.closest('div')?.parentElement || label?.parentElement
const button = root?.querySelector('button')
return (button?.textContent || '').trim() === 'json'
})()`,
'json mode selected',
8000,
300,
)
}
async function typeIntoLabeled(win, labelText, selector, text) {
const rect = await exec(win, `(() => {
const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes(${JSON.stringify(labelText)}))
const root = label?.closest('div')?.parentElement || label?.parentElement
const el = root?.querySelector(${JSON.stringify(selector)})
if (!el) return null
const r = el.getBoundingClientRect()
return { x: Math.round(r.left + 20), y: Math.round(r.top + 20) }
})()`)
if (!rect) {
throw new Error(`field not found: ${labelText}`)
}
await sendClick(win, rect.x, rect.y)
await wait(150)
await win.webContents.insertText(text)
await wait(250)
}
async function clickButton(win, text) {
const ok = await exec(win, `(() => {
const target = Array.from(document.querySelectorAll('button')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)}))
if (!target) return false
target.scrollIntoView({ block: 'center' })
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true }))
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }))
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }))
return true
})()`)
if (!ok) {
throw new Error(`button not found: ${text}`)
}
}
async function clickTab(win, text) {
const ok = await exec(win, `(() => {
const target = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)}))
if (!target) return false
target.scrollIntoView({ block: 'center' })
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true }))
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }))
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }))
return true
})()`)
if (!ok) {
throw new Error(`tab not found: ${text}`)
}
}
async function clickGraphElement(win, selector, index = 0) {
const rect = await exec(win, `(() => {
const targets = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
const target = targets[${index}]
if (!target) return null
target.scrollIntoView({ block: 'center', inline: 'center' })
const r = target.getBoundingClientRect()
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
})()`)
if (!rect) {
throw new Error(`graph element not found: ${selector}[${index}]`)
}
await sendClick(win, rect.x, rect.y)
}
async function capturePluginFilterState(win) {
await win.loadURL(`${DASHBOARD_URL}/plugin-config`)
await waitFor(
win,
`document.body && document.body.innerText.includes('插件配置') && document.querySelector('input[placeholder="搜索插件..."]')`,
'plugin config page',
30000,
400,
)
await exec(win, `(() => {
const input = document.querySelector('input[placeholder="搜索插件..."]')
if (!input) return false
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setter?.call(input, 'memorix')
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
return true
})()`)
await wait(500)
await capture(win, '01-plugin-config-filtered.png')
}
app.whenReady().then(async () => {
const win = new BrowserWindow({
width: 1600,
height: 1200,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
})
await win.loadURL(`${DASHBOARD_URL}/auth`)
await waitFor(win, `document.readyState === 'complete'`, 'auth page')
await capture(win, '00-auth-login.png')
await setSessionCookie(win)
await capturePluginFilterState(win)
await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-base`)
await waitFor(
win,
`document.body && document.body.innerText.includes('运行时自检') && document.body.innerText.includes('刷新数据')`,
'memory console ready',
30000,
500,
)
await capture(win, '02-memory-console-before-import.png')
const beforeGraph = await getJson(win, '/api/webui/memory/graph?limit=120')
const beforeTasks = await getJson(win, '/api/webui/memory/import/tasks?limit=20')
const knownTaskIds = new Set(
Array.isArray(beforeTasks.items)
? beforeTasks.items.map((item) => String(item.task_id || item.taskId || ''))
: [],
)
await openImportTab(win)
await setJsonMode(win)
await typeIntoLabeled(win, '名称', 'input', sampleName)
await typeIntoLabeled(win, '粘贴内容', 'textarea', sampleJson)
await capture(win, '03-memory-import-json-filled.png')
await clickButton(win, '创建导入任务')
let taskId = null
let taskStatus = null
const start = Date.now()
while (Date.now() - start < 120000) {
const payload = await getJson(win, '/api/webui/memory/import/tasks?limit=20')
fs.writeFileSync(path.join(OUTPUT_DIR, 'tasks-last.json'), JSON.stringify(payload, null, 2))
const items = Array.isArray(payload.items) ? payload.items : []
const task = items.find((item) => !knownTaskIds.has(String(item.task_id || item.taskId || '')))
if (task) {
taskId = task.task_id || task.taskId || null
taskStatus = task.status || null
if (['completed', 'failed', 'cancelled'].includes(String(taskStatus))) {
break
}
}
await wait(1500)
}
if (!taskId) {
throw new Error('new json import task not observed')
}
const detail = await getJson(
win,
`/api/webui/memory/import/tasks/${encodeURIComponent(taskId)}?include_chunks=true`,
)
fs.writeFileSync(path.join(OUTPUT_DIR, 'task-detail.json'), JSON.stringify(detail, null, 2))
fs.writeFileSync(
path.join(OUTPUT_DIR, 'task-status.txt'),
`taskId=${taskId}\nstatus=${taskStatus}\nsource=${sampleSource}\n`,
)
await clickButton(win, '刷新数据')
await wait(2000)
await capture(win, '04-memory-console-after-import.png')
await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-graph`)
await waitFor(
win,
`document.body && document.body.innerText.includes('长期记忆图谱') && document.body.innerText.includes('实体关系图') && document.body.innerText.includes('证据视图')`,
'graph page ready',
30000,
400,
)
await wait(3000)
const afterGraph = await getJson(win, '/api/webui/memory/graph?limit=120')
fs.writeFileSync(path.join(OUTPUT_DIR, 'graph-after.json'), JSON.stringify(afterGraph, null, 2))
await capture(win, '05-memory-graph-after-import.png')
if (Array.isArray(afterGraph.nodes) && afterGraph.nodes.length > 0) {
await clickGraphElement(win, '.react-flow__node', 0)
await waitFor(win, `document.body && document.body.innerText.includes('实体详情')`, 'node detail dialog', 10000, 250)
await capture(win, '06-memory-node-detail.png')
try {
await clickButton(win, '切到证据视图')
await waitFor(
win,
`document.body && document.body.innerText.includes('证据视图') && document.querySelectorAll('.react-flow__node').length > 0`,
'evidence graph after node click',
10000,
250,
)
await capture(win, '07-memory-evidence-view.png')
} catch (error) {
fs.writeFileSync(path.join(OUTPUT_DIR, '07-memory-evidence-view-error.txt'), String(error?.stack || error))
}
}
if (Array.isArray(afterGraph.edges) && afterGraph.edges.length > 0) {
try {
await clickTab(win, '实体关系图')
await wait(800)
await clickGraphElement(win, '.react-flow__edge', 0)
await waitFor(win, `document.body && document.body.innerText.includes('关系详情')`, 'edge detail dialog', 10000, 250)
await capture(win, '08-memory-edge-detail.png')
} catch (error) {
fs.writeFileSync(path.join(OUTPUT_DIR, '08-memory-edge-detail-error.txt'), String(error?.stack || error))
}
}
const summary = {
before: {
nodes: beforeGraph.total_nodes,
edges: beforeGraph.total_edges,
},
after: {
nodes: afterGraph.total_nodes,
edges: afterGraph.total_edges,
},
taskId,
taskStatus,
source: sampleSource,
inputMode: detail?.task?.files?.[0]?.input_mode || null,
strategyType: detail?.task?.files?.[0]?.detected_strategy_type || null,
fileStatus: detail?.task?.files?.[0]?.status || null,
outputDir: OUTPUT_DIR,
}
fs.writeFileSync(path.join(OUTPUT_DIR, 'validation-summary.json'), JSON.stringify(summary, null, 2))
console.log(JSON.stringify(summary, null, 2))
await win.close()
app.quit()
}).catch((error) => {
console.error(error)
app.exit(1)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,54 @@
import { lazy, Suspense } from 'react'
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
export interface CodeEditorProps {
value: string
onChange?: (value: string) => void
language?: Language
readOnly?: boolean
height?: string
minHeight?: string
maxHeight?: string
placeholder?: string
theme?: 'light' | 'dark'
className?: string
}
const CodeEditorImpl = lazy(() => import('./CodeEditorImpl'))
function CodeEditorFallback({
height,
minHeight,
maxHeight,
className = '',
}: Pick<CodeEditorProps, 'height' | 'minHeight' | 'maxHeight' | 'className'>) {
return (
<div
className={`bg-muted animate-pulse rounded-md border ${className}`}
style={{ height, minHeight, maxHeight }}
/>
)
}
export function CodeEditor(props: CodeEditorProps) {
const { height = '400px', minHeight, maxHeight, className = '' } = props
return (
<Suspense
fallback={
<CodeEditorFallback
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
className={className}
/>
}
>
<CodeEditorImpl {...props} />
</Suspense>
)
}
export default CodeEditor

View File

@@ -0,0 +1,105 @@
import { css } from '@codemirror/lang-css'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { StreamLanguage } from '@codemirror/language'
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
import { linter } from '@codemirror/lint'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import CodeMirror from '@uiw/react-codemirror'
import { useTheme } from '@/components/use-theme'
import type { CodeEditorProps, Language } from './CodeEditor'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), linter(jsonParseLinter())],
toml: [StreamLanguage.define(tomlMode)],
css: [css()],
text: [],
}
export default function CodeEditorImpl({
value,
onChange,
language = 'text',
readOnly = false,
height = '400px',
minHeight,
maxHeight,
placeholder,
theme,
className = '',
}: CodeEditorProps) {
const { resolvedTheme } = useTheme()
const extensions = [
...(languageExtensions[language] || []),
EditorView.lineWrapping,
// 应用 JetBrains Mono 字体
EditorView.theme({
'&': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-content': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-gutters': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-scroller': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
}),
]
if (readOnly) {
extensions.push(EditorView.editable.of(false))
}
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
const effectiveTheme = theme ?? resolvedTheme
return (
<div className={`custom-scrollbar overflow-hidden rounded-md border ${className}`}>
<CodeMirror
value={value}
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
theme={effectiveTheme === 'dark' ? oneDark : undefined}
extensions={extensions}
onChange={onChange}
placeholder={placeholder}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,525 @@
/**
* ListFieldEditor - 动态数组字段编辑器
*
* 支持功能:
* - 字符串数组 (string[])
* - 数字数组 (number[])
* - 对象数组 (object[]) - 根据 item_fields 定义渲染
* - 拖拽排序
* - 动态增删项
*/
import { useState, useCallback, useMemo } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
// ============ 类型定义 ============
export interface ItemFieldDefinition {
/** 字段类型: "string" | "number" | "boolean" | "select" */
type: string
label?: string
placeholder?: string
default?: unknown
/** select 类型的选项 */
choices?: unknown[]
/** slider 类型的最小值 */
min?: number
/** slider 类型的最大值 */
max?: number
/** slider 类型的步进 */
step?: number
}
export interface ListFieldEditorProps {
/** 当前值 */
value: unknown[] | unknown
/** 值变化回调 */
onChange: (value: unknown[]) => void
/** 数组元素类型: "string" | "number" | "object" */
itemType?: string
/** 当 itemType="object" 时的字段定义 */
itemFields?: Record<string, ItemFieldDefinition>
/** 最小元素数量 */
minItems?: number
/** 最大元素数量 */
maxItems?: number
/** 是否禁用 */
disabled?: boolean
/** 新项的占位符文字 */
placeholder?: string
}
// ============ 可排序项组件 ============
interface SortableItemProps {
id: string
index: number
itemType: string
itemFields?: Record<string, ItemFieldDefinition>
value: unknown
onChange: (value: unknown) => void
onRemove: () => void
disabled?: boolean
canRemove: boolean
placeholder?: string
}
function SortableItem({
id,
index,
itemType,
itemFields,
value,
onChange,
onRemove,
disabled,
canRemove,
placeholder,
}: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-start gap-2 group',
isDragging && 'opacity-50 z-50'
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
'flex-shrink-0 p-2 cursor-grab active:cursor-grabbing',
'text-muted-foreground hover:text-foreground transition-colors',
'opacity-0 group-hover:opacity-100 focus:opacity-100',
disabled && 'cursor-not-allowed opacity-30'
)}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* 内容区域 */}
<div className="flex-1 min-w-0">
{itemType === 'object' && itemFields ? (
<ObjectItemEditor
value={value as Record<string, unknown>}
onChange={onChange}
fields={itemFields}
disabled={disabled}
/>
) : itemType === 'number' ? (
<Input
type="number"
value={value as number ?? ''}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
placeholder={placeholder ?? `${index + 1}`}
disabled={disabled}
className="font-mono"
/>
) : (
<Input
type="text"
value={value as string ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? `${index + 1}`}
disabled={disabled}
/>
)}
</div>
{/* 删除按钮 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
disabled={disabled || !canRemove}
className={cn(
'flex-shrink-0 text-muted-foreground hover:text-destructive',
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity'
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
}
// ============ 对象项编辑器 ============
interface ObjectItemEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
fields: Record<string, ItemFieldDefinition>
disabled?: boolean
}
function ObjectItemEditor({
value,
onChange,
fields,
disabled,
}: ObjectItemEditorProps) {
const handleFieldChange = useCallback(
(fieldName: string, fieldValue: unknown) => {
onChange({
...value,
[fieldName]: fieldValue,
})
},
[value, onChange]
)
const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => {
const fieldValue = value?.[fieldName]
// boolean / switch
if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') {
return (
<div className="flex items-center justify-between py-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Switch
checked={Boolean(fieldValue ?? fieldDef.default)}
onCheckedChange={(checked) => handleFieldChange(fieldName, checked)}
disabled={disabled}
/>
</div>
)
}
// slider (number with min/max)
if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) {
const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<span className="text-xs text-muted-foreground">{numValue}</span>
</div>
<Slider
value={[numValue]}
onValueChange={(v) => handleFieldChange(fieldName, v[0])}
min={fieldDef.min ?? 0}
max={fieldDef.max ?? 100}
step={fieldDef.step ?? 1}
disabled={disabled}
className="py-1"
/>
</div>
)
}
// select
if (fieldDef.type === 'select' && fieldDef.choices) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Select
value={String(fieldValue ?? fieldDef.default ?? '')}
onValueChange={(v) => handleFieldChange(fieldName, v)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={fieldDef.placeholder ?? '请选择'} />
</SelectTrigger>
<SelectContent>
{fieldDef.choices.map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
// number
if (fieldDef.type === 'number') {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Input
type="number"
value={(fieldValue as number) ?? fieldDef.default ?? ''}
onChange={(e) =>
handleFieldChange(fieldName, parseFloat(e.target.value) || 0)
}
placeholder={fieldDef.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
)
}
// string (default)
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{fieldDef.label ?? fieldName}
</Label>
<Input
type="text"
value={(fieldValue as string) ?? fieldDef.default ?? ''}
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
placeholder={fieldDef.placeholder}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
)
}
return (
<Card className="p-3 space-y-2 bg-muted/30">
{Object.entries(fields).map(([fieldName, fieldDef]) => (
<div key={fieldName}>
{renderField(fieldName, fieldDef)}
</div>
))}
</Card>
)
}
// ============ 主组件 ============
export function ListFieldEditor({
value,
onChange,
itemType = 'string',
itemFields,
minItems,
maxItems,
disabled,
placeholder,
}: ListFieldEditorProps) {
// 确保 value 是数组
const items: unknown[] = useMemo(() => {
if (Array.isArray(value)) return value
if (typeof value === 'string' && value.trim()) {
// 尝试解析逗号分隔的字符串
return value.split(',').map((s: string) => s.trim())
}
return []
}, [value])
// 为每个项生成稳定的 ID
const [itemIds] = useState(() => new Map<number, string>())
const getItemId = useCallback(
(index: number) => {
if (!itemIds.has(index)) {
itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`)
}
return itemIds.get(index)!
},
[itemIds]
)
// 同步 itemIds
const sortableIds = useMemo(() => {
// 清理多余的 ID
const newIds: string[] = []
for (let i = 0; i < items.length; i++) {
newIds.push(getItemId(i))
}
return newIds
}, [items.length, getItemId])
// DnD 传感器配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// 拖拽结束处理
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = sortableIds.indexOf(active.id as string)
const newIndex = sortableIds.indexOf(over.id as string)
const newItems = arrayMove(items, oldIndex, newIndex)
onChange(newItems)
}
},
[items, sortableIds, onChange]
)
// 添加新项
const handleAddItem = useCallback(() => {
if (maxItems != null && items.length >= maxItems) return
let newItem: unknown
if (itemType === 'object' && itemFields) {
// 创建包含默认值的对象
newItem = Object.fromEntries(
Object.entries(itemFields).map(([k, v]) => [k, v.default ?? ''])
)
} else if (itemType === 'number') {
newItem = 0
} else {
newItem = ''
}
onChange([...items, newItem])
}, [items, maxItems, itemType, itemFields, onChange])
// 修改项
const handleItemChange = useCallback(
(index: number, newValue: unknown) => {
const newItems = [...items]
newItems[index] = newValue
onChange(newItems)
},
[items, onChange]
)
// 删除项
const handleRemoveItem = useCallback(
(index: number) => {
if (minItems != null && items.length <= minItems) return
const newItems = items.filter((_: unknown, i: number) => i !== index)
// 清理 itemIds 映射
itemIds.delete(index)
onChange(newItems)
},
[items, minItems, itemIds, onChange]
)
const canAdd = maxItems == null || items.length < maxItems
const canRemove = minItems == null || items.length > minItems
return (
<div className="space-y-2">
{/* 列表项 */}
{items.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center border border-dashed rounded-md">
<AlertCircle className="h-4 w-4" />
<span></span>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item: unknown, index: number) => (
<SortableItem
key={sortableIds[index]}
id={sortableIds[index]}
index={index}
itemType={itemType}
itemFields={itemFields}
value={item}
onChange={(newValue) => handleItemChange(index, newValue)}
onRemove={() => handleRemoveItem(index)}
disabled={disabled}
canRemove={canRemove}
placeholder={placeholder}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* 添加按钮 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddItem}
disabled={disabled || !canAdd}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
{maxItems !== undefined && (
<span className="ml-2 text-xs text-muted-foreground">
({items.length}/{maxItems})
</span>
)}
</Button>
{/* 限制提示 */}
{(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && (
<p className="text-xs text-muted-foreground text-center">
{minItems != null && maxItems != null
? `允许 ${minItems} - ${maxItems}`
: minItems != null
? `至少 ${minItems}`
: `最多 ${maxItems}`}
</p>
)}
</div>
)
}
export default ListFieldEditor

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'
import { Progress } from '@/components/ui/progress'
/**
* @deprecated 请使用新的 RestartOverlay 组件
* import { RestartOverlay } from '@/components/restart-overlay'
*/
interface RestartingOverlayProps {
onRestartComplete?: () => void
onRestartFailed?: () => void
}
/**
* @deprecated 请使用新的 RestartOverlay 组件
* import { RestartOverlay } from '@/components/restart-overlay'
*/
export function RestartingOverlay({ onRestartComplete, onRestartFailed }: RestartingOverlayProps) {
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState<'restarting' | 'checking' | 'success' | 'failed'>('restarting')
const [elapsedTime, setElapsedTime] = useState(0)
const [checkAttempts, setCheckAttempts] = useState(0)
useEffect(() => {
// 进度条动画
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 90) return prev
return prev + 1
})
}, 200)
// 计时器
const timerInterval = setInterval(() => {
setElapsedTime((prev) => prev + 1)
}, 1000)
// 等待3秒后开始检查状态给后端重启时间
const initialDelay = setTimeout(() => {
setStatus('checking')
startHealthCheck()
}, 3000)
return () => {
clearInterval(progressInterval)
clearInterval(timerInterval)
clearTimeout(initialDelay)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const startHealthCheck = () => {
const maxAttempts = 60 // 最多尝试60次约2分钟
const checkHealth = async () => {
try {
setCheckAttempts((prev) => prev + 1)
const response = await fetch('/api/webui/system/status', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(3000), // 3秒超时
})
if (response.ok) {
// 重启成功
setProgress(100)
setStatus('success')
setTimeout(() => {
onRestartComplete?.()
}, 1500)
} else {
throw new Error('Status check failed')
}
} catch {
// 继续尝试
if (checkAttempts < maxAttempts) {
setTimeout(checkHealth, 2000) // 2秒后重试
} else {
// 超过最大尝试次数
setStatus('failed')
onRestartFailed?.()
}
}
}
checkHealth()
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div className="fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="max-w-md w-full mx-4 space-y-8">
{/* 图标和状态 */}
<div className="flex flex-col items-center space-y-4">
{status === 'restarting' && (
<>
<Loader2 className="h-16 w-16 text-primary animate-spin" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
...
</p>
</>
)}
{status === 'checking' && (
<>
<Loader2 className="h-16 w-16 text-primary animate-spin" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
... ( {checkAttempts}/60)
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="h-16 w-16 text-green-500" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
...
</p>
</>
)}
{status === 'failed' && (
<>
<AlertCircle className="h-16 w-16 text-destructive" />
<h2 className="text-2xl font-bold"></h2>
<p className="text-muted-foreground text-center">
</p>
</>
)}
</div>
{/* 进度条 */}
{status !== 'failed' && (
<div className="space-y-2">
<Progress value={progress} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress}%</span>
<span>: {formatTime(elapsedTime)}</span>
</div>
</div>
)}
{/* 提示信息 */}
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<p className="text-sm text-muted-foreground">
{status === 'restarting' && '🔄 配置已保存,正在重启主程序...'}
{status === 'checking' && '⏳ 正在等待服务恢复,请勿关闭页面...'}
{status === 'success' && '✅ 配置已生效,服务运行正常'}
{status === 'failed' && '⚠️ 如果长时间无响应,请尝试手动重启'}
</p>
</div>
{/* 失败时的操作按钮 */}
{status === 'failed' && (
<div className="flex gap-2">
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
</button>
<button
onClick={() => {
setStatus('checking')
setCheckAttempts(0)
startHealthCheck()
}}
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { AnimationContext } from '@/lib/animation-context'
type AnimationProviderProps = {
children: ReactNode
defaultEnabled?: boolean
defaultWavesEnabled?: boolean
storageKey?: string
wavesStorageKey?: string
}
export function AnimationProvider({
children,
defaultEnabled = true,
defaultWavesEnabled = true,
storageKey = 'enable-animations',
wavesStorageKey = 'enable-waves-background',
}: AnimationProviderProps) {
const [enableAnimations, setEnableAnimations] = useState<boolean>(() => {
const stored = localStorage.getItem(storageKey)
return stored !== null ? stored === 'true' : defaultEnabled
})
const [enableWavesBackground, setEnableWavesBackground] = useState<boolean>(() => {
const stored = localStorage.getItem(wavesStorageKey)
return stored !== null ? stored === 'true' : defaultWavesEnabled
})
useEffect(() => {
const root = document.documentElement
if (enableAnimations) {
root.classList.remove('no-animations')
} else {
root.classList.add('no-animations')
}
localStorage.setItem(storageKey, String(enableAnimations))
}, [enableAnimations, storageKey])
useEffect(() => {
localStorage.setItem(wavesStorageKey, String(enableWavesBackground))
}, [enableWavesBackground, wavesStorageKey])
const value = {
enableAnimations,
setEnableAnimations,
enableWavesBackground,
setEnableWavesBackground,
}
return <AnimationContext value={value}>{children}</AnimationContext>
}

View File

@@ -0,0 +1,64 @@
import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
import type { ReactNode } from 'react'
import { getAsset } from '@/lib/asset-store'
type AssetStoreContextType = {
getAssetUrl: (assetId: string) => Promise<string | undefined>
}
const AssetStoreContext = createContext<AssetStoreContextType | null>(null)
type AssetStoreProviderProps = {
children: ReactNode
}
export function AssetStoreProvider({ children }: AssetStoreProviderProps) {
const urlCache = useRef<Map<string, string>>(new Map())
const getAssetUrl = async (assetId: string): Promise<string | undefined> => {
// Check cache first
const cached = urlCache.current.get(assetId)
if (cached) {
return cached
}
// Fetch from IndexedDB
const record = await getAsset(assetId)
if (!record) {
return undefined
}
// Create blob URL and cache it
const url = URL.createObjectURL(record.blob)
urlCache.current.set(assetId, url)
return url
}
const value = useMemo(
() => ({
getAssetUrl,
}),
[],
)
// Cleanup: revoke all blob URLs on unmount
useEffect(() => {
return () => {
urlCache.current.forEach((url) => {
URL.revokeObjectURL(url)
})
urlCache.current.clear()
}
}, [])
return <AssetStoreContext value={value}>{children}</AssetStoreContext>
}
export function useAssetStore() {
const context = useContext(AssetStoreContext)
if (!context) {
throw new Error('useAssetStore must be used within AssetStoreProvider')
}
return context
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState, useRef } from 'react'
import { ArrowUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
export function BackToTop() {
const [progress, setProgress] = useState(0)
const [visible, setVisible] = useState(false)
const scrollerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
// 简单的启发式:如果是主要滚动容器(通常高度较大)
// 我们假设页面中主要的滚动区域是高度最大的那个,或者就是当前触发滚动的这个
// 只要它有足够的滚动空间
if (target.scrollHeight > target.clientHeight + 100) {
scrollerRef.current = target
const scrollTop = target.scrollTop
const height = target.scrollHeight - target.clientHeight
const scrolled = height > 0 ? (scrollTop / height) * 100 : 0
setProgress(scrolled)
setVisible(scrollTop > 300)
}
}
// 使用捕获阶段监听所有滚动事件,因为 scroll 事件不冒泡
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
return () => window.removeEventListener('scroll', handleScroll, { capture: true })
}, [])
const scrollToTop = () => {
scrollerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}
// SVG 环形进度条参数
const radius = 18
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference - (progress / 100) * circumference
return (
<div
className={cn(
"fixed bottom-24 right-8 z-50 transition-all duration-500 ease-in-out transform",
visible ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0 pointer-events-none"
)}
>
<Button
variant="outline"
size="icon"
className={cn(
"relative h-12 w-12 rounded-full shadow-xl",
"bg-background/80 backdrop-blur-md border-border/50",
"hover:bg-accent hover:scale-110 hover:shadow-2xl hover:border-primary/50",
"transition-all duration-300",
"group"
)}
onClick={scrollToTop}
aria-label="回到顶部"
>
{/* 进度环背景 */}
<svg className="absolute inset-0 h-full w-full -rotate-90 transform p-1" viewBox="0 0 44 44">
<circle
className="text-muted-foreground/10"
strokeWidth="3"
stroke="currentColor"
fill="transparent"
r={radius}
cx="22"
cy="22"
/>
{/* 进度环 */}
<circle
className="text-primary transition-all duration-100 ease-out"
strokeWidth="3"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
stroke="currentColor"
fill="transparent"
r={radius}
cx="22"
cy="22"
/>
</svg>
{/* 图标 */}
<ArrowUp
className="h-5 w-5 text-primary transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-110"
strokeWidth={2.5}
/>
{/* 内部发光效果 (仅在 dark 模式下明显) */}
<div className="absolute inset-0 rounded-full bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { RotateCcw } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { hexToHSL } from '@/lib/theme/palette'
import {
type BackgroundEffects,
defaultBackgroundEffects,
} from '@/lib/theme/tokens'
function hslToHex(hsl: string): string {
if (!hsl) return '#000000'
const parts = hsl.split(' ').filter(Boolean)
if (parts.length < 3) return '#000000'
const h = parseFloat(parts[0])
const s = parseFloat(parts[1].replace('%', ''))
const l = parseFloat(parts[2].replace('%', ''))
const sDecimal = s / 100
const lDecimal = l / 100
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = lDecimal - c / 2
let r = 0
let g = 0
let b = 0
if (h >= 0 && h < 60) {
r = c
g = x
} else if (h >= 60 && h < 120) {
r = x
g = c
} else if (h >= 120 && h < 180) {
g = c
b = x
} else if (h >= 180 && h < 240) {
g = x
b = c
} else if (h >= 240 && h < 300) {
r = x
b = c
} else if (h >= 300 && h < 360) {
r = c
b = x
}
const toHex = (value: number) => {
const hex = Math.round((value + m) * 255).toString(16)
return hex.length === 1 ? `0${hex}` : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
type BackgroundEffectsControlsProps = {
effects: BackgroundEffects
onChange: (effects: BackgroundEffects) => void
disabled?: boolean
}
export function BackgroundEffectsControls({
effects,
onChange,
disabled = false,
}: BackgroundEffectsControlsProps) {
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
if (disabled) return
onChange({
...effects,
[key]: value,
})
}
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const hex = e.target.value
const hsl = hexToHSL(hex)
onChange({
...effects,
overlayColor: hsl,
})
}
const handlePositionChange = (value: string) => {
if (disabled) return
onChange({
...effects,
position: value as BackgroundEffects['position'],
})
}
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return
onChange({
...effects,
gradientOverlay: e.target.value,
})
}
const handleReset = () => {
if (disabled) return
onChange(defaultBackgroundEffects)
}
return (
<div className={disabled ? 'space-y-6 opacity-50' : 'space-y-6'}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"></h3>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={disabled}
className="h-8 px-2 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="grid gap-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Blur)</Label>
<span className="text-xs text-muted-foreground">{effects.blur}px</span>
</div>
<Slider
value={[effects.blur]}
min={0}
max={50}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('blur', vals[0])}
/>
</div>
<div className="space-y-3">
<Label> (Overlay Color)</Label>
<div className="flex items-center gap-3">
<div className="h-9 w-9 overflow-hidden rounded-md border shadow-sm">
<input
type="color"
value={hslToHex(effects.overlayColor)}
onChange={handleColorChange}
disabled={disabled}
className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0"
/>
</div>
<Input
value={hslToHex(effects.overlayColor)}
readOnly
disabled={disabled}
className="flex-1 font-mono uppercase"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Opacity)</Label>
<span className="text-xs text-muted-foreground">
{Math.round(effects.overlayOpacity * 100)}%
</span>
</div>
<Slider
value={[effects.overlayOpacity * 100]}
min={0}
max={100}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('overlayOpacity', vals[0] / 100)}
/>
</div>
<div className="space-y-3">
<Label> (Position)</Label>
<Select value={effects.position} onValueChange={handlePositionChange} disabled={disabled}>
<SelectTrigger disabled={disabled}>
<SelectValue placeholder="选择位置" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cover"> (Cover)</SelectItem>
<SelectItem value="contain"> (Contain)</SelectItem>
<SelectItem value="center"> (Center)</SelectItem>
<SelectItem value="stretch"> (Stretch)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Brightness)</Label>
<span className="text-xs text-muted-foreground">{effects.brightness}%</span>
</div>
<Slider
value={[effects.brightness]}
min={0}
max={200}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Contrast)</Label>
<span className="text-xs text-muted-foreground">{effects.contrast}%</span>
</div>
<Slider
value={[effects.contrast]}
min={0}
max={200}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> (Saturate)</Label>
<span className="text-xs text-muted-foreground">{effects.saturate}%</span>
</div>
<Slider
value={[effects.saturate]}
min={0}
max={200}
step={1}
disabled={disabled}
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
/>
</div>
<div className="space-y-3">
<Label>CSS (Gradient Overlay)</Label>
<Input
value={effects.gradientOverlay || ''}
onChange={handleGradientChange}
disabled={disabled}
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
className="font-mono text-xs"
/>
<p className="text-[10px] text-muted-foreground"> CSS gradient </p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react'
import { useAssetStore } from '@/components/asset-provider'
import type { BackgroundConfig } from '@/lib/theme/tokens'
type BackgroundLayerProps = {
config: BackgroundConfig
layerId: string
}
function getAutoOverlayOpacity(layerId: string): number {
switch (layerId) {
case 'page':
return 0.62
case 'header':
return 0.72
case 'sidebar':
return 0.78
case 'card':
return 0.82
case 'dialog':
return 0.88
default:
return 0.68
}
}
function getAutoGradientOverlay(layerId: string): string | undefined {
if (layerId !== 'page') {
return undefined
}
return 'linear-gradient(to bottom, hsl(var(--background) / 0.82), hsl(var(--background) / 0.52) 28%, hsl(var(--background) / 0.7) 100%)'
}
function buildFilterString(effects: BackgroundConfig['effects']): string {
const parts: string[] = []
if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`)
if (effects.brightness !== 100) parts.push(`brightness(${effects.brightness}%)`)
if (effects.contrast !== 100) parts.push(`contrast(${effects.contrast}%)`)
if (effects.saturate !== 100) parts.push(`saturate(${effects.saturate}%)`)
return parts.join(' ')
}
function getBackgroundSize(position: BackgroundConfig['effects']['position']): string {
switch (position) {
case 'cover':
return 'cover'
case 'contain':
return 'contain'
case 'center':
return 'auto'
case 'stretch':
return '100% 100%'
default:
return 'cover'
}
}
function getObjectFit(position: BackgroundConfig['effects']['position']): React.CSSProperties['objectFit'] {
switch (position) {
case 'cover':
return 'cover'
case 'contain':
return 'contain'
case 'center':
return 'none'
case 'stretch':
return 'fill'
default:
return 'cover'
}
}
export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
const { getAssetUrl } = useAssetStore()
const [blobUrl, setBlobUrl] = useState<string | undefined>()
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (!config.assetId) {
setBlobUrl(undefined)
return
}
getAssetUrl(config.assetId).then(setBlobUrl)
}, [config.assetId, getAssetUrl])
useEffect(() => {
if (config.type !== 'video' || !videoRef.current) return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const apply = () => {
if (videoRef.current) {
if (mq.matches) {
videoRef.current.pause()
} else {
videoRef.current.play().catch(() => {})
}
}
}
apply()
mq.addEventListener('change', apply)
return () => mq.removeEventListener('change', apply)
}, [config.type])
if (config.type === 'none') {
return null
}
const filterString = buildFilterString(config.effects)
const { overlayColor, overlayOpacity, gradientOverlay } = config.effects
const hasExplicitOverlay = overlayOpacity > 0
const effectiveOverlayOpacity = hasExplicitOverlay ? overlayOpacity : getAutoOverlayOpacity(layerId)
const effectiveOverlayColor = hasExplicitOverlay
? `hsl(${overlayColor} / ${effectiveOverlayOpacity})`
: `hsl(var(--background) / ${effectiveOverlayOpacity})`
const effectiveGradientOverlay = gradientOverlay || getAutoGradientOverlay(layerId)
return (
<div
key={layerId}
data-background-layer={layerId}
style={{
position: 'absolute',
inset: 0,
zIndex: 0,
overflow: 'hidden',
pointerEvents: 'none',
}}
>
{config.type === 'image' && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 0,
backgroundImage: blobUrl ? `url(${blobUrl})` : undefined,
backgroundSize: getBackgroundSize(config.effects.position),
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: filterString || undefined,
}}
/>
)}
{config.type === 'video' && blobUrl && (
<video
ref={videoRef}
src={blobUrl}
autoPlay
muted
loop
playsInline
style={{
position: 'absolute',
inset: 0,
zIndex: 0,
width: '100%',
height: '100%',
objectFit: getObjectFit(config.effects.position),
filter: filterString || undefined,
}}
onError={() => {
if (videoRef.current) {
videoRef.current.pause()
}
}}
/>
)}
{effectiveOverlayOpacity > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
backgroundColor: effectiveOverlayColor,
pointerEvents: 'none',
}}
/>
)}
{effectiveGradientOverlay && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 2,
background: effectiveGradientOverlay,
pointerEvents: 'none',
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,284 @@
import { useEffect, useRef, useState } from 'react'
import { Link, Loader2, Trash2, Upload } from 'lucide-react'
import { useAssetStore } from '@/components/asset-provider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { addAsset, getAsset } from '@/lib/asset-store'
import { cn } from '@/lib/utils'
type BackgroundUploaderProps = {
assetId?: string
onAssetSelect: (id: string | undefined) => void
className?: string
disabled?: boolean
}
export function BackgroundUploader({ assetId, onAssetSelect, className, disabled = false }: BackgroundUploaderProps) {
const { getAssetUrl } = useAssetStore()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dragActive, setDragActive] = useState(false)
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
const [assetType, setAssetType] = useState<'image' | 'video' | undefined>(undefined)
const [urlInput, setUrlInput] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
// 加载预览
useEffect(() => {
let active = true
const loadPreview = async () => {
if (!assetId) {
setPreviewUrl(undefined)
setAssetType(undefined)
return
}
try {
const url = await getAssetUrl(assetId)
const record = await getAsset(assetId)
if (active) {
if (url && record) {
setPreviewUrl(url)
setAssetType(record.type)
} else {
// 如果找不到资源,可能是被删除了
onAssetSelect(undefined)
}
}
} catch (err) {
console.error('Failed to load asset preview:', err)
}
}
loadPreview()
return () => {
active = false
}
}, [assetId, getAssetUrl, onAssetSelect])
const handleFile = async (file: File) => {
if (disabled) return
setError(null)
setIsLoading(true)
try {
// 验证文件类型
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
throw new Error('不支持的文件类型。请上传图片或视频。')
}
// 验证文件大小 (例如限制 50MB)
if (file.size > 50 * 1024 * 1024) {
throw new Error('文件过大。请上传小于 50MB 的文件。')
}
const id = await addAsset(file)
onAssetSelect(id)
setUrlInput('') // 清空 URL 输入框
} catch (err) {
setError(err instanceof Error ? err.message : '上传失败')
} finally {
setIsLoading(false)
}
}
const handleUrlUpload = async () => {
if (disabled || !urlInput) return
setError(null)
setIsLoading(true)
try {
const response = await fetch(urlInput)
if (!response.ok) {
throw new Error(`下载失败: ${response.statusText}`)
}
const blob = await response.blob()
// 尝试从 Content-Type 或 URL 推断文件名和类型
const contentType = response.headers.get('content-type') || ''
const urlFilename = urlInput.split('/').pop() || 'downloaded-file'
const filename = urlFilename.includes('.') ? urlFilename : `${urlFilename}.${contentType.split('/')[1] || 'bin'}`
const file = new File([blob], filename, { type: contentType })
await handleFile(file)
} catch (err) {
setError(err instanceof Error ? err.message : '从 URL 上传失败')
} finally {
setIsLoading(false)
}
}
// 拖拽处理
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabled) return
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true)
} else if (e.type === 'dragleave') {
setDragActive(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (disabled) return
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0])
}
}
const handleClear = () => {
if (disabled) return
onAssetSelect(undefined)
setPreviewUrl(undefined)
setAssetType(undefined)
setError(null)
}
return (
<div className={cn("space-y-4", disabled && 'opacity-50', className)}>
<div className="grid gap-2">
<Label></Label>
{/* 预览区域 / 上传区域 */}
<div
className={cn(
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
disabled && 'pointer-events-none',
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
error ? "border-destructive/50 bg-destructive/5" : "",
assetId ? "border-solid" : ""
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{isLoading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">...</p>
</div>
) : assetId && previewUrl ? (
<div className="relative h-full w-full">
{assetType === 'video' ? (
<video
src={previewUrl}
className="h-full max-h-[300px] w-full rounded-md object-contain"
controls={false}
muted
/>
) : (
<img
src={previewUrl}
alt="Background preview"
className="h-full max-h-[300px] w-full rounded-md object-contain"
/>
)}
<div className="absolute right-2 top-2 flex gap-2">
<Button
variant="destructive"
size="icon"
className="h-8 w-8 shadow-sm"
onClick={handleClear}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="absolute bottom-2 left-2 rounded bg-black/50 px-2 py-1 text-xs text-white backdrop-blur">
{assetType === 'video' ? '视频' : '图片'}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 text-center">
<div className="rounded-full bg-muted p-4">
<Upload className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium"></p>
<p className="text-xs text-muted-foreground">
JPG, PNG, GIF, MP4, WebM
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
>
</Button>
</div>
)}
<Input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*,video/mp4,video/webm"
onChange={(e) => {
if (disabled) return
if (e.target.files?.[0]) {
handleFile(e.target.files[0])
}
// 重置 value允许重复选择同一文件
e.target.value = ''
}}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
{/* URL 上传 */}
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground"> URL </Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Link className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://example.com/image.jpg"
className="pl-9"
value={urlInput}
disabled={disabled}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUrlUpload()
}
}}
/>
</div>
<Button
variant="secondary"
onClick={handleUrlUpload}
disabled={disabled || !urlInput || isLoading}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { AlertTriangle, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CodeEditor } from '@/components/CodeEditor'
import { Label } from '@/components/ui/label'
import { sanitizeCSS } from '@/lib/theme/sanitizer'
export type ComponentCSSEditorProps = {
/** 组件唯一标识符 */
componentId: string
/** 当前 CSS 内容 */
value: string
/** CSS 内容变更回调 */
onChange: (css: string) => void
/** 编辑器标签文字 */
label?: string
/** 编辑器高度,默认 200px */
height?: string
disabled?: boolean
}
/**
* 组件级 CSS 编辑器
* 提供 CSS 代码编辑、语法高亮和安全过滤警告功能
*/
export function ComponentCSSEditor({
componentId,
value,
onChange,
label,
height = '200px',
disabled = false,
}: ComponentCSSEditorProps) {
// 实时计算 CSS 警告
const { warnings } = sanitizeCSS(value)
return (
<div className={disabled ? 'space-y-2 opacity-50' : 'space-y-2'}>
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{label || '自定义 CSS'}
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => onChange('')}
disabled={disabled || !value}
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
title="清除所有 CSS"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
<div className="rounded-md border bg-card overflow-hidden">
<CodeEditor
value={value}
onChange={disabled ? undefined : onChange}
language="css"
readOnly={disabled}
height={height}
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
/>
{warnings.length > 0 && (
<div className="border-t border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-xs font-medium mb-1">
<AlertTriangle className="h-3.5 w-3.5" />
CSS
</div>
<ul className="text-[10px] sm:text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-5 list-disc">
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,462 @@
import * as React from 'react'
import * as LucideIcons from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
import { DynamicField } from './DynamicField'
export interface DynamicConfigFormProps {
schema: ConfigSchema
values: Record<string, unknown>
onChange: (field: string, value: unknown) => void
basePath?: string
hooks?: FieldHookRegistry
/** 嵌套层级0 = tab 内容层1 = section 内容层2+ = 更深嵌套 */
level?: number
advancedVisible?: boolean
sectionColumns?: 1 | 2
}
function buildFieldPath(basePath: string, fieldName: string) {
return basePath ? `${basePath}.${fieldName}` : fieldName
}
function resolveSectionTitle(schema: ConfigSchema) {
return schema.uiLabel || schema.classDoc || schema.className
}
function SectionIcon({ iconName }: { iconName?: string }) {
if (!iconName) return null
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!IconComponent) return null
return <IconComponent className="h-5 w-5 text-muted-foreground" />
}
export function AdvancedSettingsButton({
active,
onClick,
}: {
active: boolean
onClick: () => void
}) {
return (
<Button
type="button"
variant={active ? 'default' : 'outline'}
size="sm"
onClick={onClick}
>
</Button>
)
}
function DynamicConfigSection({
advancedVisible,
basePath,
hooks,
level,
nestedSchema,
onChange,
sectionKey,
sectionTitle,
values,
}: {
advancedVisible: boolean
basePath: string
hooks: FieldHookRegistry
level: number
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionKey: string
sectionTitle: string
values: Record<string, unknown>
}) {
return (
<Card className="min-w-0">
<CardHeader className="border-b border-border/50 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-lg text-primary">{sectionTitle}</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="pt-4">
<DynamicConfigForm
schema={nestedSchema}
values={values}
onChange={(field, value) => onChange(`${sectionKey}.${field}`, value)}
basePath={basePath}
hooks={hooks}
level={level}
advancedVisible={advancedVisible}
sectionColumns={1}
/>
</CardContent>
</Card>
)
}
/**
* DynamicConfigForm - 动态配置表单组件
*
* 根据 ConfigSchema 渲染表单字段,支持:
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
* - replace 模式:完全替换默认渲染
* - wrapper 模式:包装默认渲染(通过 children 传递)
* 2. 嵌套 schema递归渲染 schema.nested 中的子配置
* 3. 高级设置:由栏目标题右侧按钮控制显示
*/
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
schema,
values,
onChange,
basePath = '',
hooks = fieldHooks,
level = 0,
advancedVisible,
sectionColumns = 1,
}) => {
const resolvedAdvancedVisible = advancedVisible ?? false
const fieldMap = React.useMemo(
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields],
)
const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name)
const nestedSchema = schema.nested?.[field.name]
if (hooks.has(fieldPath)) {
const hookEntry = hooks.get(fieldPath)
if (!hookEntry) return null
if (hookEntry.type === 'hidden') return null
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
onParentChange={onChange}
schema={field}
nestedSchema={nestedSchema}
parentValues={values}
/>
)
}
return (
<HookComponent
fieldPath={fieldPath}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
onParentChange={onChange}
schema={field}
nestedSchema={nestedSchema}
parentValues={values}
>
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
</HookComponent>
)
}
return (
<DynamicField
schema={field}
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
fieldPath={fieldPath}
/>
)
}
const shouldRenderFieldInline = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name)
if (hooks.get(fieldPath)?.type === 'hidden') {
return false
}
if (!schema.nested?.[field.name]) {
return true
}
return hooks.get(fieldPath)?.type === 'replace'
}
const schemaHasVisibleContent = React.useCallback(
(targetSchema: ConfigSchema, targetBasePath: string): boolean => {
const targetFields = targetSchema.fields ?? []
const hasVisibleInlineField = targetFields.some((field) => {
const fieldPath = buildFieldPath(targetBasePath, field.name)
const hookEntry = hooks.get(fieldPath)
if (hookEntry?.type === 'hidden') {
return false
}
if (targetSchema.nested?.[field.name] && hookEntry?.type !== 'replace') {
return false
}
return resolvedAdvancedVisible || !field.advanced
})
if (hasVisibleInlineField) {
return true
}
return Object.entries(targetSchema.nested ?? {}).some(([key, nestedSchema]) => {
const nestedField = targetFields.find((field) => field.name === key)
const nestedFieldPath = buildFieldPath(targetBasePath, key)
const hookEntry = hooks.get(nestedFieldPath)
if (hookEntry?.type === 'hidden') {
return false
}
if (nestedField?.advanced && !resolvedAdvancedVisible) {
return false
}
if (hookEntry?.type === 'replace') {
return true
}
return schemaHasVisibleContent(nestedSchema, nestedFieldPath)
})
},
[hooks, resolvedAdvancedVisible],
)
const inlineFields = schema.fields.filter(shouldRenderFieldInline)
const inlineNestedFieldNames = new Set(
inlineFields
.filter((field) => Boolean(schema.nested?.[field.name]))
.map((field) => field.name),
)
const normalFields = inlineFields.filter((field) => !field.advanced)
const advancedFields = inlineFields.filter((field) => field.advanced)
const visibleFields = resolvedAdvancedVisible
? [...normalFields, ...advancedFields]
: normalFields
const groupFieldsByRow = (fields: FieldSchema[]) => {
const rows: FieldSchema[][] = []
let currentRow: FieldSchema[] = []
let currentRowKey: string | undefined
for (const field of fields) {
const rowKey = field['x-row']
if (rowKey && rowKey === currentRowKey) {
currentRow.push(field)
continue
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
currentRow = [field]
currentRowKey = rowKey
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
return rows
}
const renderRows = (rows: FieldSchema[][]) => (
<>
{rows.map((row) => (
row.length > 1 ? (
<div
key={row.map((field) => field.name).join('|')}
className="grid min-w-0 gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
style={{ '--field-row-count': row.length } as React.CSSProperties}
>
{row.map((field) => (
<div key={field.name} className="min-w-0">{renderField(field)}</div>
))}
</div>
) : (
<div key={row[0].name} className="min-w-0 py-1">{renderField(row[0])}</div>
)
))}
</>
)
const renderFieldList = (fields: FieldSchema[]) => (
<>
{groupFieldsByRow(fields).map((row, index) => (
<React.Fragment key={row.map((field) => field.name).join('|')}>
{index > 0 && <Separator className="my-2 bg-border/50" />}
{renderRows([row])}
</React.Fragment>
))}
</>
)
return (
<div className="min-w-0 space-y-6">
{visibleFields.length > 0 && (
<div>
{renderFieldList(visibleFields)}
</div>
)}
{schema.nested &&
(() => {
const nestedSections = Object.entries(schema.nested)
.filter(([key]) => !inlineNestedFieldNames.has(key))
.map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(basePath, key)
if (hooks.has(nestedFieldPath)) {
const hookEntry = hooks.get(nestedFieldPath)
if (!hookEntry) return null
if (hookEntry.type === 'hidden') return null
if (nestedField?.advanced && !resolvedAdvancedVisible) return null
if (
hookEntry.type !== 'replace' &&
nestedSchema &&
!schemaHasVisibleContent(nestedSchema, nestedFieldPath)
) {
return null
}
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<div key={key} className="min-w-0">
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
onParentChange={onChange}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
/>
</div>
)
}
return (
<div key={key} className="min-w-0">
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
onChange={(v) => onChange(key, v)}
onParentChange={onChange}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
>
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
advancedVisible={resolvedAdvancedVisible}
sectionColumns={1}
/>
</HookComponent>
</div>
)
}
const sectionTitle = resolveSectionTitle(nestedSchema)
if (!schemaHasVisibleContent(nestedSchema, nestedFieldPath)) {
return null
}
if (level === 0) {
return (
<DynamicConfigSection
key={key}
advancedVisible={resolvedAdvancedVisible}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={onChange}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
sectionKey={key}
sectionTitle={sectionTitle}
/>
)
}
return (
<Card key={key} className="min-w-0 border-border/70 bg-muted/20 shadow-none">
<CardHeader className="border-b border-border/50 px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={nestedSchema.uiIcon} />
<CardTitle className="text-sm text-primary">{sectionTitle}</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-4">
<DynamicConfigForm
schema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={(field, value) => onChange(`${key}.${field}`, value)}
basePath={nestedFieldPath}
hooks={hooks}
level={level + 1}
advancedVisible={resolvedAdvancedVisible}
sectionColumns={1}
/>
</CardContent>
</Card>
)
})
const visibleNestedSections = nestedSections.filter(
(section): section is React.ReactElement => Boolean(section),
)
if (level === 0 && sectionColumns === 2 && visibleNestedSections.length > 1) {
return (
<div className="grid min-w-0 gap-4 md:grid-cols-2">
{visibleNestedSections}
</div>
)
}
return visibleNestedSections
})()}
</div>
)
}

View File

@@ -0,0 +1,487 @@
import * as React from "react"
import * as LucideIcons from "lucide-react"
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import { KeyValueEditor } from "@/components/ui/key-value-editor"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { resolveFieldLabel } from "@/lib/config-label"
import type { FieldSchema } from "@/types/config-schema"
export interface DynamicFieldProps {
schema: FieldSchema
value: unknown
onChange: (value: unknown) => void
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fieldPath?: string // 用于 Hook 系统(未来使用)
}
/**
* DynamicField - 根据字段类型和 x-widget 渲染对应的 shadcn/ui 组件
*
* 渲染逻辑:
* 1. x-widget 优先:如果 schema 有 x-widget使用对应组件
* 2. type 回退:如果没有 x-widget根据 type 选择默认组件
*/
export const DynamicField: React.FC<DynamicFieldProps> = ({
schema,
value,
onChange,
}) => {
const { i18n } = useTranslation()
const fieldLabel = resolveFieldLabel(schema, i18n.language)
const isNumericField = schema.type === 'integer' || schema.type === 'number'
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
return rawValue
}
if (typeof rawValue === 'string') {
const parsedValue = parseFloat(rawValue)
if (Number.isFinite(parsedValue)) {
return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue
}
}
if (fallbackValue !== rawValue) {
return parseNumericValue(fallbackValue, 0)
}
return 0
}
const renderPrimitiveArrayEditor = () => {
const itemType = schema.items?.type ?? 'string'
const arrayValue = Array.isArray(value)
? value
: Array.isArray(schema.default)
? schema.default
: []
const textareaValue = arrayValue.map((item) => String(item ?? '')).join('\n')
return (
<Textarea
value={textareaValue}
onChange={(e) => {
const nextItems = e.target.value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
if (itemType === 'integer') {
return parseInt(line, 10) || 0
}
if (itemType === 'number') {
return parseFloat(line) || 0
}
if (itemType === 'boolean') {
return line === 'true'
}
return line
})
onChange(nextItems)
}}
rows={Math.max(4, arrayValue.length || 4)}
/>
)
}
const renderObjectEditor = () => {
const objectValue =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
return (
<KeyValueEditor
value={objectValue}
onChange={onChange}
/>
)
}
/**
* 渲染字段图标
*/
const renderIcon = () => {
if (!schema['x-icon']) return null
const IconComponent = LucideIcons[schema['x-icon'] as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }> | undefined
if (!IconComponent) return null
return <IconComponent className="h-4 w-4" />
}
const optionDescriptions = schema['x-option-descriptions'] ?? {}
const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
const descriptionDisplay = schema['x-description-display'] ?? 'label-hover'
const fieldDescription = schema.description
const inlineDescription = descriptionDisplay === 'inline' && !hasOptionDescriptions ? fieldDescription : ''
const renderDescriptionTooltip = (trigger: React.ReactElement, side: 'top' | 'right' = 'top') => {
if (!fieldDescription) return trigger
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
{trigger}
</TooltipTrigger>
<TooltipContent
side={side}
align="start"
className="max-w-80 whitespace-pre-line bg-background text-foreground border shadow-lg"
>
{fieldDescription}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const renderFieldHeader = () => (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{(() => {
const label = (
<Label
className={cn(
"inline-flex min-w-0 items-center gap-1.5 text-[15px] leading-6",
descriptionDisplay === 'label-hover' && fieldDescription && "cursor-help",
schema.advanced
? "text-sky-700 dark:text-sky-300"
: "text-foreground",
)}
>
{renderIcon()}
<span className="break-words">{fieldLabel}</span>
{schema.required && <span className="text-destructive">*</span>}
</Label>
)
return descriptionDisplay === 'label-hover'
? renderDescriptionTooltip(label)
: label
})()}
{descriptionDisplay === 'icon' && fieldDescription && (
renderDescriptionTooltip(
<button
type="button"
aria-label={`${fieldLabel} 说明`}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LucideIcons.CircleAlert className="h-4 w-4" />
</button>,
'right',
)
)}
{inlineDescription && (
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
{inlineDescription}
</span>
)}
</div>
)
/**
* 根据 x-widget 或 type 选择并渲染对应的输入组件
*/
const renderInputComponent = () => {
const widget = schema['x-widget']
const type = schema.type
const resolvedWidget =
isNumericField && (widget === 'input' || widget === 'number' || !widget)
? 'number'
: widget
// x-widget 优先
if (resolvedWidget) {
switch (resolvedWidget) {
case 'slider':
return renderSlider()
case 'input':
return renderTextInput()
case 'number':
return renderNumberInput()
case 'password':
return renderTextInput('password')
case 'switch':
return renderSwitch()
case 'textarea':
return renderTextarea()
case 'select':
return renderSelect()
case 'custom':
if (type === 'array' && schema.items && schema.items.type !== 'object') {
return renderPrimitiveArrayEditor()
}
if (type === 'object') {
return renderObjectEditor()
}
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Custom field requires Hook
</div>
)
default:
// 未知的 x-widget回退到 type
break
}
}
// type 回退
switch (type) {
case 'boolean':
return renderSwitch()
case 'number':
case 'integer':
return renderNumberInput()
case 'string':
return renderTextInput()
case 'select':
return renderSelect()
case 'array':
if (!schema.items || schema.items.type === 'object') {
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Complex array requires Hook
</div>
)
}
return renderPrimitiveArrayEditor()
case 'object':
return renderObjectEditor()
case 'textarea':
return renderTextarea()
default:
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
Unknown field type: {type}
</div>
)
}
}
/**
* 渲染 Switch 组件(用于 boolean 类型)
* 使用水平布局:标签+描述在左,开关在右
*/
const renderSwitch = () => {
const checked = Boolean(value)
return (
<div className="flex min-w-0 items-center justify-between gap-4 py-2">
<div className="min-w-0 pr-4">
{renderFieldHeader()}
</div>
<Switch
checked={checked}
onCheckedChange={(checked) => onChange(checked)}
/>
</div>
)
}
/**
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider
*/
const renderSlider = () => {
const numValue = parseNumericValue(value, schema.default)
const min = schema.minValue ?? 0
const max = schema.maxValue ?? 100
const step = schema.step ?? 1
return (
<div className="min-w-0 space-y-2">
<Slider
value={[numValue]}
onValueChange={(values) => onChange(values[0])}
min={min}
max={max}
step={step}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{min}</span>
<span className="font-medium text-foreground">{numValue}</span>
<span>{max}</span>
</div>
</div>
)
}
/**
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
*/
const renderNumberInput = () => {
const numValue = parseNumericValue(value, schema.default)
const min = schema.minValue
const max = schema.maxValue
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
return (
<Input
type="number"
value={numValue}
onChange={(e) => {
const nextValue = schema.type === 'integer'
? parseInt(e.target.value, 10)
: parseFloat(e.target.value)
onChange(Number.isFinite(nextValue) ? nextValue : 0)
}}
min={min}
max={max}
step={step}
/>
)
}
/**
* 渲染 Input[type="text"] 组件(用于 string 类型)
*/
const renderTextInput = (type: 'password' | 'text' = 'text') => {
const strValue =
typeof value === 'string'
? value
: value === null || value === undefined
? String(schema.default ?? '')
: String(value)
return (
<Input
type={type}
value={strValue}
onChange={(e) => onChange(e.target.value)}
/>
)
}
/**
* 渲染 Textarea 组件(用于 textarea 类型或 x-widget: textarea
*/
const renderTextarea = () => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
? schema['x-textarea-min-height']
: undefined
const rows = typeof schema['x-textarea-rows'] === 'number'
? schema['x-textarea-rows']
: 4
return (
<Textarea
value={strValue}
onChange={(e) => onChange(e.target.value)}
rows={rows}
minHeight={minHeight}
/>
)
}
/**
* 渲染 Select 组件(用于 select 类型或 x-widget: select
*/
const renderSelect = () => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const options = schema.options ?? []
if (options.length === 0) {
return (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
No options available for select
</div>
)
}
return (
<Select value={strValue} onValueChange={(val) => onChange(val)}>
<SelectTrigger>
<SelectValue placeholder={`Select ${fieldLabel}`} />
</SelectTrigger>
<SelectContent>
{hasOptionDescriptions ? (
<TooltipProvider delayDuration={150}>
{options.map((option) => {
const description = optionDescriptions[option]
return description ? (
<Tooltip key={option}>
<TooltipTrigger asChild>
<SelectItem value={option} title={description}>
{option}
</SelectItem>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="max-w-72 bg-background text-foreground border shadow-lg"
>
{description}
</TooltipContent>
</Tooltip>
) : (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
)
})}
</TooltipProvider>
) : (
options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))
)}
</SelectContent>
</Select>
)
}
// 判断当前字段是否为 Switch/Boolean 类型(独立处理布局)
const isBoolean =
schema['x-widget'] === 'switch' ||
(!schema['x-widget'] && schema.type === 'boolean')
const supportsInlineRight =
schema['x-layout'] === 'inline-right' &&
['input', 'number', 'password', 'select', undefined].includes(schema['x-widget']) &&
['string', 'number', 'integer', 'select'].includes(schema.type)
// Switch/Boolean 字段自带完整布局,直接返回
if (isBoolean) {
return renderInputComponent()
}
if (supportsInlineRight) {
return (
<div
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center"
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
>
<div className="min-w-0 sm:shrink-0">
{renderFieldHeader()}
</div>
<div className="min-w-20 flex-1 sm:ml-auto sm:max-w-[var(--field-input-width)]">
{renderInputComponent()}
</div>
</div>
)
}
return (
<div className="min-w-0 space-y-2">
{renderFieldHeader()}
{/* Input component */}
{renderInputComponent()}
</div>
)
}

View File

@@ -0,0 +1,126 @@
# Dynamic Config Form System
## Overview
The Dynamic Config Form system is a schema-driven UI component designed to automatically generate configuration forms based on backend Pydantic models. It supports rich metadata for UI customization and a flexible Hook system for complex fields.
### Core Components
- **DynamicConfigForm**: The main component that takes a `ConfigSchema` and renders the entire form.
- **DynamicField**: A lower-level component that renders individual fields based on their type and UI metadata.
- **FieldHookRegistry**: A registry for custom React components that can replace or wrap default field rendering.
## Quick Start
To use the dynamic form in your page:
```typescript
import { DynamicConfigForm } from '@/components/dynamic-form'
import { fieldHooks } from '@/lib/field-hooks'
// Example usage in a component
export function ConfigPage() {
const [config, setConfig] = useState({})
const schema = useConfigSchema() // Fetch from API
const handleChange = (fieldPath: string, value: unknown) => {
// fieldPath can be nested, e.g., 'section.subfield'
updateConfigAt(fieldPath, value)
}
return (
<DynamicConfigForm
schema={schema}
values={config}
onChange={handleChange}
hooks={fieldHooks}
/>
)
}
```
## Adding UI Metadata (Backend)
You can customize how fields are rendered by adding `json_schema_extra` to your Pydantic `Field` definitions.
### Supported Metadata
- `x-widget`: Specifies the UI component to use.
- `slider`: A range slider (requires `ge`, `le`, and `step`).
- `switch`: A toggle switch (for booleans).
- `textarea`: A multi-line text input.
- `select`: A dropdown menu (for `Literal` or enum types).
- `custom`: Indicates that this field requires a Hook for rendering.
- `x-icon`: A Lucide icon name (e.g., `MessageSquare`, `Settings`).
- `step`: Incremental step for sliders or number inputs.
### Example
```python
class ChatConfig(ConfigBase):
talk_value: float = Field(
default=0.5,
ge=0.0,
le=1.0,
json_schema_extra={
"x-widget": "slider",
"x-icon": "MessageSquare",
"step": 0.1
}
)
```
## Creating Hook Components
Hooks allow you to provide custom UI for complex configuration sections or fields.
### FieldHookComponent Interface
A Hook component receives the following props:
- `fieldPath`: The full path to the field.
- `value`: The current value of the field/section.
- `onChange`: Callback to update the value.
- `children`: (Only for `wrapper` hooks) The default field renderer.
### Implementation Example
```typescript
import type { FieldHookComponent } from '@/lib/field-hooks'
export const CustomSectionHook: FieldHookComponent = ({
fieldPath,
value,
onChange
}) => {
return (
<div className="custom-section">
<h3>Custom UI</h3>
<input
value={value.some_prop}
onChange={(e) => onChange({ ...value, some_prop: e.target.value })}
/>
</div>
)
}
```
### Registering Hooks
Register hooks in your component's lifecycle:
```typescript
useEffect(() => {
fieldHooks.register('chat', ChatSectionHook, 'replace')
return () => fieldHooks.unregister('chat')
}, [])
```
## API Reference
### DynamicConfigForm
| Prop | Type | Description |
|------|------|-------------|
| `schema` | `ConfigSchema` | The schema generated by the backend. |
| `values` | `Record<string, any>` | Current configuration values. |
| `onChange` | `(field: string, value: any) => void` | Change handler. |
| `hooks` | `FieldHookRegistry` | Optional custom hook registry. |
### FieldHookRegistry
- `register(path, component, type)`: Register a hook.
- `get(path)`: Retrieve a registered hook.
- `has(path)`: Check if a hook exists.
- `unregister(path)`: Remove a hook.
## Troubleshooting
- **Hook not rendering**: Ensure the registration path matches the schema field name exactly (e.g., `chat` vs `Chat`).
- **Field missing**: Check if the field is present in the `ConfigSchema` returned by the backend.
- **TypeScript errors**: Ensure your Hook implements the `FieldHookComponent` type.

View File

@@ -0,0 +1,427 @@
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/dom'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicConfigForm } from '../DynamicConfigForm'
import { FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema } from '@/types/config-schema'
import type { FieldHookComponentProps } from '@/lib/field-hooks'
describe('DynamicConfigForm', () => {
describe('basic rendering', () => {
it('renders simple fields', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'field1',
type: 'string',
label: 'Field 1',
description: 'First field',
required: false,
default: 'value1',
},
{
name: 'field2',
type: 'boolean',
label: 'Field 2',
description: 'Second field',
required: false,
default: false,
},
],
}
const values = { field1: 'value1', field2: false }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Field 1')).toBeInTheDocument()
expect(screen.getByText('Field 2')).toBeInTheDocument()
expect(screen.getByText('First field')).toBeInTheDocument()
expect(screen.getByText('Second field')).toBeInTheDocument()
})
it('renders nested schema', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [
{
name: 'top_field',
type: 'string',
label: 'Top Field',
description: 'Top level field',
required: false,
},
],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'number',
label: 'Nested Field',
description: 'Nested field',
required: false,
default: 42,
},
],
},
},
}
const values = {
top_field: 'top',
sub_config: {
nested_field: 42,
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Top Field')).toBeInTheDocument()
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
})
describe('Hook system', () => {
it('renders Hook component in replace mode', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value }) => {
return <div data-testid="hook-component">Hook: {fieldPath} = {String(value)}</div>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A field with hook',
required: false,
},
{
name: 'normal_field',
type: 'string',
label: 'Normal Field',
description: 'A normal field',
required: false,
},
],
}
const values = { hooked_field: 'test', normal_field: 'normal' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('hook-component')).toBeInTheDocument()
expect(screen.getByText('Hook: hooked_field = test')).toBeInTheDocument()
expect(screen.queryByText('Hooked Field')).not.toBeInTheDocument()
expect(screen.getByText('Normal Field')).toBeInTheDocument()
})
it('renders Hook component in wrapper mode', () => {
const WrapperHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, children }) => {
return (
<div data-testid="wrapper-hook">
<div>Wrapper for: {fieldPath}</div>
{children}
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('wrapped_field', WrapperHookComponent, 'wrapper')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'wrapped_field',
type: 'string',
label: 'Wrapped Field',
description: 'A wrapped field',
required: false,
},
],
}
const values = { wrapped_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('wrapper-hook')).toBeInTheDocument()
expect(screen.getByText('Wrapper for: wrapped_field')).toBeInTheDocument()
expect(screen.getByText('Wrapped Field')).toBeInTheDocument()
})
it('passes correct props to Hook component', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value, onChange }) => {
return (
<div>
<div data-testid="field-path">{fieldPath}</div>
<div data-testid="field-value">{String(value)}</div>
<button onClick={() => onChange?.('new_value')}>Change</button>
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('test_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'original' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('field-path')).toHaveTextContent('test_field')
expect(screen.getByTestId('field-value')).toHaveTextContent('original')
})
})
describe('onChange propagation', () => {
it('propagates onChange from simple field', async () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: '' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange.mock.calls.every(call => call[0] === 'test_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'test_field', 'H')
expect(onChange).toHaveBeenNthCalledWith(5, 'test_field', 'o')
})
it('propagates onChange from nested field with correct path', async () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {
sub_config: {
nested_field: '',
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Test')
expect(onChange).toHaveBeenCalledTimes(4)
expect(onChange.mock.calls.every(call => call[0] === 'sub_config.nested_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'sub_config.nested_field', 'T')
expect(onChange).toHaveBeenNthCalledWith(4, 'sub_config.nested_field', 't')
})
it('propagates onChange from Hook component', async () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ onChange }) => {
return <button onClick={() => onChange?.('hook_value')}>Set Value</button>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A hooked field',
required: false,
},
],
}
const values = { hooked_field: '' }
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
})
it('renders nested Hook component with full field path', async () => {
const NestedHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, onChange }) => {
return (
<button onClick={() => onChange?.([{ enabled: true }])}>
{fieldPath}
</button>
)
}
const hooks = new FieldHookRegistry()
hooks.register('mcp.servers', NestedHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'RootConfig',
classDoc: 'Root configuration',
fields: [],
nested: {
mcp: {
className: 'MCPConfig',
classDoc: 'MCP 配置',
fields: [
{
name: 'enable',
type: 'boolean',
label: '启用 MCP',
description: '是否启用 MCP',
required: false,
},
{
name: 'servers',
type: 'array',
label: '服务器列表',
description: '复杂对象数组',
required: false,
items: {
type: 'object',
},
},
],
nested: {
servers: {
className: 'MCPServerItemConfig',
classDoc: 'MCP 服务器项',
fields: [],
},
},
},
},
}
const values = {
mcp: {
enable: true,
servers: [],
},
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button', { name: 'mcp.servers' }))
expect(onChange).toHaveBeenCalledWith('mcp.servers', [{ enabled: true }])
})
})
describe('edge cases', () => {
it('renders with empty nested values', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
it('uses default hook registry when not provided', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Test Field')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,475 @@
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/dom'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicField } from '../DynamicField'
import type { FieldSchema } from '@/types/config-schema'
describe('DynamicField', () => {
describe('x-widget priority', () => {
it('renders Slider when x-widget is slider', () => {
const schema: FieldSchema = {
name: 'test_slider',
type: 'number',
label: 'Test Slider',
description: 'A test slider',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 100,
default: 50,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={50} onChange={onChange} />)
expect(screen.getByText('Test Slider')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
})
it('renders Switch when x-widget is switch', () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
'x-widget': 'switch',
default: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
expect(screen.getByText('Test Switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('renders Textarea when x-widget is textarea', () => {
const schema: FieldSchema = {
name: 'test_textarea',
type: 'string',
label: 'Test Textarea',
description: 'A test textarea',
required: false,
'x-widget': 'textarea',
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByText('Test Textarea')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Select when x-widget is select', () => {
const schema: FieldSchema = {
name: 'test_select',
type: 'string',
label: 'Test Select',
description: 'A test select',
required: false,
'x-widget': 'select',
options: ['Option 1', 'Option 2', 'Option 3'],
default: 'Option 1',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Option 1" onChange={onChange} />)
expect(screen.getByText('Test Select')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders placeholder for custom widget', () => {
const schema: FieldSchema = {
name: 'test_custom',
type: 'string',
label: 'Test Custom',
description: 'A test custom field',
required: false,
'x-widget': 'custom',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
})
it('renders number Input when x-widget is input but type is integer', () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget',
type: 'integer',
label: 'Test Integer Input Widget',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(2)
})
it('parses string values for numeric input widgets', () => {
const schema: FieldSchema = {
name: 'test_string_number_input_widget',
type: 'integer',
label: 'Test String Number Input Widget',
description: 'A numeric field with legacy string value',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
expect(screen.getByRole('spinbutton')).toHaveValue(2)
})
})
describe('type fallback', () => {
it('renders Input for string type', () => {
const schema: FieldSchema = {
name: 'test_string',
type: 'string',
label: 'Test String',
description: 'A test string',
required: false,
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Switch for boolean type', () => {
const schema: FieldSchema = {
name: 'test_bool',
type: 'boolean',
label: 'Test Boolean',
description: 'A test boolean',
required: false,
default: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={true} onChange={onChange} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeChecked()
})
it('renders number Input for number type', () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 42,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={42} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(42)
})
it('renders number Input for integer type', () => {
const schema: FieldSchema = {
name: 'test_integer',
type: 'integer',
label: 'Test Integer',
description: 'A test integer',
required: false,
default: 10,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={10} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(10)
})
it('renders Textarea for textarea type', () => {
const schema: FieldSchema = {
name: 'test_textarea_type',
type: 'textarea',
label: 'Test Textarea Type',
description: 'A test textarea type',
required: false,
default: 'Long text',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Long text" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Long text')
})
it('renders Select for select type', () => {
const schema: FieldSchema = {
name: 'test_select_type',
type: 'select',
label: 'Test Select Type',
description: 'A test select type',
required: false,
options: ['A', 'B', 'C'],
default: 'A',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="A" onChange={onChange} />)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders textarea editor for primitive array type', () => {
const schema: FieldSchema = {
name: 'test_array',
type: 'array',
label: 'Test Array',
description: 'A test array',
required: false,
items: {
type: 'string',
},
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={['a', 'b']} onChange={onChange} />)
expect(screen.getByRole('textbox')).toHaveValue('a\nb')
})
it('renders key-value editor for object type', () => {
const schema: FieldSchema = {
name: 'test_object',
type: 'object',
label: 'Test Object',
description: 'A test object',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={{ foo: 'bar' }} onChange={onChange} />)
expect(screen.getByText('可视化编辑')).toBeInTheDocument()
expect(screen.getByDisplayValue('foo')).toBeInTheDocument()
})
})
describe('onChange events', () => {
it('triggers onChange for Switch', async () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
default: false,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith(true)
})
it('triggers onChange for Input', async () => {
const schema: FieldSchema = {
name: 'test_input',
type: 'string',
label: 'Test Input',
description: 'A test input',
required: false,
default: '',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange).toHaveBeenNthCalledWith(1, 'H')
expect(onChange).toHaveBeenNthCalledWith(2, 'e')
expect(onChange).toHaveBeenNthCalledWith(3, 'l')
expect(onChange).toHaveBeenNthCalledWith(4, 'l')
expect(onChange).toHaveBeenNthCalledWith(5, 'o')
})
it('triggers onChange for number Input', async () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '123')
expect(onChange).toHaveBeenCalled()
})
it('triggers numeric onChange for input widget with integer type', async () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget_change',
type: 'integer',
label: 'Test Integer Input Widget Change',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '5')
expect(onChange).toHaveBeenLastCalledWith(5)
})
})
describe('visual features', () => {
it('renders label with icon', () => {
const schema: FieldSchema = {
name: 'test_icon',
type: 'string',
label: 'Test Icon',
description: 'A test with icon',
required: false,
'x-icon': 'Settings',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Test Icon')).toBeInTheDocument()
})
it('renders required indicator', () => {
const schema: FieldSchema = {
name: 'test_required',
type: 'string',
label: 'Test Required',
description: 'A required field',
required: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders description', () => {
const schema: FieldSchema = {
name: 'test_desc',
type: 'string',
label: 'Test Description',
description: 'This is a description',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('This is a description')).toBeInTheDocument()
})
})
describe('slider features', () => {
it('renders slider with min/max/step', () => {
const schema: FieldSchema = {
name: 'test_slider_props',
type: 'number',
label: 'Test Slider Props',
description: 'A slider with props',
required: false,
'x-widget': 'slider',
minValue: 10,
maxValue: 50,
step: 5,
default: 25,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={25} onChange={onChange} />)
expect(screen.getByText('10')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('parses string values for slider widgets', () => {
const schema: FieldSchema = {
name: 'test_slider_string_value',
type: 'number',
label: 'Test Slider String Value',
description: 'A slider with legacy string value',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 10,
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
expect(screen.getByText('2.5')).toBeInTheDocument()
})
})
describe('select features', () => {
it('renders placeholder when no options', () => {
const schema: FieldSchema = {
name: 'test_select_no_options',
type: 'string',
label: 'Test Select No Options',
description: 'A select with no options',
required: false,
'x-widget': 'select',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('No options available for select')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,2 @@
export { DynamicConfigForm } from './DynamicConfigForm'
export { DynamicField } from './DynamicField'

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { Check, Loader2, Pencil, Plus, Server, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useBackendConnections } from '@/hooks/useBackendConnections'
import { isElectron } from '@/lib/runtime'
import type { BackendConnection } from '@/types/electron'
export interface BackendManagerProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
const {
activeId,
addBackend,
backends,
loading,
removeBackend,
switchBackend,
updateBackend,
} = useBackendConnections()
const [editConn, setEditConn] = useState<Partial<BackendConnection> | null>(null)
const [deleteConn, setDeleteConn] = useState<BackendConnection | null>(null)
if (!isElectron()) return null
const handleSave = async () => {
if (!editConn?.name || !editConn?.url) return
const urlPattern = /^https?:\/\//
if (!urlPattern.test(editConn.url)) return
if (editConn.id) {
await updateBackend(editConn.id, editConn)
} else {
await addBackend({
name: editConn.name,
url: editConn.url,
isDefault: editConn.isDefault ?? false,
})
}
setEditConn(null)
}
const handleDelete = async () => {
if (!deleteConn) return
if (deleteConn.id === activeId) return
await removeBackend(deleteConn.id)
setDeleteConn(null)
}
const handleSwitch = async (id: string) => {
if (id === activeId) return
await switchBackend(id)
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-106.25">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<DialogBody className="pr-4">
<div className="flex flex-col gap-3 py-4">
{backends.map((backend) => {
const isActive = backend.id === activeId
return (
<div
key={backend.id}
className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-border'
}`}
>
<div className="flex flex-1 items-center gap-3 overflow-hidden">
<div className="shrink-0">
{isActive ? (
<Check className="h-5 w-5 text-blue-500" />
) : (
<div className="h-3 w-3 rounded-full bg-muted-foreground/30 ml-1" title="未知状态" />
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium leading-none">
{backend.name}
</span>
<span className="truncate text-xs text-muted-foreground mt-1">
{backend.url}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{!isActive && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSwitch(backend.id)}
title="切换到此后端"
>
<Server className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditConn(backend)}
title="编辑"
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeleteConn(backend)}
disabled={isActive}
title={isActive ? '无法删除活跃后端' : '删除'}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
)
})}
</div>
</DialogBody>
)}
<div className="flex justify-end pt-4 border-t">
<Button
className="w-full"
onClick={() => setEditConn({ name: '', url: 'http://', isDefault: false })}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit/Add Dialog */}
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
<DialogContent className="sm:max-w-106.25" confirmOnEnter>
<DialogHeader>
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={editConn?.name || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
placeholder="我的服务器"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">URL</Label>
<Input
id="url"
value={editConn?.url || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, url: e.target.value } : null))
}
placeholder="http://192.168.1.100:8001"
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setEditConn(null)}>
</Button>
<Button
onClick={handleSave}
disabled={
!editConn?.name ||
!editConn?.url ||
!/^https?:\/\//.test(editConn.url)
}
data-dialog-action="confirm"
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteConn} onOpenChange={(open) => !open && setDeleteConn(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteConn?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,256 @@
import { useState } from 'react'
import {
ArrowRight,
Bot,
CheckCircle2,
Loader2,
XCircle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { isElectron } from '@/lib/runtime'
interface BackendSetupWizardProps {
open: boolean
}
type TestStatus = 'idle' | 'loading' | 'success' | 'error'
/**
* First-launch backend setup wizard for Electron environment.
* Full-screen modal that guides users to configure their first backend connection.
* Cannot be dismissed until configuration is complete.
*/
export function BackendSetupWizard({ open }: BackendSetupWizardProps) {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [testStatus, setTestStatus] = useState<TestStatus>('idle')
const [testError, setTestError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Validation errors
const [nameError, setNameError] = useState('')
const [urlError, setUrlError] = useState('')
// Only render in Electron environment
if (!isElectron()) {
return null
}
if (!open) {
return null
}
const validateName = (value: string): boolean => {
if (!value.trim()) {
setNameError('后端名称不能为空')
return false
}
setNameError('')
return true
}
const validateUrl = (value: string): boolean => {
if (!value.trim()) {
setUrlError('后端地址不能为空')
return false
}
if (!/^https?:\/\/.+/.test(value)) {
setUrlError('地址必须以 http:// 或 https:// 开头')
return false
}
if (value.endsWith('/')) {
setUrlError('地址末尾不能包含 /')
return false
}
setUrlError('')
return true
}
const handleTestConnection = async () => {
if (!validateUrl(url)) return
setTestStatus('loading')
setTestError('')
try {
const response = await fetch(`${url}/api/webui/system/health`, {
method: 'GET',
signal: AbortSignal.timeout(10000),
})
if (response.ok) {
setTestStatus('success')
} else {
setTestStatus('error')
setTestError(`服务器返回状态码 ${response.status}`)
}
} catch (err) {
setTestStatus('error')
if (err instanceof DOMException && err.name === 'TimeoutError') {
setTestError('连接超时,请检查地址是否正确')
} else if (err instanceof TypeError) {
setTestError('无法连接到服务器,请检查地址和网络')
} else {
setTestError(err instanceof Error ? err.message : '未知错误')
}
}
}
const handleFinish = async () => {
const isNameValid = validateName(name)
const isUrlValid = validateUrl(url)
if (!isNameValid || !isUrlValid) return
setIsSubmitting(true)
try {
const newBackend = await window.electronAPI!.addBackend({
name: name.trim(),
url: url.trim(),
isDefault: true,
})
await window.electronAPI!.setActiveBackend(newBackend.id)
await window.electronAPI!.markFirstLaunchComplete()
window.location.reload()
} catch (err) {
setIsSubmitting(false)
setTestStatus('error')
setTestError(
err instanceof Error ? err.message : '保存配置失败,请重试'
)
}
}
const isFormValid = name.trim() !== '' && /^https?:\/\/.+/.test(url) && !url.endsWith('/')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
</div>
<Card className="relative z-10 max-w-md w-full mx-4 shadow-lg">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">使 MaiBot</CardTitle>
<CardDescription>
使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Backend name field */}
<div className="space-y-2">
<Label htmlFor="backend-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-name"
placeholder="例如:本地服务器"
value={name}
onChange={(e) => {
setName(e.target.value)
if (nameError) validateName(e.target.value)
}}
onBlur={() => validateName(name)}
/>
{nameError && (
<p className="text-sm text-destructive">{nameError}</p>
)}
</div>
{/* Backend URL field */}
<div className="space-y-2">
<Label htmlFor="backend-url">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-url"
placeholder="例如http://192.168.1.100:8001"
value={url}
onChange={(e) => {
setUrl(e.target.value)
if (urlError) validateUrl(e.target.value)
// Reset test status when URL changes
if (testStatus !== 'idle') {
setTestStatus('idle')
setTestError('')
}
}}
onBlur={() => validateUrl(url)}
/>
{urlError && (
<p className="text-sm text-destructive">{urlError}</p>
)}
</div>
{/* Test connection */}
<div className="space-y-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testStatus === 'loading' || !url.trim()}
className="w-full"
>
{testStatus === 'loading' ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
'测试连接'
)}
</Button>
{testStatus === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
</div>
)}
{testStatus === 'error' && (
<div className="flex items-start gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{testError || '无法连接'}</span>
</div>
)}
</div>
{/* Submit button */}
<Button
onClick={handleFinish}
disabled={!isFormValid || isSubmitting}
className="w-full"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
使
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { Copy, Minus, Square, X } from 'lucide-react'
import { useMemo } from 'react'
import { useWindowControls } from '@/hooks/useWindowControls'
import { getPlatform, isElectron } from '@/lib/runtime'
const dragStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties & { WebkitAppRegion: string }
const noDragStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties & { WebkitAppRegion: string }
export function TitleBar() {
const { close, isMaximized, minimize, toggleMaximize } = useWindowControls()
const isMac = useMemo(() => getPlatform() === 'darwin', [])
if (!isElectron()) return null
return (
<div
className={`flex items-center justify-between border-b border-border bg-background select-none ${isMac ? 'h-7' : 'h-8'}`}
style={dragStyle}
>
{/* macOS traffic light padding */}
{isMac && <div className="h-full w-[78px]" style={noDragStyle} />}
{/* Title / Drag area */}
<div className="flex flex-1 items-center justify-center text-xs font-semibold text-foreground/80">
MaiBot
</div>
{/* Windows / Linux Controls */}
{!isMac && (
<div className="flex h-full items-center" style={noDragStyle}>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={minimize}
tabIndex={-1}
type="button"
aria-label="最小化"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={toggleMaximize}
tabIndex={-1}
type="button"
aria-label={isMaximized ? "还原窗口" : "最大化"}
>
{isMaximized ? (
<Copy className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-destructive hover:text-destructive-foreground"
onClick={close}
tabIndex={-1}
type="button"
aria-label="关闭窗口"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,123 @@
/**
* 表情包缩略图组件
*
* 特性:
* - 自动处理 202 响应(缩略图生成中)
* - 显示 Skeleton 占位符
* - 自动重试加载
* - 加载失败显示占位图标
*/
import { useState, useEffect, useCallback } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { ImageIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface EmojiThumbnailProps {
src: string
alt?: string
className?: string
/** 最大重试次数 */
maxRetries?: number
/** 重试间隔(毫秒) */
retryInterval?: number
}
type LoadingState = 'loading' | 'loaded' | 'generating' | 'error'
export function EmojiThumbnail({
src,
alt = '表情包',
className,
maxRetries = 5,
retryInterval = 1500,
}: EmojiThumbnailProps) {
const [state, setState] = useState<LoadingState>('loading')
const [retryCount, setRetryCount] = useState(0)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [currentSrc, setCurrentSrc] = useState(src)
// 当 src 变化时重置状态
if (src !== currentSrc) {
setState('loading')
setRetryCount(0)
setImageSrc(null)
setCurrentSrc(src)
}
const loadImage = useCallback(async () => {
try {
const response = await fetch(src, {
credentials: 'include', // 携带 Cookie
})
if (response.status === 202) {
// 缩略图正在生成中
setState('generating')
if (retryCount < maxRetries) {
// 延迟后重试
setTimeout(() => {
setRetryCount(prev => prev + 1)
}, retryInterval)
} else {
// 超过最大重试次数,显示错误
setState('error')
}
return
}
if (!response.ok) {
setState('error')
return
}
// 成功获取图片
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
setImageSrc(objectUrl)
setState('loaded')
} catch (error) {
console.error('加载缩略图失败:', error)
setState('error')
}
}, [src, retryCount, maxRetries, retryInterval])
useEffect(() => {
loadImage()
}, [loadImage])
// 清理 Object URL
useEffect(() => {
return () => {
if (imageSrc) {
URL.revokeObjectURL(imageSrc)
}
}
}, [imageSrc])
// 加载中或生成中显示 Skeleton
if (state === 'loading' || state === 'generating') {
return (
<Skeleton className={cn('w-full h-full', className)} />
)
}
// 加载失败显示占位图标
if (state === 'error' || !imageSrc) {
return (
<div className={cn('w-full h-full flex items-center justify-center bg-muted', className)}>
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)
}
// 加载成功显示图片
return (
<img
src={imageSrc}
alt={alt}
className={cn('w-full h-full object-contain', className)}
/>
)
}

View File

@@ -0,0 +1,310 @@
import { Component } from 'react'
import { useTranslation } from 'react-i18next'
import type { ErrorInfo, ReactNode } from 'react'
import { AlertTriangle, RefreshCw, Home, ChevronDown, ChevronUp, Copy, Check, Bug } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { useState } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}
// 解析堆栈信息为结构化数据
interface StackFrame {
functionName: string
fileName: string
lineNumber: string
columnNumber: string
raw: string
}
function parseStackTrace(stack: string): StackFrame[] {
const lines = stack.split('\n').slice(1) // 跳过第一行(错误消息)
const frames: StackFrame[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('at ')) continue
// 匹配格式: at functionName (fileName:line:column) 或 at fileName:line:column
const match = trimmed.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/)
if (match) {
frames.push({
functionName: match[1] || '<anonymous>',
fileName: match[2],
lineNumber: match[3],
columnNumber: match[4],
raw: trimmed,
})
} else {
frames.push({
functionName: '<unknown>',
fileName: '',
lineNumber: '',
columnNumber: '',
raw: trimmed,
})
}
}
return frames
}
// 错误详情展示组件(函数组件,用于使用 hooks
function ErrorDetails({ error, errorInfo }: { error: Error; errorInfo: ErrorInfo | null }) {
const [isStackOpen, setIsStackOpen] = useState(true)
const [isComponentStackOpen, setIsComponentStackOpen] = useState(false)
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
const stackFrames = error.stack ? parseStackTrace(error.stack) : []
const copyErrorInfo = async () => {
const errorText = `
Error: ${error.name}
Message: ${error.message}
Stack Trace:
${error.stack || 'No stack trace available'}
Component Stack:
${errorInfo?.componentStack || 'No component stack available'}
URL: ${window.location.href}
User Agent: ${navigator.userAgent}
Time: ${new Date().toISOString()}
`.trim()
try {
await navigator.clipboard.writeText(errorText)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
return (
<div className="space-y-4">
{/* 错误消息 */}
<Alert variant="destructive" className="border-red-500/50 bg-red-500/10">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="font-mono text-sm">
<span className="font-semibold">{error.name}:</span> {error.message}
</AlertDescription>
</Alert>
{/* 堆栈跟踪 */}
{stackFrames.length > 0 && (
<Collapsible open={isStackOpen} onOpenChange={setIsStackOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
<span className="font-semibold text-sm flex items-center gap-2">
<Bug className="h-4 w-4" />
Stack Trace ({stackFrames.length} frames)
</span>
{isStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[280px] rounded-md border bg-muted/30">
<div className="p-3 space-y-1">
{stackFrames.map((frame, index) => (
<div
key={index}
className="font-mono text-xs p-2 rounded hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground w-6 text-right flex-shrink-0">
{index + 1}.
</span>
<div className="flex-1 min-w-0">
<span className="text-primary font-medium">
{frame.functionName}
</span>
{frame.fileName && (
<div className="text-muted-foreground mt-0.5 break-all">
{frame.fileName}
{frame.lineNumber && (
<span className="text-yellow-600 dark:text-yellow-400">
:{frame.lineNumber}:{frame.columnNumber}
</span>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</CollapsibleContent>
</Collapsible>
)}
{/* 组件堆栈 */}
{errorInfo?.componentStack && (
<Collapsible open={isComponentStackOpen} onOpenChange={setIsComponentStackOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
<span className="font-semibold text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Component Stack
</span>
{isComponentStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[200px] rounded-md border bg-muted/30">
<pre className="p-3 font-mono text-xs whitespace-pre-wrap text-muted-foreground">
{errorInfo.componentStack}
</pre>
</ScrollArea>
</CollapsibleContent>
</Collapsible>
)}
{/* 复制按钮 */}
<Button
variant="outline"
size="sm"
onClick={copyErrorInfo}
className="w-full"
>
{copied ? (
<>
<Check className="mr-2 h-4 w-4 text-green-500" />
{t('errorBoundary.copiedToClipboard')}
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
{t('errorBoundary.copyError')}
</>
)}
</Button>
</div>
)
}
// 错误回退 UI
function ErrorFallback({
error,
errorInfo,
}: {
error: Error
errorInfo: ErrorInfo | null
}) {
const { t } = useTranslation()
const handleGoHome = () => {
window.location.href = '/'
}
const handleRefresh = () => {
window.location.reload()
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-2xl shadow-lg">
<CardHeader className="text-center pb-2">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<CardTitle className="text-2xl font-bold">{t('errorBoundary.title')}</CardTitle>
<CardDescription className="text-base mt-2">
{t('errorBoundary.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ErrorDetails error={error} errorInfo={errorInfo} />
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-2 pt-2">
<Button onClick={handleRefresh} className="flex-1">
<RefreshCw className="mr-2 h-4 w-4" />
{t('errorBoundary.refreshPage')}
</Button>
<Button onClick={handleGoHome} variant="outline" className="flex-1">
<Home className="mr-2 h-4 w-4" />
{t('errorBoundary.goHome')}
</Button>
</div>
{/* 提示信息 */}
<p className="text-xs text-center text-muted-foreground pt-2">
{t('errorBoundary.footer')}
</p>
</CardContent>
</Card>
</div>
)
}
// 错误边界类组件
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
this.setState({ errorInfo })
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
})
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<ErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
/>
)
}
return this.props.children
}
}
// 路由级别的错误边界组件(用于 TanStack Router
export function RouteErrorBoundary({ error }: { error: Error }) {
return (
<ErrorFallback
error={error}
errorInfo={null}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* HTTP 警告横幅组件
* 当用户通过 HTTP 访问时显示安全警告
*/
export function HttpWarningBanner() {
const { t } = useTranslation()
// 直接计算初始状态,避免 effect 中调用 setState
const isHttp = window.location.protocol === 'http:'
const hostname = window.location.hostname.toLowerCase()
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
const dismissed = sessionStorage.getItem('http-warning-dismissed') === 'true'
// 本地访问localhost/127.0.0.1)不显示警告
const [isVisible, setIsVisible] = useState(isHttp && !isLocalhost && !dismissed)
const [isDismissed, setIsDismissed] = useState(false)
const handleDismiss = () => {
setIsDismissed(true)
setIsVisible(false)
sessionStorage.setItem('http-warning-dismissed', 'true')
}
if (!isVisible || isDismissed) {
return null
}
return (
<div className="relative bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-sm">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
<span className="font-semibold">{t('httpWarning.title')}</span>
{t('httpWarning.message')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
{t('httpWarning.description')}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDismiss}
className="h-8 w-8 text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 flex-shrink-0"
aria-label={t('httpWarning.dismiss')}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
export { CodeEditor } from './CodeEditor'
export type { Language } from './CodeEditor'
// 重启遮罩层
export { RestartOverlay } from './restart-overlay'
// 兼容旧版本
export { RestartingOverlay } from './RestartingOverlay.legacy'
// 列表编辑器
export { ListFieldEditor } from './ListFieldEditor'
// Markdown 渲染器
export { MarkdownRenderer } from './markdown-renderer'

View File

@@ -0,0 +1,278 @@
import { Link } from '@tanstack/react-router'
import {
BookOpen,
ChevronLeft,
Globe,
LogOut,
Menu,
MessageSquare,
Moon,
Search,
Server,
SlidersHorizontal,
Sun,
} from 'lucide-react'
import { LayoutGroup, motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BackgroundLayer } from '@/components/background-layer'
import { BackendManager } from '@/components/electron/BackendManager'
import { SearchDialog } from '@/components/search-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ShortcutKbd } from '@/components/ui/kbd'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background'
import { logout } from '@/lib/fetch-with-auth'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import type { WorkspaceMode } from './types'
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
const LANGUAGE_NAMES: Record<(typeof LANGUAGE_CODES)[number], string> = {
zh: '中文',
en: 'English',
ja: '日本語',
ko: '한국어',
}
interface HeaderProps {
sidebarOpen: boolean
mobileMenuOpen: boolean
searchOpen: boolean
actualTheme: 'light' | 'dark'
onSidebarToggle: () => void
onMobileMenuToggle: () => void
onSearchOpenChange: (open: boolean) => void
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
workspaceMode: WorkspaceMode
}
export function Header({
sidebarOpen,
mobileMenuOpen,
searchOpen,
actualTheme,
onSidebarToggle,
onMobileMenuToggle,
onSearchOpenChange,
onThemeChange,
workspaceMode,
}: HeaderProps) {
const { t, i18n: i18nInstance } = useTranslation()
const currentLang = i18nInstance.language || 'zh'
const { config: headerBg, inheritedFrom } = useBackground('header')
const inheritsPageBackground = inheritedFrom === 'page'
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
const [activeBackendName, setActiveBackendName] = useState<string>('')
useEffect(() => {
if (!isElectron()) return
window.electronAPI!.getActiveBackend().then((b) => {
setActiveBackendName(b?.name ?? t('header.notConnected'))
})
}, [])
const handleLogout = async () => {
await logout()
}
return (
<header
className={cn(
'sticky top-0 isolate z-10 flex h-16 min-w-0 items-center justify-between gap-2 border-b px-3 backdrop-blur-md sm:px-4',
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80'
)}
>
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
<div className="relative z-10 flex min-w-0 shrink-0 items-center gap-2 sm:gap-4">
{/* 移动端菜单按钮 */}
<button
onClick={onMobileMenuToggle}
aria-label={t('a11y.closeMenu')}
aria-expanded={mobileMenuOpen}
className={cn(
'hover:bg-accent rounded-lg p-2 lg:hidden',
workspaceMode === 'chat' && 'hidden'
)}
>
<Menu className="h-5 w-5" />
</button>
{/* 桌面端侧边栏收起/展开按钮 */}
<button
onClick={onSidebarToggle}
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
aria-expanded={sidebarOpen}
className={cn(
'hover:bg-accent hidden rounded-lg p-2 lg:block',
workspaceMode === 'chat' && 'lg:hidden'
)}
>
<ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
/>
</button>
</div>
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-end gap-1 sm:gap-2">
{/* 工作区切换:复用 Tabs 组件 + Motion 动画指示器 */}
<LayoutGroup id="workspace-switcher">
<Tabs value={workspaceMode} aria-label={t('workspace.switcherLabel')}>
<TabsList className="bg-background/60 relative h-9 gap-0.5 border p-1 shadow-sm backdrop-blur">
<TabsTrigger
asChild
value="settings"
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
>
<Link to="/">
{workspaceMode === 'settings' && (
<motion.span
layoutId="workspace-tab-pill"
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
/>
)}
<SlidersHorizontal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('workspace.settings')}</span>
</Link>
</TabsTrigger>
<TabsTrigger
asChild
value="chat"
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
>
<Link to="/chat">
{workspaceMode === 'chat' && (
<motion.span
layoutId="workspace-tab-pill"
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
/>
)}
<MessageSquare className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('workspace.chat')}</span>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</LayoutGroup>
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 后端切换按钮(仅 Electron */}
{isElectron() && (
<>
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setBackendManagerOpen(true)}
title={t('header.toggleConnection')}
>
<Server className="h-4 w-4" />
<span className="text-muted-foreground hidden max-w-25 truncate text-xs sm:inline">
{activeBackendName}
</span>
</Button>
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
<div className="bg-border h-6 w-px" />
</>
)}
{/* 搜索框 */}
<button
onClick={() => onSearchOpenChange(true)}
aria-label={t('header.searchPlaceholder')}
className="bg-background/50 hover:bg-accent/50 relative hidden h-9 w-64 items-center rounded-md border pr-16 pl-9 text-left transition-colors md:flex"
>
<Search
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">{t('header.searchPlaceholder')}</span>
<ShortcutKbd
size="sm"
className="absolute top-1/2 right-2 -translate-y-1/2"
keys={['mod', 'k']}
/>
</button>
{/* 搜索对话框 */}
<SearchDialog open={searchOpen} onOpenChange={onSearchOpenChange} />
{/* 麦麦文档链接 */}
<Button
variant="ghost"
size="sm"
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
className="hidden gap-2 sm:inline-flex"
title={t('header.viewDocs')}
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">{t('header.docs')}</span>
</Button>
{/* 语言切换 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 px-2 sm:px-3">
<Globe className="h-4 w-4" />
<span className="hidden text-xs sm:inline">
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ??
currentLang}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGE_CODES.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => i18nInstance.changeLanguage(code)}
className={cn(
'cursor-pointer',
currentLang.split('-')[0] === code && 'text-primary font-semibold'
)}
>
{currentLang.split('-')[0] === code && <span className="mr-2"></span>}
{LANGUAGE_NAMES[code]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 主题切换按钮 */}
<button
onClick={(e) => {
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
toggleThemeWithTransition(newTheme, onThemeChange, e)
}}
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
className="hover:bg-accent rounded-lg p-2"
>
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* 分隔线 */}
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 登出按钮 */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="gap-2 px-2 sm:px-3"
title={t('header.logout')}
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">{t('header.logoutLabel')}</span>
</Button>
</div>
</header>
)
}

View File

@@ -0,0 +1,239 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useRouterState } from '@tanstack/react-router'
import { AnimatePresence, motion } from 'motion/react'
import { BackgroundLayer } from '@/components/background-layer'
import { BackToTop } from '@/components/back-to-top'
import { HttpWarningBanner } from '@/components/http-warning-banner'
import { SkipNav } from '@/components/ui/skip-nav'
import { useAnnounce } from '@/components/ui/announcer'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useTheme } from '@/components/use-theme'
import { useAuthGuard } from '@/hooks/use-auth'
import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar'
import { matchesShortcut } from '@/lib/keyboard'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { menuSections } from './constants'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import type { LayoutProps } from './types'
export function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const { checking } = useAuthGuard() // 检查认证状态
const router = useRouter()
const pathname = useRouterState({ select: (state) => state.location.pathname })
const announce = useAnnounce()
const workspaceMode = pathname.startsWith('/chat') ? 'chat' : 'settings'
const isChatWorkspace = workspaceMode === 'chat'
const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态
const { theme, setTheme } = useTheme()
// 侧边栏状态变化时,延迟启用/禁用 tooltip
useEffect(() => {
if (sidebarOpen) {
// 侧边栏展开时,立即禁用 tooltip
setTooltipsEnabled(false)
} else {
// 侧边栏收起时,等待动画完成后再启用 tooltip
const timer = setTimeout(() => {
setTooltipsEnabled(true)
}, 350) // 稍大于 CSS transition duration (300ms)
return () => clearTimeout(timer)
}
}, [sidebarOpen])
// 搜索快捷键监听Cmd/Ctrl + K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (matchesShortcut(e, ['mod', 'k'])) {
e.preventDefault()
setSearchOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 路由变更:焦点管理 + 屏幕阅读器播报 + document.title 更新
useEffect(() => {
// 构建 路径 -> 页面标题 的映射表(以当前语言 t() 翻译)
const pathToLabel: Record<string, string> = {}
for (const section of menuSections) {
for (const item of section.items) {
pathToLabel[item.path] = t(item.label)
}
}
pathToLabel['/chat'] = t('workspace.chat')
return router.subscribe('onResolved', () => {
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
const fullTitle =
pageTitle === 'MaiBot Dashboard' ? 'MaiBot Dashboard' : `${pageTitle} — MaiBot Dashboard`
// 更新 document.title
document.title = fullTitle
// 屏幕阅读器朗读导航结果
announce(t('a11y.navigatedTo', { page: pageTitle }), 'polite')
// 将焦点移到主内容区(仅当焦点不在其内部时)
const mainEl = document.getElementById('main-content')
if (mainEl && !mainEl.contains(document.activeElement)) {
// requestAnimationFrame 确保 DOM 已渲染完成
requestAnimationFrame(() => {
mainEl.focus({ preventScroll: true })
})
}
})
}, [router, announce, t])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
}
const actualTheme = getActualTheme()
const { config: pageBg } = useBackground('page')
// 认证检查中,显示加载状态
if (checking) {
return (
<div className="bg-background flex h-screen items-center justify-center">
<div className="text-muted-foreground">{t('layout.verifyingLogin')}</div>
</div>
)
}
return (
<TooltipProvider delayDuration={300}>
<SkipNav />
{isElectron() && <TitleBar />}
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
<BackgroundLayer config={pageBg} layerId="page" />
<div className="relative z-10 flex h-full w-full overflow-hidden">
{/* Sidebar仅在设置工作区显示伴随滑入/滑出动画 */}
<AnimatePresence initial={false}>
{!isChatWorkspace && (
<motion.div
key="settings-sidebar"
className="relative z-40 hidden shrink-0 lg:block"
initial={{ width: 0, opacity: 0 }}
animate={{ width: sidebarOpen ? 208 : 64, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 320,
damping: 36,
mass: 0.7,
opacity: { duration: 0.2 },
}}
style={{ overflow: 'hidden' }}
>
<Sidebar
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* 移动端 Sidebar 走自己的 fixed 定位,通过 mobileMenuOpen 控制显隐 */}
{!isChatWorkspace && (
<div className="lg:hidden">
<Sidebar
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={() => setMobileMenuOpen(false)}
/>
</div>
)}
{/* Mobile overlay */}
<AnimatePresence>
{!isChatWorkspace && mobileMenuOpen && (
<motion.div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={() => setMobileMenuOpen(false)}
/>
)}
</AnimatePresence>
{/* Main content */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
<HttpWarningBanner />
{/* Topbar */}
<Header
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
searchOpen={searchOpen}
actualTheme={actualTheme}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
onSearchOpenChange={setSearchOpen}
onThemeChange={setTheme}
workspaceMode={workspaceMode}
/>
{/* Page content */}
<main
id="main-content"
tabIndex={-1}
className={cn(
'relative isolate flex-1 overflow-hidden outline-none',
isChatWorkspace
? 'bg-transparent'
: pageBg.type === 'none'
? 'bg-background'
: 'bg-transparent'
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={workspaceMode}
className="relative z-10 h-full min-w-0"
initial={{ opacity: 0, x: isChatWorkspace ? 32 : -32, filter: 'blur(6px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: isChatWorkspace ? -32 : 32, filter: 'blur(6px)' }}
transition={{
type: 'spring',
stiffness: 320,
damping: 34,
mass: 0.7,
opacity: { duration: 0.18 },
filter: { duration: 0.22 },
}}
>
{children}
</motion.div>
</AnimatePresence>
</main>
{/* Back to Top Button */}
{!isChatWorkspace && <BackToTop />}
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { getDashboardVersionStatus, type DashboardVersionStatus } from '@/lib/system-api'
import { cn } from '@/lib/utils'
import { APP_VERSION, formatVersion } from '@/lib/version'
interface LogoAreaProps {
sidebarOpen: boolean
}
export function LogoArea({ sidebarOpen }: LogoAreaProps) {
const [versionStatus, setVersionStatus] = useState<DashboardVersionStatus | null>(null)
useEffect(() => {
let mounted = true
const loadVersionStatus = async () => {
try {
const status = await getDashboardVersionStatus(APP_VERSION)
if (mounted) {
setVersionStatus(status)
}
} catch (error) {
console.debug('检查 WebUI 版本更新失败:', error)
}
}
void loadVersionStatus()
return () => {
mounted = false
}
}, [])
const hasUpdate = versionStatus?.has_update === true && Boolean(versionStatus.latest_version)
return (
<div className="flex h-20 items-center border-b px-4">
<div
className={cn(
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
// 移动端始终完整显示,桌面端根据 sidebarOpen 切换
'lg:flex-1',
!sidebarOpen && 'lg:flex-none lg:w-8'
)}
>
{/* 移动端始终显示完整 Logo桌面端根据 sidebarOpen 切换 */}
<div className={cn(
"flex min-w-0 flex-col items-start justify-center gap-1",
!sidebarOpen && "lg:hidden"
)}>
<span className="max-w-full truncate whitespace-nowrap text-xl font-bold text-primary-gradient">
MaiBot WebUI
</span>
<div className="flex max-w-full items-center gap-2 overflow-hidden">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold text-primary/70">
{formatVersion()}
</span>
{hasUpdate && (
<a
href={versionStatus?.pypi_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex h-5 min-w-0 items-center rounded-md border border-amber-400/50 px-2",
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
)}
>
<span className="truncate"> v{versionStatus?.latest_version}</span>
</a>
)}
</div>
{false && hasUpdate && (
<a
href={versionStatus?.pypi_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex h-5 items-center rounded-md border border-amber-400/50 px-2",
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
)}
>
v{versionStatus?.latest_version}
</a>
)}
<div className="hidden">
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
<span className="text-base font-semibold text-primary/70 whitespace-nowrap">
{formatVersion()}
</span>
</div>
</div>
{/* 折叠时的 Logo - 仅桌面端显示 */}
{!sidebarOpen && (
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { Link, useMatchRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { MenuItem } from './types'
interface NavItemProps {
item: MenuItem
sidebarOpen: boolean
tooltipsEnabled: boolean
onMobileMenuClose: () => void
}
export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) {
const { t } = useTranslation()
const matchRoute = useMatchRoute()
const isActive = matchRoute({ to: item.path })
const Icon = item.icon
const menuItemContent = (
<>
{/* 左侧高亮条 */}
{isActive && (
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
)}
<div className={cn(
'flex items-center transition-all duration-300',
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
)}>
<Icon
className={cn(
'h-5 w-5 flex-shrink-0',
isActive && 'text-primary'
)}
strokeWidth={2}
fill="none"
/>
<span className={cn(
'text-sm font-medium whitespace-nowrap transition-all duration-300',
isActive && 'font-semibold',
sidebarOpen
? 'opacity-100 max-w-[200px]'
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
)}>
{t(item.label)}
</span>
</div>
</>
)
return (
<li className="relative">
<Tooltip>
<TooltipTrigger asChild>
<Link
to={item.path}
data-tour={item.tourId}
className={cn(
'relative flex items-center rounded-lg py-2 transition-all duration-300',
'hover:bg-accent hover:text-accent-foreground',
isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground',
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
)}
onClick={onMobileMenuClose}
>
{menuItemContent}
</Link>
</TooltipTrigger>
{tooltipsEnabled && (
<TooltipContent side="right" className="hidden lg:block">
<p>{t(item.label)}</p>
</TooltipContent>
)}
</Tooltip>
</li>
)
}

View File

@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { useBackground } from '@/hooks/use-background'
import { BackgroundLayer } from '@/components/background-layer'
import { LogoArea } from './LogoArea'
import { NavItem } from './NavItem'
import { menuSections } from './constants'
interface SidebarProps {
sidebarOpen: boolean
mobileMenuOpen: boolean
tooltipsEnabled: boolean
onMobileMenuClose: () => void
}
export function Sidebar({
sidebarOpen,
mobileMenuOpen,
tooltipsEnabled,
onMobileMenuClose
}: SidebarProps) {
const { t } = useTranslation()
const { config: sidebarBg, inheritedFrom } = useBackground('sidebar')
const inheritsPageBackground = inheritedFrom === 'page'
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0 lg:h-full',
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
'w-52 lg:w-auto',
sidebarOpen ? 'lg:w-52' : 'lg:w-16',
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
{!inheritsPageBackground && <BackgroundLayer config={sidebarBg} layerId="sidebar" />}
{/* Logo 区域 */}
<div className="relative z-10">
<LogoArea sidebarOpen={sidebarOpen} />
</div>
<ScrollArea className={cn(
'relative z-10',
"min-h-0 flex-1 overflow-x-hidden",
!sidebarOpen && "lg:w-16"
)}
viewportClassName="[&>div]:!block"
>
<nav
aria-label={t('a11y.sidebarNav')}
className={cn(
"p-4",
!sidebarOpen && "lg:p-2 lg:w-16"
)}>
<ul className={cn(
// 移动端始终使用正常间距,桌面端根据 sidebarOpen 切换
"space-y-6",
!sidebarOpen && "lg:space-y-3 lg:w-full"
)}>
{menuSections.map((section, sectionIndex) => (
<li key={section.title}>
{/* 块标题 - 移动端始终可见,桌面端根据 sidebarOpen 切换 */}
<div className={cn(
"px-3 h-[1.25rem]",
// 移动端始终显示,桌面端根据状态切换
"mb-2",
!sidebarOpen && "lg:mb-1 lg:invisible"
)}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
{t(section.title)}
</h3>
</div>
{/* 分割线 - 仅在桌面端折叠时显示 */}
{!sidebarOpen && sectionIndex > 0 && (
<div className="hidden lg:block mb-2 border-t border-border" />
)}
{/* 菜单项列表 */}
<ul className="space-y-1">
{section.items.map((item) => (
<NavItem
key={item.path}
item={item}
sidebarOpen={sidebarOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={onMobileMenuClose}
/>
))}
</ul>
</li>
))}
</ul>
</nav>
</ScrollArea>
</aside>
)
}

View File

@@ -0,0 +1,46 @@
import { Activity, Boxes, BrainCircuit, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Settings, Sliders, Smile } from 'lucide-react'
import type { MenuSection } from './types'
export const menuSections: MenuSection[] = [
{
title: 'sidebar.groups.overview',
items: [
{ icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' },
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
],
},
{
title: 'sidebar.groups.botConfig',
items: [
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
{ icon: ScrollText, label: 'sidebar.menu.promptManagement', path: '/config/prompts' },
],
},
{
title: 'sidebar.groups.botResources',
items: [
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' },
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' },
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' },
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
],
},
{
title: 'sidebar.groups.extensionsMonitor',
items: [
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
{ icon: Network, label: 'sidebar.menu.mcpSettings', path: '/mcp-settings' },
],
},
{
title: 'sidebar.groups.system',
items: [
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
{ icon: BrainCircuit, label: 'sidebar.menu.reasoningProcess', path: '/reasoning-process', searchDescription: 'search.items.reasoningProcessDesc' },
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' },
],
},
]

View File

@@ -0,0 +1,2 @@
export { Layout } from './Layout'
export type { LayoutProps, MenuItem, MenuSection } from './types'

View File

@@ -0,0 +1,21 @@
import type { ComponentType, ReactNode } from 'react'
import type { LucideProps } from 'lucide-react'
export interface LayoutProps {
children: ReactNode
}
export type WorkspaceMode = 'settings' | 'chat'
export interface MenuItem {
icon: ComponentType<LucideProps>
label: string
path: string
searchDescription?: string
tourId?: string
}
export interface MenuSection {
title: string
items: MenuItem[]
}

View File

@@ -0,0 +1,134 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import type { ComponentPropsWithoutRef } from 'react'
interface MarkdownRendererProps {
content: string
className?: string
}
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
return (
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// 自定义代码块样式
code({ inline, className, children, ...props }: ComponentPropsWithoutRef<'code'> & { inline?: boolean }) {
return inline ? (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
) : (
<code className={`${className} block bg-muted p-4 rounded-lg overflow-x-auto`} {...props}>
{children}
</code>
)
},
// 自定义表格样式
table({ children, ...props }) {
return (
<div className="overflow-x-auto">
<table className="border-collapse border border-border" {...props}>
{children}
</table>
</div>
)
},
th({ children, ...props }) {
return (
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold" {...props}>
{children}
</th>
)
},
td({ children, ...props }) {
return (
<td className="border border-border px-4 py-2" {...props}>
{children}
</td>
)
},
// 自定义链接样式
a({ children, ...props }) {
return (
<a className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
)
},
// 自定义引用块样式
blockquote({ children, ...props }) {
return (
<blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground" {...props}>
{children}
</blockquote>
)
},
// 自定义标题样式
h1({ children, ...props }) {
return (
<h1 className="text-3xl font-bold mt-6 mb-4" {...props}>
{children}
</h1>
)
},
h2({ children, ...props }) {
return (
<h2 className="text-2xl font-bold mt-5 mb-3" {...props}>
{children}
</h2>
)
},
h3({ children, ...props }) {
return (
<h3 className="text-xl font-bold mt-4 mb-2" {...props}>
{children}
</h3>
)
},
h4({ children, ...props }) {
return (
<h4 className="text-lg font-semibold mt-3 mb-2" {...props}>
{children}
</h4>
)
},
// 自定义列表样式
ul({ children, ...props }) {
return (
<ul className="list-disc list-inside space-y-1 my-2" {...props}>
{children}
</ul>
)
},
ol({ children, ...props }) {
return (
<ol className="list-decimal list-inside space-y-1 my-2" {...props}>
{children}
</ol>
)
},
// 自定义段落样式
p({ children, ...props }) {
return (
<p className="my-2 leading-relaxed" {...props}>
{children}
</p>
)
},
// 自定义分隔线样式
hr({ ...props }) {
return <hr className="my-4 border-border" {...props} />
},
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,311 @@
import { useMemo, useState } from 'react'
import { ListFieldEditor } from '@/components/ListFieldEditor'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ConfigFieldSchema, PluginConfigSchema } from '@/lib/plugin-api'
interface MemoryConfigEditorProps {
schema: PluginConfigSchema
config: Record<string, unknown>
onChange: (nextConfig: Record<string, unknown>) => void
disabled?: boolean
}
function getNestedRecord(config: Record<string, unknown>, path: string): Record<string, unknown> | undefined {
const parts = path.split('.').filter(Boolean)
let current: unknown = config
for (const part of parts) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined
}
return current as Record<string, unknown>
}
function setNestedField(
config: Record<string, unknown>,
path: string,
fieldName: string,
value: unknown,
): Record<string, unknown> {
const parts = path.split('.').filter(Boolean)
const nextConfig: Record<string, unknown> = { ...config }
let target = nextConfig
let source: Record<string, unknown> | undefined = config
for (const part of parts) {
const sourceValue: unknown = source?.[part]
const nextValue =
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
? { ...(sourceValue as Record<string, unknown>) }
: {}
target[part] = nextValue
target = nextValue
source =
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
? (sourceValue as Record<string, unknown>)
: undefined
}
target[fieldName] = value
return nextConfig
}
function FieldRenderer({
field,
value,
onChange,
disabled,
}: {
field: ConfigFieldSchema
value: unknown
onChange: (value: unknown) => void
disabled?: boolean
}) {
const [jsonDraft, setJsonDraft] = useState(
typeof value === 'string' ? String(value) : JSON.stringify(value ?? field.default ?? {}, null, 2),
)
switch (field.ui_type) {
case 'switch':
return (
<div className="flex items-center justify-between rounded-lg border bg-background px-4 py-3">
<div className="space-y-1">
<Label>{field.label}</Label>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
<Switch
checked={Boolean(value ?? field.default)}
onCheckedChange={onChange}
disabled={disabled || field.disabled}
/>
</div>
)
case 'number':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Input
type="number"
value={String(value ?? field.default ?? '')}
onChange={(event) => onChange(Number(event.target.value))}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={disabled || field.disabled}
placeholder={field.placeholder}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'select':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Select
value={String(value ?? field.default ?? '')}
onValueChange={onChange}
disabled={disabled || field.disabled}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder ?? '请选择'} />
</SelectTrigger>
<SelectContent>
{(field.choices ?? []).map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'textarea':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Textarea
value={String(value ?? field.default ?? '')}
onChange={(event) => onChange(event.target.value)}
rows={field.rows ?? 4}
placeholder={field.placeholder}
disabled={disabled || field.disabled}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'list':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<ListFieldEditor
value={Array.isArray(value) ? value : (Array.isArray(field.default) ? field.default : [])}
onChange={onChange as (value: unknown[]) => void}
itemType={field.item_type}
itemFields={field.item_fields}
minItems={field.min_items}
maxItems={field.max_items}
placeholder={field.placeholder}
disabled={disabled || field.disabled}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
case 'json':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Textarea
value={jsonDraft}
rows={field.rows ?? 6}
disabled={disabled || field.disabled}
onChange={(event) => {
const nextValue = event.target.value
setJsonDraft(nextValue)
try {
onChange(JSON.parse(nextValue))
} catch {
// keep draft until valid JSON
}
}}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
default:
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Input
value={String(value ?? field.default ?? '')}
onChange={(event) => onChange(event.target.value)}
disabled={disabled || field.disabled}
placeholder={field.placeholder}
/>
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
</div>
)
}
}
function SectionCard({
sectionName,
schema,
config,
onChange,
disabled,
}: {
sectionName: string
schema: PluginConfigSchema
config: Record<string, unknown>
onChange: (nextConfig: Record<string, unknown>) => void
disabled?: boolean
}) {
const section = schema.sections[sectionName]
if (!section) {
return null
}
const sectionValues = getNestedRecord(config, sectionName) ?? {}
const orderedFields = Object.values(section.fields).sort((left, right) => left.order - right.order)
return (
<Card>
<CardHeader>
<CardTitle>{section.title}</CardTitle>
{section.description && <CardDescription>{section.description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-4">
{orderedFields.map((field) => (
<FieldRenderer
key={`${sectionName}.${field.name}`}
field={field}
value={sectionValues[field.name]}
disabled={disabled}
onChange={(value) => onChange(setNestedField(config, sectionName, field.name, value))}
/>
))}
</CardContent>
</Card>
)
}
export function MemoryConfigEditor({ schema, config, onChange, disabled }: MemoryConfigEditorProps) {
const tabs = useMemo(
() => [...(schema.layout.tabs ?? [])].sort((left, right) => left.order - right.order),
[schema.layout.tabs],
)
if (tabs.length === 0) {
const orderedSections = Object.keys(schema.sections).sort(
(left, right) => (schema.sections[left]?.order ?? 0) - (schema.sections[right]?.order ?? 0),
)
return (
<div className="space-y-4">
{orderedSections.map((sectionName) => (
<SectionCard
key={sectionName}
sectionName={sectionName}
schema={schema}
config={config}
onChange={onChange}
disabled={disabled}
/>
))}
</div>
)
}
return (
<Tabs defaultValue={tabs[0]?.id} className="space-y-4">
<TabsList className="h-auto flex-wrap justify-start">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
{tab.sections.map((sectionName) => (
<SectionCard
key={sectionName}
sectionName={sectionName}
schema={schema}
config={config}
onChange={onChange}
disabled={disabled}
/>
))}
</TabsContent>
))}
</Tabs>
)
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, RotateCcw, Search, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import type {
MemoryDeleteExecutePayload,
MemoryDeletePreviewItemPayload,
MemoryDeletePreviewPayload,
} from '@/lib/memory-api'
const DELETE_PREVIEW_PAGE_SIZE = 8
function formatMode(mode: string): string {
switch (mode) {
case 'entity':
return '实体删除'
case 'relation':
return '关系删除'
case 'paragraph':
return '段落删除'
case 'source':
return '来源删除'
case 'mixed':
return '混合删除'
default:
return mode || '删除'
}
}
function formatCountLabel(label: string, value: number): string {
return `${label} ${value}`
}
function PreviewItemList({ items }: { items: MemoryDeletePreviewItemPayload[] }) {
if (items.length <= 0) {
return <p className="text-sm text-muted-foreground"></p>
}
return (
<div className="space-y-2">
{items.slice(0, 16).map((item) => (
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/30 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.item_type}</Badge>
{item.source ? <Badge variant="secondary">{item.source}</Badge> : null}
</div>
<div className="mt-2 text-sm font-medium break-words">{item.label || item.item_key || item.item_hash}</div>
{item.preview ? <div className="mt-1 text-xs text-muted-foreground break-words">{item.preview}</div> : null}
<code className="mt-2 block break-all text-[11px] text-muted-foreground">{item.item_hash || item.item_key}</code>
</div>
))}
</div>
)
}
interface MemoryDeleteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
preview: MemoryDeletePreviewPayload | null
result: MemoryDeleteExecutePayload | null
loadingPreview?: boolean
executing?: boolean
restoring?: boolean
error?: string | null
onExecute: () => void
onRestore?: () => void
}
export function MemoryDeleteDialog({
open,
onOpenChange,
title,
description,
preview,
result,
loadingPreview = false,
executing = false,
restoring = false,
error,
onExecute,
onRestore,
}: MemoryDeleteDialogProps) {
const [itemSearch, setItemSearch] = useState('')
const [itemPage, setItemPage] = useState(1)
const counts = preview?.counts ?? result?.counts ?? {}
const previewSources = Array.isArray(preview?.sources) ? preview.sources : []
const previewItems = Array.isArray(preview?.items) ? preview.items : []
const filteredPreviewItems = useMemo(() => {
const keyword = itemSearch.trim().toLowerCase()
if (!keyword) {
return previewItems
}
return previewItems.filter((item) =>
[
item.item_type,
item.item_hash,
item.item_key,
item.label,
item.preview,
item.source,
]
.map((value) => String(value ?? '').toLowerCase())
.some((value) => value.includes(keyword)),
)
}, [itemSearch, previewItems])
const itemPageCount = Math.max(1, Math.ceil(filteredPreviewItems.length / DELETE_PREVIEW_PAGE_SIZE))
const pagedPreviewItems = useMemo(() => {
const start = (itemPage - 1) * DELETE_PREVIEW_PAGE_SIZE
return filteredPreviewItems.slice(start, start + DELETE_PREVIEW_PAGE_SIZE)
}, [filteredPreviewItems, itemPage])
const countBadges = [
{ key: 'entities', label: '实体', value: Number(counts.entities ?? 0) },
{ key: 'relations', label: '关系', value: Number(counts.relations ?? 0) },
{ key: 'paragraphs', label: '段落', value: Number(counts.paragraphs ?? 0) },
{ key: 'sources', label: '来源', value: Number(counts.sources ?? 0) },
].filter((item) => item.value > 0)
useEffect(() => {
setItemSearch('')
setItemPage(1)
}, [preview?.mode, preview?.item_count, open])
useEffect(() => {
setItemPage(1)
}, [itemSearch])
useEffect(() => {
if (itemPage > itemPageCount) {
setItemPage(itemPageCount)
}
}, [itemPage, itemPageCount])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] grid grid-rows-[auto_1fr_auto]" confirmOnEnter>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
{title}
</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogBody className="space-y-4 overflow-y-auto">
{loadingPreview ? (
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">...</div>
) : null}
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{preview ? (
<>
<div className="rounded-xl border bg-muted/30 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge>{formatMode(preview.mode)}</Badge>
<Badge variant="secondary">{formatCountLabel('预览项', Number(preview.item_count ?? previewItems.length))}</Badge>
{countBadges.map((item) => (
<Badge key={item.key} variant="outline">
{formatCountLabel(item.label, item.value)}
</Badge>
))}
</div>
{previewSources.length > 0 ? (
<div className="mt-3 text-sm text-muted-foreground break-words">
{previewSources.join('、')}
</div>
) : null}
{preview.matched_source_count ? (
<div className="mt-2 text-xs text-muted-foreground">
{preview.matched_source_count}
{preview.requested_source_count ? ` / 请求来源 ${preview.requested_source_count}` : ''}
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold"></div>
<div className="text-xs text-muted-foreground">
{filteredPreviewItems.length} / {previewItems.length}
</div>
</div>
<div className="flex flex-col gap-2 md:min-w-[300px]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={itemSearch}
onChange={(event) => setItemSearch(event.target.value)}
placeholder="搜索类型 / hash / item_key / source"
className="pl-8"
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span> {itemPage} / {itemPageCount} </span>
<span> {DELETE_PREVIEW_PAGE_SIZE} </span>
</div>
</div>
</div>
<ScrollArea className="h-[320px] rounded-lg border bg-background/60">
<div className="p-3">
<PreviewItemList items={pagedPreviewItems} />
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setItemPage((current) => Math.max(1, current - 1))}
disabled={itemPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
hashitem_keysource
</div>
<Button
variant="outline"
size="sm"
onClick={() => setItemPage((current) => Math.min(itemPageCount, current + 1))}
disabled={itemPage >= itemPageCount}
>
</Button>
</div>
</div>
</>
) : null}
{result?.success ? (
<Alert>
<AlertDescription className="space-y-1">
<div> ID<code>{result.operation_id}</code></div>
<div>
{result.deleted_entity_count} {result.deleted_relation_count} {result.deleted_paragraph_count} {result.deleted_source_count}
</div>
</AlertDescription>
</Alert>
) : null}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
{result?.success && onRestore ? (
<Button variant="outline" onClick={onRestore} disabled={restoring}>
<RotateCcw className="mr-2 h-4 w-4" />
{restoring ? '恢复中...' : '恢复本次删除'}
</Button>
) : null}
{!result?.success ? (
<Button data-dialog-action="confirm" variant="destructive" onClick={onExecute} disabled={loadingPreview || executing || !preview}>
<Trash2 className="mr-2 h-4 w-4" />
{executing ? '执行中...' : '确认删除'}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,518 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Loader2, Play, RefreshCw, RotateCcw, Search } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import {
getMemoryEpisode,
getMemoryEpisodes,
getMemoryEpisodeStatus,
processMemoryEpisodePending,
rebuildMemoryEpisodes,
type MemoryEpisodeDetailPayload,
type MemoryEpisodeItemPayload,
type MemoryEpisodeParagraphPayload,
type MemoryEpisodeStatusPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
function formatMemoryTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function parseOptionalNumber(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}
function getEpisodeId(item: MemoryEpisodeItemPayload | null | undefined): string {
return String(item?.episode_id ?? item?.id ?? '')
}
function getEpisodeTitle(item: MemoryEpisodeItemPayload): string {
return String(item.title ?? item.summary ?? item.content ?? getEpisodeId(item) ?? '未命名 Episode')
}
function getEpisodeParagraphs(
item: MemoryEpisodeItemPayload | MemoryEpisodeDetailPayload['episode'] | null | undefined,
): MemoryEpisodeParagraphPayload[] {
const paragraphs = item?.paragraphs
return Array.isArray(paragraphs) ? paragraphs : []
}
function getStatusCount(status: MemoryEpisodeStatusPayload | null, key: string): number {
const counts = status?.counts
if (counts && typeof counts[key] === 'number') {
return counts[key]
}
const value = status?.[key]
return typeof value === 'number' ? value : 0
}
export function MemoryEpisodeManager() {
const { toast } = useToast()
const [query, setQuery] = useState('')
const [source, setSource] = useState('')
const [platform, setPlatform] = useState('')
const [userId, setUserId] = useState('')
const [personId, setPersonId] = useState('')
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawEpisodePayload, setShowRawEpisodePayload] = useState(false)
const [timeStart, setTimeStart] = useState('')
const [timeEnd, setTimeEnd] = useState('')
const [limit, setLimit] = useState('20')
const [items, setItems] = useState<MemoryEpisodeItemPayload[]>([])
const [status, setStatus] = useState<MemoryEpisodeStatusPayload | null>(null)
const [selectedId, setSelectedId] = useState('')
const [detail, setDetail] = useState<MemoryEpisodeDetailPayload | null>(null)
const [loading, setLoading] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [rebuildSource, setRebuildSource] = useState('')
const [rebuildSources, setRebuildSources] = useState('')
const [rebuildAll, setRebuildAll] = useState(false)
const [pendingLimit, setPendingLimit] = useState('20')
const [pendingMaxRetry, setPendingMaxRetry] = useState('3')
const initialLoadedRef = useRef(false)
const selectedEpisode = useMemo(() => detail?.episode ?? items.find((item) => getEpisodeId(item) === selectedId), [detail?.episode, items, selectedId])
const selectedEpisodeParagraphs = useMemo(() => getEpisodeParagraphs(selectedEpisode), [selectedEpisode])
const failedItems = Array.isArray(status?.failed) ? status.failed : []
const loadStatus = useCallback(async () => {
const payload = await getMemoryEpisodeStatus(parsePositiveInt(limit, 20))
setStatus(payload)
}, [limit])
const loadEpisodes = useCallback(async () => {
setLoading(true)
try {
const directPersonId = showAdvancedPersonId ? personId.trim() : ''
const [listPayload] = await Promise.all([
getMemoryEpisodes({
query: query.trim(),
source: source.trim(),
platform: platform.trim(),
userId: userId.trim(),
personId: directPersonId,
limit: parsePositiveInt(limit, 20),
timeStart: parseOptionalNumber(timeStart),
timeEnd: parseOptionalNumber(timeEnd),
}),
loadStatus(),
])
const nextItems = listPayload.items ?? []
setItems(nextItems)
if (!selectedId && nextItems.length > 0) {
setSelectedId(getEpisodeId(nextItems[0]))
}
} catch (error) {
toast({
title: '加载情节记忆失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [limit, loadStatus, personId, platform, query, selectedId, showAdvancedPersonId, source, timeEnd, timeStart, toast, userId])
const loadDetail = useCallback(async (episodeId: string) => {
if (!episodeId) {
setDetail(null)
return
}
setDetailLoading(true)
try {
const payload = await getMemoryEpisode(episodeId)
setDetail(payload)
} catch (error) {
toast({
title: '加载 Episode 详情失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setDetailLoading(false)
}
}, [toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadEpisodes()
}, [loadEpisodes])
useEffect(() => {
if (selectedId) {
void loadDetail(selectedId)
}
}, [loadDetail, selectedId])
const submitRebuild = useCallback(async () => {
if (rebuildAll && !window.confirm('确认重建全部可用来源的 Episode这个操作可能耗时较长。')) {
return
}
const sources = rebuildSources
.split(',')
.map((item) => item.trim())
.filter(Boolean)
setActionLoading(true)
try {
const payload = await rebuildMemoryEpisodes({
source: rebuildSource.trim(),
sources,
all: rebuildAll,
})
toast({
title: payload.success ? 'Episode 重建已提交' : 'Episode 重建失败',
description: String(payload.detail ?? payload.error ?? `影响来源 ${payload.rebuilt ?? 0}`),
variant: payload.success ? 'default' : 'destructive',
})
await loadEpisodes()
} catch (error) {
toast({
title: 'Episode 重建失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadEpisodes, rebuildAll, rebuildSource, rebuildSources, toast])
const submitProcessPending = useCallback(async () => {
setActionLoading(true)
try {
const payload = await processMemoryEpisodePending({
limit: parsePositiveInt(pendingLimit, 20),
max_retry: parsePositiveInt(pendingMaxRetry, 3),
})
toast({
title: payload.success ? '已处理待生成 Episode' : '处理待生成 Episode 失败',
description: String(payload.detail ?? payload.error ?? `已处理 ${payload.processed ?? 0}`),
variant: payload.success ? 'default' : 'destructive',
})
await loadEpisodes()
} catch (error) {
toast({
title: '处理待生成 Episode 失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadEpisodes, pendingLimit, pendingMaxRetry, toast])
return (
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-4">
{[
{ label: '待处理队列', value: Number(status?.pending_queue ?? 0) },
{ label: '待重建', value: getStatusCount(status, 'pending') },
{ label: '运行中', value: getStatusCount(status, 'running') },
{ label: '失败来源', value: failedItems.length || getStatusCount(status, 'failed') },
].map((item) => (
<Card key={item.label}>
<CardHeader className="pb-3">
<CardDescription>{item.label}</CardDescription>
<CardTitle className="text-2xl">{item.value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription>person_id </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-platform"></Label>
<Input
id="episode-platform"
value={platform}
onChange={(event) => setPlatform(event.target.value)}
placeholder="例如 qq、telegram、webui"
/>
</div>
<div className="space-y-2">
<Label htmlFor="episode-user-id"></Label>
<Input id="episode-user-id" value={userId} onChange={(event) => setUserId(event.target.value)} placeholder="输入平台侧 user_id" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-query"></Label>
<Input id="episode-query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索摘要或内容" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-source"></Label>
<Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." />
</div>
<div className="space-y-2">
<Label htmlFor="episode-limit"></Label>
<Input id="episode-limit" type="number" value={limit} onChange={(event) => setLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-time-start"></Label>
<Input id="episode-time-start" value={timeStart} onChange={(event) => setTimeStart(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-time-end"></Label>
<Input id="episode-time-end" value={timeEnd} onChange={(event) => setTimeEnd(event.target.value)} placeholder="可选" />
</div>
</div>
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span></span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
<Label htmlFor="episode-person">person_id</Label>
<Input
id="episode-person"
value={personId}
onChange={(event) => setPersonId(event.target.value)}
placeholder="调试或后台管理时直接输入"
/>
</CollapsibleContent>
</Collapsible>
<Button onClick={() => void loadEpisodes()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
Episode
</Button>
<ScrollArea className="h-[420px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead>Episode</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? items.map((item) => {
const episodeId = getEpisodeId(item)
return (
<TableRow
key={episodeId || getEpisodeTitle(item)}
className={cn('cursor-pointer', selectedId === episodeId && 'bg-muted/60')}
onClick={() => setSelectedId(episodeId)}
>
<TableCell>
<div className="max-w-[280px] truncate font-medium">{getEpisodeTitle(item)}</div>
{item.person_name || item.person_id ? (
<div className="max-w-[280px] truncate text-xs text-muted-foreground">
{String(item.person_name || item.person_id)}
{item.person_name && item.person_id ? <span className="font-mono"> · {String(item.person_id)}</span> : null}
</div>
) : null}
<div className="font-mono text-[11px] text-muted-foreground break-all">{episodeId || '-'}</div>
</TableCell>
<TableCell className="max-w-[180px] truncate">{String(item.source ?? '-')}</TableCell>
<TableCell>{formatMemoryTime(item.updated_at ?? item.created_at)}</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载 Episode...' : '没有匹配的 Episode'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Episode </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{detailLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : selectedEpisode ? (
<>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge>
{selectedEpisode.source ? <Badge variant="secondary">{String(selectedEpisode.source)}</Badge> : null}
{selectedEpisode.person_name ? <Badge>{String(selectedEpisode.person_name)}</Badge> : null}
{selectedEpisode.person_id ? <Badge variant="outline">{String(selectedEpisode.person_id)}</Badge> : null}
</div>
<Textarea value={String(selectedEpisode.summary ?? selectedEpisode.content ?? '')} readOnly className="min-h-[120px]" />
<Collapsible open={showRawEpisodePayload} onOpenChange={setShowRawEpisodePayload} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span> JSON</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawEpisodePayload && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<pre className="max-h-56 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedEpisode, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
{selectedEpisodeParagraphs.length > 0 ? (
<ScrollArea className="h-[220px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{selectedEpisodeParagraphs.map((paragraph, index) => (
<div key={String(paragraph.hash ?? index)} className="rounded-lg border bg-muted/20 p-3">
<div className="font-mono text-[11px] text-muted-foreground break-all">{String(paragraph.hash ?? '-')}</div>
<div className="mt-2 text-sm break-words">{String(paragraph.preview ?? paragraph.content ?? '')}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
</>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground"> Episode </div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription> Episode </CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{failedItems.length > 0 ? (
<Alert>
<AlertDescription>
{failedItems.slice(0, 3).map((item) => String(item.source ?? item.id ?? item.error ?? '未知')).join('、')}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<div>
<div className="text-sm font-medium"> Episode</div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-rebuild-source"> ID</Label>
<Input
id="episode-rebuild-source"
value={rebuildSource}
onChange={(event) => setRebuildSource(event.target.value)}
placeholder="例如 chat_summary:test-webui:coffee"
/>
</div>
<div className="space-y-2">
<Label htmlFor="episode-rebuild-sources"> ID</Label>
<Input
id="episode-rebuild-sources"
value={rebuildSources}
onChange={(event) => setRebuildSources(event.target.value)}
placeholder="用英文逗号分隔多个来源"
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
</label>
<Button onClick={() => void submitRebuild()} disabled={actionLoading}>
<RotateCcw className="mr-2 h-4 w-4" />
Episode
</Button>
</div>
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<div>
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="episode-pending-limit"></Label>
<Input id="episode-pending-limit" type="number" value={pendingLimit} onChange={(event) => setPendingLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-pending-retry"></Label>
<Input id="episode-pending-retry" type="number" value={pendingMaxRetry} onChange={(event) => setPendingMaxRetry(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void submitProcessPending()} disabled={actionLoading}>
<Play className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Lock, RefreshCw, RotateCcw, Shield, Snowflake } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { useToast } from '@/hooks/use-toast'
import {
freezeMemory,
getMemoryRecycleBin,
protectMemory,
reinforceMemory,
restoreMaintainedMemory,
type MemoryMaintenanceActionPayload,
type MemoryMaintenanceItemPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
type MaintenanceAction = 'reinforce' | 'freeze' | 'protect' | 'restore'
function formatMemoryTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}
function parseOptionalHours(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
}
function getRelationTarget(item: MemoryMaintenanceItemPayload): string {
return String(item.hash ?? item.relation_hash ?? '')
}
function getRelationText(item: MemoryMaintenanceItemPayload): string {
const direct = String(item.text ?? '').trim()
if (direct) {
return direct
}
return [item.subject, item.predicate, item.object].map((value) => String(value ?? '').trim()).filter(Boolean).join(' ')
}
function getActionLabel(action: MaintenanceAction): string {
switch (action) {
case 'reinforce':
return '强化'
case 'freeze':
return '冻结'
case 'protect':
return '保护'
case 'restore':
return '恢复'
default:
return action
}
}
export function MemoryMaintenanceManager() {
const { toast } = useToast()
const [target, setTarget] = useState('')
const [action, setAction] = useState<MaintenanceAction>('reinforce')
const [protectHours, setProtectHours] = useState('')
const [recycleLimit, setRecycleLimit] = useState('50')
const [items, setItems] = useState<MemoryMaintenanceItemPayload[]>([])
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [itemSearch, setItemSearch] = useState('')
const initialLoadedRef = useRef(false)
const filteredItems = useMemo(() => {
const keyword = itemSearch.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[
getRelationTarget(item),
getRelationText(item),
item.source,
item.subject,
item.predicate,
item.object,
].some((value) => String(value ?? '').toLowerCase().includes(keyword)),
)
}, [itemSearch, items])
const loadRecycleBin = useCallback(async () => {
setLoading(true)
try {
const payload = await getMemoryRecycleBin(parsePositiveInt(recycleLimit, 50))
setItems(payload.items ?? [])
} catch (error) {
toast({
title: '加载记忆回收站失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [recycleLimit, toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadRecycleBin()
}, [loadRecycleBin])
const runAction = useCallback(async (nextAction: MaintenanceAction, nextTarget: string) => {
const cleanTarget = nextTarget.trim()
if (!cleanTarget) {
toast({
title: '缺少维护目标',
description: '请输入关系 hash 或查询文本。',
variant: 'destructive',
})
return
}
if (nextAction === 'freeze' && !window.confirm('确认冻结命中的记忆关系?冻结后关系会从活跃图谱中移除。')) {
return
}
if (nextAction === 'restore' && !window.confirm('确认恢复命中的记忆关系?')) {
return
}
setActionLoading(true)
try {
let payload: MemoryMaintenanceActionPayload
if (nextAction === 'reinforce') {
payload = await reinforceMemory(cleanTarget)
} else if (nextAction === 'freeze') {
payload = await freezeMemory(cleanTarget)
} else if (nextAction === 'protect') {
payload = await protectMemory(cleanTarget, parseOptionalHours(protectHours))
} else {
payload = await restoreMaintainedMemory(cleanTarget)
}
toast({
title: payload.success ? `记忆${getActionLabel(nextAction)}完成` : `记忆${getActionLabel(nextAction)}失败`,
description: String(payload.detail ?? payload.error ?? ''),
variant: payload.success ? 'default' : 'destructive',
})
await loadRecycleBin()
} catch (error) {
toast({
title: `记忆${getActionLabel(nextAction)}失败`,
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadRecycleBin, protectHours, toast])
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
</CardTitle>
<CardDescription> hash </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
沿 hash
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="maintenance-target"></Label>
<Input id="maintenance-target" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="relation hash 或查询文本" />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select value={action} onValueChange={(value) => setAction(value as MaintenanceAction)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reinforce"></SelectItem>
<SelectItem value="freeze"></SelectItem>
<SelectItem value="protect"></SelectItem>
<SelectItem value="restore"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-hours"></Label>
<Input
id="maintenance-hours"
type="number"
value={protectHours}
onChange={(event) => setProtectHours(event.target.value)}
placeholder="空值表示永久保护"
disabled={action !== 'protect'}
/>
</div>
</div>
<Button onClick={() => void runAction(action, target)} disabled={actionLoading}>
{action === 'reinforce' ? <Lock className="mr-2 h-4 w-4" /> : null}
{action === 'freeze' ? <Snowflake className="mr-2 h-4 w-4" /> : null}
{action === 'protect' ? <Shield className="mr-2 h-4 w-4" /> : null}
{action === 'restore' ? <RotateCcw className="mr-2 h-4 w-4" /> : null}
{getActionLabel(action)}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_140px_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="maintenance-search"></Label>
<Input id="maintenance-search" value={itemSearch} onChange={(event) => setItemSearch(event.target.value)} placeholder="按 hash、主体、谓词、来源筛选" />
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-limit"></Label>
<Input id="maintenance-limit" type="number" value={recycleLimit} onChange={(event) => setRecycleLimit(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void loadRecycleBin()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline"> {items.length} </Badge>
<Badge variant="secondary"> {filteredItems.length} </Badge>
</div>
<ScrollArea className="h-[520px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? filteredItems.map((item, index) => {
const rowTarget = getRelationTarget(item)
return (
<TableRow key={`${rowTarget}:${index}`}>
<TableCell>
<div className="font-medium break-words">{getRelationText(item) || '-'}</div>
<div className="mt-1 font-mono text-[11px] text-muted-foreground break-all">{rowTarget || '-'}</div>
{item.source ? <Badge variant="outline" className="mt-2">{String(item.source)}</Badge> : null}
</TableCell>
<TableCell>{formatMemoryTime(item.deleted_at ?? item.updated_at)}</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => void runAction('restore', rowTarget)}
disabled={!rowTarget || actionLoading}
>
</Button>
</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载回收站...' : '回收站没有可展示的关系'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
export interface MemoryMiniTabItem<TValue extends string> {
value: TValue
label: string
description?: string
}
export interface MemoryMiniTabsProps<TValue extends string> {
items: ReadonlyArray<MemoryMiniTabItem<TValue>>
className?: string
/** 触发器额外样式 */
triggerClassName?: string
}
/**
* 长期记忆控制台统一的迷你标签页样式。
*
* - 复用 shadcn `Tabs` 原语,仅替换样式以保留无障碍能力(`role="tab"` 与文案不变)。
* - 胶囊形外观,激活态使用主色渐变,便于在密集表单上快速定位当前页签。
*/
export function MemoryMiniTabs<TValue extends string>({
items,
className,
triggerClassName,
}: MemoryMiniTabsProps<TValue>) {
return (
<TabsList
className={cn(
'h-auto w-full flex-wrap justify-start gap-1.5 rounded-full border border-border/60',
'bg-gradient-to-r from-muted/40 via-background to-muted/30 p-1.5 shadow-inner',
className,
)}
>
{items.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
title={item.description}
className={cn(
'rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors',
'hover:bg-background/80 hover:text-foreground',
'data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary data-[state=active]:to-primary/80',
'data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm',
triggerClassName,
)}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
)
}

View File

@@ -0,0 +1,482 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Loader2, RefreshCw, Save, Search, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import {
deleteMemoryProfileOverride,
getMemoryProfiles,
queryMemoryProfile,
searchMemoryProfiles,
setMemoryProfileOverride,
type MemoryProfileItemPayload,
type MemoryProfileQueryPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
function formatMemoryTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return fallback
}
return parsed
}
function stringifyOverride(value: MemoryProfileItemPayload['manual_override']): string {
if (!value) {
return ''
}
if (typeof value === 'string') {
return value
}
const text = value.override_text ?? value.text
if (typeof text === 'string') {
return text
}
return JSON.stringify(value, null, 2)
}
function resolveProfileText(queryResult: MemoryProfileQueryPayload | null, selectedProfile: MemoryProfileItemPayload | null): string {
if (typeof queryResult?.profile_text === 'string') {
return queryResult.profile_text
}
const queryProfile = queryResult?.profile
if (queryProfile && typeof queryProfile === 'object' && typeof queryProfile.profile_text === 'string') {
return queryProfile.profile_text
}
return selectedProfile?.profile_text ?? ''
}
export function MemoryProfileManager() {
const { toast } = useToast()
const [profiles, setProfiles] = useState<MemoryProfileItemPayload[]>([])
const [profileListMode, setProfileListMode] = useState<'library' | 'search'>('library')
const [selectedPersonId, setSelectedPersonId] = useState('')
const [queryPersonId, setQueryPersonId] = useState('')
const [queryKeyword, setQueryKeyword] = useState('')
const [queryPlatform, setQueryPlatform] = useState('')
const [queryUserId, setQueryUserId] = useState('')
const [queryLimit, setQueryLimit] = useState('12')
const [forceRefresh, setForceRefresh] = useState(false)
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawProfilePayload, setShowRawProfilePayload] = useState(false)
const [overrideText, setOverrideText] = useState('')
const [queryResult, setQueryResult] = useState<MemoryProfileQueryPayload | null>(null)
const [loading, setLoading] = useState(false)
const [querying, setQuerying] = useState(false)
const [saving, setSaving] = useState(false)
const initialLoadedRef = useRef(false)
const selectedProfile = useMemo(
() => profiles.find((item) => item.person_id === selectedPersonId) ?? null,
[profiles, selectedPersonId],
)
const profileText = resolveProfileText(queryResult, selectedProfile)
const selectedDisplayName = selectedProfile?.person_name || selectedPersonId || String(queryResult?.person_id ?? '未选择')
const loadProfiles = useCallback(async () => {
setLoading(true)
try {
const payload = await getMemoryProfiles(80)
const nextItems = payload.items ?? []
setProfiles(nextItems)
setProfileListMode('library')
if (!selectedPersonId && nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
}
} catch (error) {
toast({
title: '加载人物画像失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [selectedPersonId, toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadProfiles()
}, [loadProfiles])
useEffect(() => {
setOverrideText(stringifyOverride(selectedProfile?.manual_override))
}, [selectedProfile])
const submitQuery = useCallback(async () => {
const directPersonId = showAdvancedPersonId ? queryPersonId.trim() : ''
const cleanKeyword = queryKeyword.trim()
const cleanPlatform = queryPlatform.trim()
const cleanUserId = queryUserId.trim()
const hasAccountLocator = Boolean(cleanPlatform && cleanUserId)
if (!directPersonId && !cleanKeyword && !hasAccountLocator) {
toast({
title: '请输入查询条件',
description: '用户账号、关键词、或高级 person_id 至少填写一种。',
variant: 'destructive',
})
return
}
setQuerying(true)
try {
if (!directPersonId && !hasAccountLocator) {
const searchPayload = await searchMemoryProfiles({
personKeyword: cleanKeyword,
limit: 80,
})
const nextItems = searchPayload.items ?? []
setProfiles(nextItems)
setProfileListMode('search')
setQueryResult(null)
setSelectedPersonId(nextItems[0]?.person_id ?? '')
toast({
title: '人物画像检索完成',
description: `命中 ${nextItems.length} 个画像。`,
})
return
}
const payload = await queryMemoryProfile({
personId: directPersonId,
personKeyword: cleanKeyword,
platform: cleanPlatform,
userId: cleanUserId,
limit: parsePositiveInt(queryLimit, 12),
forceRefresh,
})
if (payload.success === false) {
throw new Error(String(payload.error ?? '人物画像查询失败'))
}
setQueryResult(payload)
const nextPersonId = String(payload.person_id ?? payload.profile?.person_id ?? directPersonId ?? '')
const searchPayload = await searchMemoryProfiles({
personId: nextPersonId || directPersonId,
personKeyword: cleanKeyword,
platform: cleanPlatform,
userId: cleanUserId,
limit: 80,
})
const nextItems = searchPayload.items ?? []
setProfiles(nextItems)
setProfileListMode('search')
if (nextPersonId) {
setSelectedPersonId(nextPersonId)
setQueryPersonId(nextPersonId)
} else if (nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
}
toast({
title: '人物画像查询完成',
description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。',
})
} catch (error) {
toast({
title: '人物画像查询失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setQuerying(false)
}
}, [forceRefresh, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, showAdvancedPersonId, toast])
const saveOverride = useCallback(async () => {
const personId = selectedPersonId || queryPersonId.trim()
if (!personId) {
toast({
title: '缺少人物 ID',
description: '请选择或输入一个 person_id 后再保存 override。',
variant: 'destructive',
})
return
}
setSaving(true)
try {
await setMemoryProfileOverride({
person_id: personId,
override_text: overrideText,
updated_by: 'knowledge_base',
source: 'webui',
})
toast({ title: '人物画像 override 已保存' })
await loadProfiles()
} catch (error) {
toast({
title: '保存人物画像 override 失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [loadProfiles, overrideText, queryPersonId, selectedPersonId, toast])
const deleteOverride = useCallback(async () => {
const personId = selectedPersonId || queryPersonId.trim()
if (!personId) {
return
}
if (!window.confirm(`确认删除 ${personId} 的人物画像 override`)) {
return
}
setSaving(true)
try {
await deleteMemoryProfileOverride(personId)
setOverrideText('')
toast({ title: '人物画像 override 已删除' })
await loadProfiles()
} catch (error) {
toast({
title: '删除人物画像 override 失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setSaving(false)
}
}, [loadProfiles, queryPersonId, selectedPersonId, toast])
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
</CardTitle>
<CardDescription>person_id </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-platform"></Label>
<Input
id="profile-platform"
value={queryPlatform}
onChange={(event) => setQueryPlatform(event.target.value)}
placeholder="例如 qq、telegram、webui"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-user-id"></Label>
<Input
id="profile-user-id"
value={queryUserId}
onChange={(event) => setQueryUserId(event.target.value)}
placeholder="输入平台侧 user_id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-keyword"></Label>
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label htmlFor="profile-limit"></Label>
<Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} />
</div>
<div className="flex items-center gap-2 self-end pb-2">
<Checkbox
id="profile-force-refresh"
checked={forceRefresh}
onCheckedChange={(value) => setForceRefresh(Boolean(value))}
/>
<Label htmlFor="profile-force-refresh" className="text-sm font-normal">
</Label>
</div>
</div>
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span></span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
<Label htmlFor="profile-person-id">person_id</Label>
<Input
id="profile-person-id"
value={queryPersonId}
onChange={(event) => setQueryPersonId(event.target.value)}
placeholder="调试或后台管理时直接输入"
/>
</CollapsibleContent>
</Collapsible>
{selectedPersonId || queryPersonId ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="text-muted-foreground"> person_id</div>
<div className="mt-1 break-all font-mono text-xs">{selectedPersonId || queryPersonId}</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button onClick={() => void submitQuery()} disabled={querying}>
<Search className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => void loadProfiles()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<div className="rounded-lg border bg-muted/10 px-3 py-2">
<div className="text-sm font-medium">{profileListMode === 'search' ? '检索结果' : '画像库'}</div>
<div className="mt-1 text-xs text-muted-foreground">
{profileListMode === 'search'
? '根据当前平台账号、关键词或 person_id 筛选出的画像候选。'
: '系统中已生成的最新人物画像快照,按更新时间排序。'}
</div>
</div>
<ScrollArea className="h-[520px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profiles.length > 0 ? profiles.map((item) => (
<TableRow
key={item.person_id}
className={cn('cursor-pointer', selectedPersonId === item.person_id && 'bg-muted/60')}
onClick={() => setSelectedPersonId(item.person_id)}
>
<TableCell>
<div className="font-medium break-all">{item.person_name || item.person_id}</div>
{item.person_name ? <div className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{item.person_id}</div> : null}
<div className="mt-1 flex flex-wrap gap-1">
{item.has_manual_override ? <Badge variant="secondary"> override</Badge> : null}
{item.source_note ? <Badge variant="outline">{item.source_note}</Badge> : null}
</div>
</TableCell>
<TableCell>{Number(item.profile_version ?? 0)}</TableCell>
<TableCell>{formatMemoryTime(item.updated_at)}</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{querying ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : null}
{selectedProfile || queryResult ? (
<>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedPersonId || String(queryResult?.person_id ?? '未选择')}</Badge>
{selectedProfile?.expires_at ? <Badge variant="secondary"> {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null}
</div>
<Textarea value={profileText} readOnly className="min-h-[180px]" placeholder="当前没有画像文本" />
<Collapsible open={showRawProfilePayload} onOpenChange={setShowRawProfilePayload} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span> JSON</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawProfilePayload && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<pre className="max-h-72 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> Override</CardTitle>
<CardDescription> override </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!selectedPersonId && !queryPersonId.trim() ? (
<Alert>
<AlertDescription> person_id override</AlertDescription>
</Alert>
) : null}
{selectedDisplayName ? <div className="text-sm text-muted-foreground">{selectedDisplayName}</div> : null}
<Textarea
value={overrideText}
onChange={(event) => setOverrideText(event.target.value)}
className="min-h-[180px]"
placeholder="输入希望固定使用的人物画像文本"
/>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveOverride()} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
override
</Button>
<Button variant="outline" onClick={() => void deleteOverride()} disabled={saving || (!selectedPersonId && !queryPersonId.trim())}>
<Trash2 className="mr-2 h-4 w-4" />
override
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
export interface MemoryProgressIndicatorProps {
/** 0-100 之间的进度百分比 */
value: number
/** 任务状态文本(如 “运行中”、“已完成”) */
statusLabel?: string
/** 当前步骤文本(如 “分块中”) */
stepLabel?: string
/** 状态对应的语义色(用于左侧圆环和徽标) */
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'muted'
/** 是否显示加载动画(运行中/取消中场景) */
busy?: boolean
/** 紧凑模式:用于队列列表项 */
compact?: boolean
/** 额外说明(如 “已完成 36 / 120 分块”) */
detail?: string
className?: string
}
const TONE_RING_CLASS: Record<NonNullable<MemoryProgressIndicatorProps['tone']>, string> = {
default: 'text-primary',
success: 'text-emerald-500',
warning: 'text-amber-500',
destructive: 'text-rose-500',
muted: 'text-muted-foreground',
}
const TONE_BADGE_VARIANT: Record<
NonNullable<MemoryProgressIndicatorProps['tone']>,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
default: 'default',
success: 'secondary',
warning: 'outline',
destructive: 'destructive',
muted: 'outline',
}
/**
* 长期记忆控制台统一的任务进度展示组件。
*
* 设计目标:
* - 让用户一眼看清「整体百分比 + 语义状态 + 当前步骤」。
* - 复用 shadcn `Progress` 与 `Badge`,避免引入额外样式来源。
* - 在紧凑模式下保留可读性,可放进队列卡片;非紧凑模式带圆环用于详情区。
*/
export function MemoryProgressIndicator({
value,
statusLabel,
stepLabel,
tone = 'default',
busy = false,
compact = false,
detail,
className,
}: MemoryProgressIndicatorProps) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
const ringSize = compact ? 36 : 56
const ringStroke = compact ? 4 : 5
const radius = (ringSize - ringStroke) / 2
const circumference = 2 * Math.PI * radius
const dashOffset = circumference * (1 - safeValue / 100)
return (
<div className={cn('flex items-center gap-3', className)}>
<div
className={cn('relative shrink-0', TONE_RING_CLASS[tone])}
style={{ width: ringSize, height: ringSize }}
aria-hidden="true"
>
<svg width={ringSize} height={ringSize} className="-rotate-90">
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
className="stroke-muted/40"
fill="none"
/>
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
strokeLinecap="round"
stroke="currentColor"
fill="none"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
className="transition-[stroke-dashoffset] duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
{busy ? (
<Loader2 className={cn('animate-spin', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
) : (
<span className={cn('font-medium tabular-nums', compact ? 'text-[10px]' : 'text-xs')}>
{Math.round(safeValue)}%
</span>
)}
</div>
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{statusLabel ? (
<Badge variant={TONE_BADGE_VARIANT[tone]} className="shrink-0">
{statusLabel}
</Badge>
) : null}
{stepLabel ? (
<span className="truncate text-xs text-muted-foreground">{stepLabel}</span>
) : null}
{!compact ? (
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{safeValue.toFixed(1)}%
</span>
) : null}
</div>
<Progress value={safeValue} className={cn(compact ? 'h-1' : 'h-1.5')} />
{detail ? <div className="truncate text-xs text-muted-foreground">{detail}</div> : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,303 @@
/**
* 插件统计组件
* 显示点赞、点踩、评分和下载量
*/
import { useState, useEffect } from 'react'
import { ThumbsUp, ThumbsDown, Star, Download } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
import {
getPluginStats,
likePlugin,
dislikePlugin,
ratePlugin,
type PluginStatsData,
} from '@/lib/plugin-stats'
interface PluginStatsProps {
pluginId: string
compact?: boolean // 紧凑模式(只显示数字)
}
export function PluginStats({ pluginId, compact = false }: PluginStatsProps) {
const [stats, setStats] = useState<PluginStatsData | null>(null)
const [loading, setLoading] = useState(true)
const [userRating, setUserRating] = useState(0)
const [userComment, setUserComment] = useState('')
const [isRatingDialogOpen, setIsRatingDialogOpen] = useState(false)
const { toast } = useToast()
// 加载统计数据
const loadStats = async () => {
setLoading(true)
const data = await getPluginStats(pluginId)
if (data) {
setStats(data)
}
setLoading(false)
}
useEffect(() => {
loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId])
// 处理点赞
const handleLike = async () => {
const result = await likePlugin(pluginId)
if (result.success) {
toast({ title: '已点赞', description: '感谢你的支持!' })
loadStats() // 重新加载统计数据
} else {
toast({
title: '点赞失败',
description: result.error || '未知错误',
variant: 'destructive',
})
}
}
// 处理点踩
const handleDislike = async () => {
const result = await dislikePlugin(pluginId)
if (result.success) {
toast({ title: '已反馈', description: '感谢你的反馈!' })
loadStats()
} else {
toast({
title: '操作失败',
description: result.error || '未知错误',
variant: 'destructive',
})
}
}
// 提交评分
const handleSubmitRating = async () => {
if (userRating === 0) {
toast({
title: '请选择评分',
description: '至少选择 1 颗星',
variant: 'destructive',
})
return
}
const result = await ratePlugin(pluginId, userRating, userComment || undefined)
if (result.success) {
toast({ title: '评分成功', description: '感谢你的评价!' })
setIsRatingDialogOpen(false)
setUserRating(0)
setUserComment('')
loadStats()
} else {
toast({
title: '评分失败',
description: result.error || '未知错误',
variant: 'destructive',
})
}
}
if (loading) {
return (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
<span>-</span>
</div>
<div className="flex items-center gap-1">
<Star className="h-4 w-4" />
<span>-</span>
</div>
</div>
)
}
if (!stats) {
return null
}
// 紧凑模式
if (compact) {
return (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1" title={`下载量: ${stats.downloads.toLocaleString()}`}>
<Download className="h-4 w-4" />
<span>{stats.downloads.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1" title={`评分: ${stats.rating.toFixed(1)} (${stats.rating_count} 条评价)`}>
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span>{stats.rating.toFixed(1)}</span>
</div>
<div className="flex items-center gap-1" title={`点赞数: ${stats.likes}`}>
<ThumbsUp className="h-4 w-4" />
<span>{stats.likes}</span>
</div>
</div>
)
}
// 完整模式
return (
<div className="space-y-4">
{/* 统计数字 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<Download className="h-5 w-5 text-muted-foreground mb-1" />
<span className="text-2xl font-bold">{stats.downloads.toLocaleString()}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<Star className="h-5 w-5 text-yellow-400 mb-1 fill-yellow-400" />
<span className="text-2xl font-bold">{stats.rating.toFixed(1)}</span>
<span className="text-xs text-muted-foreground">{stats.rating_count} </span>
</div>
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<ThumbsUp className="h-5 w-5 text-green-500 mb-1" />
<span className="text-2xl font-bold">{stats.likes}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
<ThumbsDown className="h-5 w-5 text-red-500 mb-1" />
<span className="text-2xl font-bold">{stats.dislikes}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleLike}>
<ThumbsUp className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDislike}>
<ThumbsDown className="h-4 w-4 mr-1" />
</Button>
<Dialog open={isRatingDialogOpen} onOpenChange={setIsRatingDialogOpen}>
<DialogTrigger asChild>
<Button variant="default" size="sm">
<Star className="h-4 w-4 mr-1" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>使</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 星级评分 */}
<div className="flex flex-col items-center gap-2">
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setUserRating(star)}
className="focus:outline-none"
>
<Star
className={`h-8 w-8 transition-colors ${
star <= userRating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:text-yellow-300'
}`}
/>
</button>
))}
</div>
<span className="text-sm text-muted-foreground">
{userRating === 0 && '点击星星进行评分'}
{userRating === 1 && '很差'}
{userRating === 2 && '一般'}
{userRating === 3 && '还行'}
{userRating === 4 && '不错'}
{userRating === 5 && '非常好'}
</span>
</div>
{/* 评论 */}
<div>
<label htmlFor="plugin-rating-comment" className="text-sm font-medium mb-2 block"></label>
<Textarea
value={userComment}
id="plugin-rating-comment"
onChange={(e) => setUserComment(e.target.value)}
placeholder="分享你的使用体验..."
rows={4}
maxLength={500}
/>
<div className="text-xs text-muted-foreground mt-1 text-right">
{userComment.length} / 500
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRatingDialogOpen(false)}>
</Button>
<Button onClick={handleSubmitRating} disabled={userRating === 0}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* 最近评价 */}
{stats.recent_ratings && stats.recent_ratings.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold"></h4>
<div className="space-y-3">
{stats.recent_ratings.map((rating, index) => (
<div key={index} className="p-3 rounded-lg border bg-muted/50">
<div className="flex items-center justify-between mb-2">
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-3 w-3 ${
star <= rating.rating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
{new Date(rating.created_at).toLocaleDateString()}
</span>
</div>
{rating.comment && (
<p className="text-sm text-muted-foreground">{rating.comment}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,416 @@
/**
* 重启遮罩层组件
*
* 用于显示重启进度和状态,阻止用户操作
*
* 使用方式 1: 配合 RestartProvider推荐
* <RestartProvider>
* <App />
* <RestartOverlay />
* </RestartProvider>
*
* 使用方式 2: 独立使用
* <RestartOverlay
* visible={true}
* onComplete={() => navigate('/auth')}
* />
*/
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Loader2,
CheckCircle2,
AlertCircle,
RefreshCw,
RotateCcw,
} from 'lucide-react'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useRestart, type RestartStatus, type RestartContextValue } from '@/lib/restart-context'
import { cn } from '@/lib/utils'
// Hook 用于安全获取 restart context
function useSafeRestart(): RestartContextValue | null {
try {
return useRestart()
} catch {
return null
}
}
// ============ 类型定义 ============
interface RestartOverlayProps {
/** 是否可见(仅独立模式使用) */
visible?: boolean
/** 重启完成回调 */
onComplete?: () => void
/** 重启失败回调 */
onFailed?: () => void
/** 自定义标题 */
title?: string
/** 自定义描述 */
description?: string
/** 是否显示背景动画 */
showAnimation?: boolean
/** 自定义类名 */
className?: string
}
// ============ 状态配置 ============
interface StatusConfig {
icon: React.ReactNode
title: string
description: string
tip: string
}
const getStatusConfig = (
status: RestartStatus,
checkAttempts: number,
maxAttempts: number,
t: (key: string, opts?: Record<string, unknown>) => string,
customTitle?: string,
customDescription?: string
): StatusConfig => {
const configs: Record<RestartStatus, StatusConfig> = {
idle: {
icon: null,
title: '',
description: '',
tip: '',
},
requesting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? t('restart.preparing'),
description: customDescription ?? t('restart.preparingDesc'),
tip: t('restart.preparingTip'),
},
restarting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? t('restart.restarting'),
description: customDescription ?? t('restart.restartingDesc'),
tip: t('restart.restartingTip'),
},
checking: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: t('restart.checking'),
description: t('restart.checkingDesc', { current: checkAttempts, max: maxAttempts }),
tip: t('restart.checkingTip'),
},
success: {
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
title: t('restart.success'),
description: t('restart.successDesc'),
tip: t('restart.successTip'),
},
failed: {
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
title: t('restart.failed'),
description: t('restart.failedDesc'),
tip: t('restart.failedTip'),
},
}
return configs[status]
}
// ============ 主组件(配合 Provider ============
export function RestartOverlay({
visible,
onComplete,
onFailed,
title,
description,
showAnimation = true,
className,
}: RestartOverlayProps) {
// 尝试使用 context可能不存在
const contextValue = useSafeRestart()
// 如果有 context使用 context 状态;否则使用 props
const isVisible = contextValue ? contextValue.isRestarting : visible
if (!isVisible) return null
if (contextValue) {
return (
<RestartOverlayContent
state={contextValue.state}
onRetry={contextValue.retryHealthCheck}
onComplete={onComplete}
onFailed={onFailed}
title={title}
description={description}
showAnimation={showAnimation}
className={className}
/>
)
}
// 独立模式
return (
<StandaloneRestartOverlay
onComplete={onComplete}
onFailed={onFailed}
title={title}
description={description}
showAnimation={showAnimation}
className={className}
/>
)
}
// ============ 内容组件 ============
interface RestartOverlayContentProps {
state: {
status: RestartStatus
progress: number
elapsedTime: number
checkAttempts: number
maxAttempts: number
error?: string
}
onRetry: () => void
onComplete?: () => void
onFailed?: () => void
title?: string
description?: string
showAnimation?: boolean
className?: string
}
function RestartOverlayContent({
state,
onRetry,
onComplete,
onFailed,
title,
description,
showAnimation,
className,
}: RestartOverlayContentProps) {
const { status, progress, elapsedTime, checkAttempts, maxAttempts } = state
const { t } = useTranslation()
// 回调处理
useEffect(() => {
if (status === 'success' && onComplete) {
onComplete()
} else if (status === 'failed' && onFailed) {
onFailed()
}
}, [status, onComplete, onFailed])
const config = getStatusConfig(
status,
checkAttempts,
maxAttempts,
t,
title,
description
)
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return (
<div
className={cn(
'fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center',
className
)}
>
{/* 背景动画 */}
{showAnimation && <BackgroundAnimation />}
<div className="max-w-md w-full mx-4 space-y-8 relative z-10">
{/* 图标和状态 */}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{config.icon}
{/* 脉冲动画 */}
{(status === 'restarting' || status === 'checking') && (
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
)}
</div>
<h2 className="text-2xl font-bold">{config.title}</h2>
<p className="text-muted-foreground text-center">{config.description}</p>
</div>
{/* 进度条 */}
{status !== 'failed' && status !== 'idle' && (
<div className="space-y-2">
<Progress value={progress} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress}%</span>
<span>{t('restart.elapsed')} {formatTime(elapsedTime)}</span>
</div>
</div>
)}
{/* 提示信息 */}
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm text-muted-foreground">{config.tip}</p>
</div>
{/* 失败时的操作按钮 */}
{status === 'failed' && (
<div className="flex gap-2">
<Button
onClick={() => window.location.reload()}
variant="default"
className="flex-1"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('restart.refreshPage')}
</Button>
<Button onClick={onRetry} variant="secondary" className="flex-1">
<RotateCcw className="mr-2 h-4 w-4" />
{t('restart.retryCheck')}
</Button>
</div>
)}
</div>
</div>
)
}
// ============ 独立模式组件 ============
interface StandaloneRestartOverlayProps {
onComplete?: () => void
onFailed?: () => void
title?: string
description?: string
showAnimation?: boolean
className?: string
}
function StandaloneRestartOverlay({
onComplete,
onFailed,
title,
description,
showAnimation,
className,
}: StandaloneRestartOverlayProps) {
const [state, setState] = useState({
status: 'restarting' as RestartStatus,
progress: 0,
elapsedTime: 0,
checkAttempts: 0,
maxAttempts: 60,
})
const startHealthCheck = useCallback(() => {
let attempts = 0
const maxAttempts = 60
const check = async () => {
attempts++
setState((prev) => ({
...prev,
status: 'checking',
checkAttempts: attempts,
}))
try {
const response = await fetch('/api/webui/system/status', {
method: 'GET',
signal: AbortSignal.timeout(3000),
})
if (response.ok) {
setState((prev) => ({ ...prev, status: 'success', progress: 100 }))
setTimeout(() => {
onComplete?.()
window.location.href = '/auth'
}, 1500)
return
}
} catch {
// 继续重试
}
if (attempts >= maxAttempts) {
setState((prev) => ({ ...prev, status: 'failed' }))
onFailed?.()
} else {
setTimeout(check, 2000)
}
}
check()
}, [onComplete, onFailed])
useEffect(() => {
// 进度条动画
const progressInterval = setInterval(() => {
setState((prev) => ({
...prev,
progress: prev.progress >= 90 ? prev.progress : prev.progress + 1,
}))
}, 200)
// 计时器
const timerInterval = setInterval(() => {
setState((prev) => ({ ...prev, elapsedTime: prev.elapsedTime + 1 }))
}, 1000)
// 3秒后开始健康检查
const initialDelay = setTimeout(() => {
startHealthCheck()
}, 3000)
return () => {
clearInterval(progressInterval)
clearInterval(timerInterval)
clearTimeout(initialDelay)
}
}, [startHealthCheck])
return (
<RestartOverlayContent
state={state}
onRetry={startHealthCheck}
onComplete={onComplete}
onFailed={onFailed}
title={title}
description={description}
showAnimation={showAnimation}
className={className}
/>
)
}
// ============ 背景动画 ============
function BackgroundAnimation() {
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* 渐变圆环 */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px]">
<div className="absolute inset-0 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite]" />
<div className="absolute inset-8 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_0.5s]" />
<div className="absolute inset-16 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_1s]" />
</div>
{/* 浮动粒子 */}
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/20 rounded-full animate-bounce" />
<div className="absolute top-3/4 right-1/4 w-3 h-3 bg-primary/15 rounded-full animate-bounce delay-150" />
<div className="absolute top-1/2 right-1/3 w-2 h-2 bg-primary/20 rounded-full animate-bounce delay-300" />
</div>
)
}
// ============ 导出旧组件(兼容性) ============
// 如需使用旧版组件,请直接导入:
// import { RestartingOverlay } from '@/components/RestartingOverlay.legacy'

View File

@@ -0,0 +1,349 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { FileText, Search, SlidersHorizontal } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import type { LucideProps } from 'lucide-react'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ShortcutKbd } from '@/components/ui/kbd'
import { menuSections } from '@/components/layout/constants'
import { registeredRoutePaths } from '@/router'
import { getBotConfigSchema, getModelConfigSchema } from '@/lib/config-api'
import { getAllLocalizedText, resolveFieldLabel } from '@/lib/config-label'
import { cn } from '@/lib/utils'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface SearchItem {
id: string
icon: React.ComponentType<LucideProps>
title: string
description: string
path: string
category: string
keywords: string
}
function resolveSchemaTitle(schema: ConfigSchema, fallback: string) {
return schema.uiLabel || schema.classDoc || schema.className || fallback
}
function unwrapConfigSchema(payload: unknown): ConfigSchema | null {
if (!payload || typeof payload !== 'object') {
return null
}
if ('fields' in payload) {
return payload as ConfigSchema
}
if ('schema' in payload) {
const schema = (payload as { schema?: unknown }).schema
if (schema && typeof schema === 'object' && 'fields' in schema) {
return schema as ConfigSchema
}
}
return null
}
function getModelConfigPath(_fieldPath: string) {
return '/config/model'
}
function buildFieldSearchText(field: FieldSchema, fieldPath: string, sectionTitle: string, language?: string) {
const options = field.options?.join(' ') ?? ''
const optionDescriptions = field['x-option-descriptions']
? Object.entries(field['x-option-descriptions'])
.map(([key, value]) => `${key} ${value}`)
.join(' ')
: ''
return [
resolveFieldLabel(field, language),
...getAllLocalizedText(field.label),
field.name,
fieldPath,
field.description,
sectionTitle,
field.type,
options,
optionDescriptions,
].join(' ')
}
function collectConfigFields(
schema: ConfigSchema,
sourceLabel: string,
basePath: string,
routePath: (fieldPath: string) => string,
language?: string,
): SearchItem[] {
const items: SearchItem[] = []
const walk = (currentSchema: ConfigSchema, pathPrefix: string, sectionTrail: string[]) => {
const sectionTitle = resolveSchemaTitle(currentSchema, sourceLabel)
const nextTrail = [...sectionTrail, sectionTitle].filter(Boolean)
for (const field of currentSchema.fields) {
const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name
const nestedSchema = currentSchema.nested?.[field.name]
const fieldTitle = resolveFieldLabel(field, language)
const description = field.description || nextTrail.join(' / ') || fieldPath
const fullPath = basePath ? `${basePath}.${fieldPath}` : fieldPath
const route = routePath(fullPath)
items.push({
id: `config:${sourceLabel}:${fullPath}`,
icon: sourceLabel === '模型配置' ? SlidersHorizontal : FileText,
title: fieldTitle,
description: `${sourceLabel} / ${nextTrail.join(' / ')} / ${fullPath} · ${description}`,
path: route,
category: '配置项',
keywords: buildFieldSearchText(field, fullPath, nextTrail.join(' / '), language),
})
if (nestedSchema) {
walk(nestedSchema, fieldPath, nextTrail)
}
}
}
walk(schema, '', [])
return items
}
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [configSearchItems, setConfigSearchItems] = useState<SearchItem[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const { i18n, t } = useTranslation()
useEffect(() => {
setConfigSearchItems([])
}, [i18n.language])
useEffect(() => {
if (!open) {
return
}
const frameId = window.requestAnimationFrame(() => {
inputRef.current?.focus()
})
return () => window.cancelAnimationFrame(frameId)
}, [open])
useEffect(() => {
if (!open || configSearchItems.length > 0) {
return
}
let cancelled = false
const loadConfigSearchItems = async () => {
const [botSchemaResult, modelSchemaResult] = await Promise.all([
getBotConfigSchema(),
getModelConfigSchema(),
])
if (cancelled) {
return
}
const nextItems: SearchItem[] = []
if (botSchemaResult.success) {
const botSchema = unwrapConfigSchema(botSchemaResult.data)
if (botSchema) {
nextItems.push(...collectConfigFields(
botSchema,
'Bot 配置',
'',
() => '/config/bot',
i18n.language,
))
}
}
if (modelSchemaResult.success) {
const modelSchema = unwrapConfigSchema(modelSchemaResult.data)
if (modelSchema) {
nextItems.push(...collectConfigFields(
modelSchema,
'模型配置',
'',
getModelConfigPath,
i18n.language,
))
}
}
setConfigSearchItems(nextItems)
}
loadConfigSearchItems().catch(() => {
if (!cancelled) {
setConfigSearchItems([])
}
})
return () => {
cancelled = true
}
}, [configSearchItems.length, i18n.language, open])
const searchItems: SearchItem[] = useMemo(
() =>
menuSections.flatMap((section) =>
section.items
.filter((item) => registeredRoutePaths.has(item.path))
.map((item) => ({
id: `route:${item.path}`,
icon: item.icon,
title: t(item.label),
description: item.searchDescription ? t(item.searchDescription) : item.path,
path: item.path,
category: t(section.title),
keywords: [
t(item.label),
item.path,
item.searchDescription ? t(item.searchDescription) : '',
t(section.title),
].join(' '),
}))
),
[t]
)
// 过滤搜索结果
const normalizedQuery = searchQuery.trim().toLowerCase()
const filteredItems = (normalizedQuery ? [...searchItems, ...configSearchItems] : searchItems)
.filter((item) => item.keywords.toLowerCase().includes(normalizedQuery))
.slice(0, 80)
// 导航到页面
const handleNavigate = useCallback((path: string) => {
navigate({ to: path })
onOpenChange(false)
// 在导航后重置状态
setSearchQuery('')
setSelectedIndex(0)
}, [navigate, onOpenChange])
// 键盘导航
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
if (filteredItems.length === 0) return
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (filteredItems.length === 0) return
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
} else if (e.key === 'Enter' && filteredItems[selectedIndex]) {
e.preventDefault()
handleNavigate(filteredItems[selectedIndex].path)
}
},
[filteredItems, selectedIndex, handleNavigate]
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0" confirmOnEnter>
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="sr-only">{t('search.title')}</DialogTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setSelectedIndex(0)
}}
onKeyDown={handleKeyDown}
placeholder={t('search.placeholder')}
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
/>
</div>
</DialogHeader>
<div className="border-t">
<DialogBody className="h-100" viewportClassName="px-0">
{filteredItems.length > 0 ? (
<div className="p-2">
{filteredItems.map((item, index) => {
const Icon = item.icon
return (
<button
key={item.id}
onClick={() => handleNavigate(item.path)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-left transition-colors',
index === selectedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
)}
>
<Icon className="h-5 w-5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.title}</div>
<div className="text-xs text-muted-foreground truncate">
{item.description}
</div>
</div>
<div className="text-xs text-muted-foreground px-2 py-1 bg-muted rounded">
{item.category}
</div>
</button>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-sm text-muted-foreground">
{searchQuery ? t('search.noResults') : t('search.startSearch')}
</p>
</div>
)}
</DialogBody>
</div>
<div className="border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<ShortcutKbd size="sm" keys={['up']} />
<ShortcutKbd size="sm" keys={['down']} />
{t('search.navigate')}
</span>
<span className="flex items-center gap-1">
<ShortcutKbd size="sm" keys={['enter']} />
{t('search.select')}
</span>
<span className="flex items-center gap-1">
<ShortcutKbd size="sm" keys={['esc']} />
{t('search.close')}
</span>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,685 @@
/**
* 分享 Pack 对话框
*
* 允许用户将当前配置导出并分享到 Pack 市场
*/
import { useState, useEffect } from 'react'
import {
Package,
Share2,
Server,
Layers,
ListChecks,
Tag,
Loader2,
Check,
Info,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/hooks/use-toast'
import {
createPack,
exportCurrentConfigAsPack,
type PackProvider,
type PackModel,
type PackTaskConfigs,
} from '@/lib/pack-api'
// 任务类型名称映射
const TASK_TYPE_NAMES: Record<string, string> = {
utils: '通用工具',
utils_small: '轻量工具',
tool_use: '工具调用',
replyer: '回复生成',
planner: '规划推理',
vlm: '视觉模型',
voice: '语音处理',
embedding: '向量嵌入',
lpmm_entity_extract: '实体提取',
lpmm_rdf_build: 'RDF构建',
lpmm_qa: '问答模型',
}
// 预设标签
const PRESET_TAGS = [
'官方推荐',
'性价比',
'高性能',
'免费模型',
'国内可用',
'海外模型',
'OpenAI',
'Claude',
'Gemini',
'国产模型',
'多模态',
'轻量级',
]
interface SharePackDialogProps {
trigger?: React.ReactNode
}
export function SharePackDialog({ trigger }: SharePackDialogProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
// 配置数据
const [providers, setProviders] = useState<PackProvider[]>([])
const [models, setModels] = useState<PackModel[]>([])
const [taskConfig, setTaskConfig] = useState<PackTaskConfigs>({})
// 选择状态
const [selectedProviders, setSelectedProviders] = useState<Set<string>>(new Set())
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set())
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set())
// Pack 信息
const [packName, setPackName] = useState('')
const [packDescription, setPackDescription] = useState('')
const [packAuthor, setPackAuthor] = useState('')
const [packTags, setPackTags] = useState<string[]>([])
// 加载当前配置
useEffect(() => {
if (open && step === 1) {
loadCurrentConfig()
}
}, [open, step])
const loadCurrentConfig = async () => {
setLoading(true)
try {
const config = await exportCurrentConfigAsPack({
name: '',
description: '',
author: '',
})
setProviders(config.providers)
setModels(config.models)
setTaskConfig(config.task_config)
// 默认全选
setSelectedProviders(new Set(config.providers.map(p => p.name)))
setSelectedModels(new Set(config.models.map(m => m.name)))
setSelectedTasks(new Set(Object.keys(config.task_config)))
} catch (error) {
console.error('加载配置失败:', error)
toast({ title: '加载当前配置失败', variant: 'destructive' })
} finally {
setLoading(false)
}
}
// 切换选择
const toggleProvider = (name: string) => {
const newSet = new Set(selectedProviders)
const newModels = new Set(selectedModels)
const newTasks = new Set(selectedTasks)
if (newSet.has(name)) {
// 取消选择提供商
newSet.delete(name)
// 取消选择该提供商下的所有模型
const providerModels = models.filter(m => m.api_provider === name)
providerModels.forEach(m => newModels.delete(m.name))
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list) {
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
if (!hasSelectedModel) {
newTasks.delete(key)
}
}
})
} else {
// 选择提供商
newSet.add(name)
// 自动选择该提供商下的所有模型
const providerModels = models.filter(m => m.api_provider === name)
providerModels.forEach(m => newModels.add(m.name))
// 自动选择使用这些模型的任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list) {
const hasProviderModel = config.model_list.some((modelName: string) => {
const model = models.find(m => m.name === modelName)
return model && model.api_provider === name
})
if (hasProviderModel) {
newTasks.add(key)
}
}
})
}
setSelectedProviders(newSet)
setSelectedModels(newModels)
setSelectedTasks(newTasks)
}
const toggleModel = (name: string) => {
const newModels = new Set(selectedModels)
const newTasks = new Set(selectedTasks)
if (newModels.has(name)) {
// 取消选择模型
newModels.delete(name)
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list) {
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
if (!hasSelectedModel) {
newTasks.delete(key)
}
}
})
} else {
// 选择模型
newModels.add(name)
// 自动选择使用这个模型的任务
Object.entries(taskConfig).forEach(([key, config]) => {
if (config.model_list && config.model_list.includes(name)) {
newTasks.add(key)
}
})
}
setSelectedModels(newModels)
setSelectedTasks(newTasks)
}
const toggleTask = (key: string) => {
const newSet = new Set(selectedTasks)
if (newSet.has(key)) {
newSet.delete(key)
} else {
newSet.add(key)
}
setSelectedTasks(newSet)
}
const toggleTag = (tag: string) => {
if (packTags.includes(tag)) {
setPackTags(packTags.filter(t => t !== tag))
} else if (packTags.length < 5) {
setPackTags([...packTags, tag])
} else {
toast({ title: '最多选择 5 个标签', variant: 'destructive' })
}
}
// 全选/取消全选
const selectAllProviders = () => {
if (selectedProviders.size === providers.length) {
setSelectedProviders(new Set())
} else {
setSelectedProviders(new Set(providers.map(p => p.name)))
}
}
const selectAllModels = () => {
if (selectedModels.size === models.length) {
setSelectedModels(new Set())
} else {
setSelectedModels(new Set(models.map(m => m.name)))
}
}
const selectAllTasks = () => {
const taskKeys = Object.keys(taskConfig)
if (selectedTasks.size === taskKeys.length) {
setSelectedTasks(new Set())
} else {
setSelectedTasks(new Set(taskKeys))
}
}
// 提交
const handleSubmit = async () => {
// 验证
if (!packName.trim()) {
toast({ title: '请输入模板名称', variant: 'destructive' })
return
}
if (!packDescription.trim()) {
toast({ title: '请输入模板描述', variant: 'destructive' })
return
}
if (!packAuthor.trim()) {
toast({ title: '请输入作者名称', variant: 'destructive' })
return
}
if (selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0) {
toast({ title: '请至少选择一项配置', variant: 'destructive' })
return
}
setSubmitting(true)
try {
// 过滤选中的配置
const selectedProviderConfigs = providers.filter(p => selectedProviders.has(p.name))
const selectedModelConfigs = models.filter(m => selectedModels.has(m.name))
const selectedTaskConfigs: PackTaskConfigs = {}
for (const [key, config] of Object.entries(taskConfig)) {
if (selectedTasks.has(key)) {
selectedTaskConfigs[key as keyof PackTaskConfigs] = config
}
}
await createPack({
name: packName.trim(),
description: packDescription.trim(),
author: packAuthor.trim(),
tags: packTags,
providers: selectedProviderConfigs,
models: selectedModelConfigs,
task_config: selectedTaskConfigs,
})
toast({ title: '模板已提交审核,审核通过后将显示在市场中' })
setOpen(false)
resetForm()
} catch (error) {
console.error('提交失败:', error)
toast({ title: error instanceof Error ? error.message : '提交失败', variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
// 重置表单
const resetForm = () => {
setStep(1)
setPackName('')
setPackDescription('')
setPackAuthor('')
setPackTags([])
setSelectedProviders(new Set())
setSelectedModels(new Set())
setSelectedTasks(new Set())
}
const totalSteps = 2
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline">
<Share2 className="w-4 h-4 mr-2" />
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl flex flex-col" confirmOnEnter>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
{step} / {totalSteps}
{step === 1 && '选择要分享的配置'}
{step === 2 && '填写模板信息'}
</DialogDescription>
</DialogHeader>
<DialogBody>
{loading ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">...</p>
</div>
) : (
<>
{/* 步骤 1: 选择配置 */}
{step === 1 && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
<strong></strong> API Key
</AlertDescription>
</Alert>
<Tabs defaultValue="providers" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="providers">
<Server className="w-4 h-4 mr-2" />
API
<Badge variant="secondary" className="ml-2">
{selectedProviders.size}/{providers.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="models">
<Layers className="w-4 h-4 mr-2" />
<Badge variant="secondary" className="ml-2">
{selectedModels.size}/{models.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="tasks">
<ListChecks className="w-4 h-4 mr-2" />
<Badge variant="secondary" className="ml-2">
{selectedTasks.size}/{Object.keys(taskConfig).length}
</Badge>
</TabsTrigger>
</TabsList>
{/* 提供商选择 */}
<TabsContent value="providers" className="space-y-2 mt-4">
<div className="space-y-2">
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={selectAllProviders}>
{selectedProviders.size === providers.length ? '取消全选' : '全选'}
</Button>
</div>
{providers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
) : (
providers.map(provider => (
<div
key={provider.name}
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
>
<Checkbox
id={`provider-${provider.name}`}
checked={selectedProviders.has(provider.name)}
onCheckedChange={() => toggleProvider(provider.name)}
/>
<Label
htmlFor={`provider-${provider.name}`}
className="flex-1 cursor-pointer"
>
<span className="font-medium">{provider.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{provider.base_url}
</span>
</Label>
<Badge variant="outline" className="text-xs">
{provider.client_type}
</Badge>
</div>
))
)}
</div>
</TabsContent>
{/* 模型选择 */}
<TabsContent value="models" className="space-y-2 mt-4">
<div className="space-y-2">
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={selectAllModels}>
{selectedModels.size === models.length ? '取消全选' : '全选'}
</Button>
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
) : (
models.map(model => (
<div
key={model.name}
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
>
<Checkbox
id={`model-${model.name}`}
checked={selectedModels.has(model.name)}
onCheckedChange={() => toggleModel(model.name)}
/>
<Label
htmlFor={`model-${model.name}`}
className="flex-1 cursor-pointer"
>
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{model.model_identifier}
</span>
</Label>
<span className="text-xs text-muted-foreground">
{model.api_provider}
</span>
</div>
))
)}
</div>
</TabsContent>
{/* 任务配置选择 */}
<TabsContent value="tasks" className="space-y-2 mt-4">
<div className="space-y-2">
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={selectAllTasks}>
{selectedTasks.size === Object.keys(taskConfig).length ? '取消全选' : '全选'}
</Button>
</div>
{Object.keys(taskConfig).length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
) : (
Object.entries(taskConfig).map(([key, config]) => (
<div
key={key}
className="space-y-2 p-2 rounded hover:bg-muted"
>
<div className="flex items-center space-x-2">
<Checkbox
id={`task-${key}`}
checked={selectedTasks.has(key)}
onCheckedChange={() => toggleTask(key)}
/>
<Label
htmlFor={`task-${key}`}
className="flex-1 cursor-pointer"
>
<span className="font-medium">
{TASK_TYPE_NAMES[key] || key}
</span>
</Label>
<Badge variant="outline" className="text-xs">
{config.model_list.length}
</Badge>
</div>
{config.model_list && config.model_list.length > 0 && (
<div className="ml-6 flex flex-wrap gap-1">
{config.model_list.map((modelName: string) => {
const model = models.find(m => m.name === modelName)
const isSelected = selectedModels.has(modelName)
return (
<Badge
key={modelName}
variant={isSelected ? "default" : "outline"}
className="text-xs cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => toggleModel(modelName)}
>
{modelName}
{model && (
<span className="ml-1 opacity-70">
({model.api_provider})
</span>
)}
</Badge>
)
})}
</div>
)}
</div>
))
)}
</div>
</TabsContent>
</Tabs>
</div>
)}
{/* 步骤 2: 填写信息 */}
{step === 2 && (
<div className="space-y-4">
{/* 选择摘要 */}
<div className="flex gap-4 text-sm p-3 bg-muted rounded-lg">
<span className="flex items-center gap-1">
<Server className="w-4 h-4" />
{selectedProviders.size}
</span>
<span className="flex items-center gap-1">
<Layers className="w-4 h-4" />
{selectedModels.size}
</span>
<span className="flex items-center gap-1">
<ListChecks className="w-4 h-4" />
{selectedTasks.size}
</span>
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pack-name"> *</Label>
<Input
id="pack-name"
placeholder="例如:高性价比国产模型配置"
value={packName}
onChange={e => setPackName(e.target.value)}
maxLength={50}
/>
<p className="text-xs text-muted-foreground">
{packName.length}/50
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pack-description"> *</Label>
<Textarea
id="pack-description"
placeholder="详细描述这个配置模板的特点、适用场景等..."
value={packDescription}
onChange={e => setPackDescription(e.target.value)}
rows={4}
maxLength={500}
/>
<p className="text-xs text-muted-foreground">
{packDescription.length}/500
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pack-author"> *</Label>
<Input
id="pack-author"
placeholder="你的昵称或 ID"
value={packAuthor}
onChange={e => setPackAuthor(e.target.value)}
maxLength={30}
/>
</div>
<div className="space-y-2">
<Label> 5 </Label>
<div className="flex flex-wrap gap-2">
{PRESET_TAGS.map(tag => (
<Badge
key={tag}
variant={packTags.includes(tag) ? 'default' : 'outline'}
className="cursor-pointer transition-colors"
onClick={() => toggleTag(tag)}
>
{packTags.includes(tag) && <Check className="w-3 h-3 mr-1" />}
<Tag className="w-3 h-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
1-3
</AlertDescription>
</Alert>
</div>
)}
</>
)}
</DialogBody>
<DialogFooter className="flex justify-between pt-4 border-t">
<div>
{step > 1 && (
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={submitting}>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setOpen(false)
resetForm()
}}
disabled={submitting}
>
</Button>
{step < totalSteps ? (
<Button
data-dialog-action="confirm"
onClick={() => setStep(step + 1)}
disabled={
loading ||
(selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0)
}
>
</Button>
) : (
<Button data-dialog-action="confirm" onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,8 @@
/**
* 问卷组件导出
*/
export { SurveyRenderer } from './survey-renderer'
export { SurveyQuestion } from './survey-question'
export { SurveyResults } from './survey-results'
export type { SurveyRendererProps } from './survey-renderer'

View File

@@ -0,0 +1,247 @@
/**
* 单个问题渲染组件
*/
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Star } from 'lucide-react'
import type { SurveyQuestion as SurveyQuestionType } from '@/types/survey'
interface SurveyQuestionProps {
question: SurveyQuestionType
value: string | string[] | number | undefined
onChange: (value: string | string[] | number) => void
error?: string
disabled?: boolean
}
export function SurveyQuestion({
question,
value,
onChange,
error,
disabled = false
}: SurveyQuestionProps) {
const [hoverRating, setHoverRating] = useState<number | null>(null)
// 如果问题设置了只读,则禁用输入
const isDisabled = disabled || question.readOnly
const renderQuestion = () => {
switch (question.type) {
case 'single':
return (
<RadioGroup
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
className="space-y-2"
>
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${question.id}-${option.id}`} />
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
)
case 'multiple': {
const selectedValues = (value as string[]) || []
return (
<div className="space-y-2">
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<Checkbox
id={`${question.id}-${option.id}`}
checked={selectedValues.includes(option.value)}
disabled={isDisabled || (
question.maxSelections !== undefined &&
selectedValues.length >= question.maxSelections &&
!selectedValues.includes(option.value)
)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...selectedValues, option.value])
} else {
onChange(selectedValues.filter(v => v !== option.value))
}
}}
/>
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
{question.maxSelections && (
<p className="text-xs text-muted-foreground">
{question.maxSelections}
</p>
)}
</div>
)
}
case 'text':
return (
<Input
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
)
case 'textarea':
return (
<div className="space-y-1">
<Textarea
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
rows={4}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
{question.maxLength && (
<p className="text-xs text-muted-foreground text-right">
{(value as string || '').length} / {question.maxLength}
</p>
)}
</div>
)
case 'rating': {
const ratingValue = (value as number) || 0
const displayRating = hoverRating !== null ? hoverRating : ratingValue
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={isDisabled}
className={cn(
"p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring rounded",
isDisabled && "cursor-not-allowed opacity-50"
)}
onMouseEnter={() => !isDisabled && setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => !isDisabled && onChange(star)}
>
<Star
className={cn(
"h-6 w-6 transition-colors",
star <= displayRating
? "fill-yellow-400 text-yellow-400"
: "text-muted-foreground"
)}
/>
</button>
))}
{ratingValue > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
{ratingValue} / 5
</span>
)}
</div>
)
}
case 'scale': {
const min = question.min ?? 1
const max = question.max ?? 10
const step = question.step ?? 1
const scaleValue = (value as number) ?? min
return (
<div className="space-y-4">
<Slider
value={[scaleValue]}
onValueChange={([val]) => onChange(val)}
min={min}
max={max}
step={step}
disabled={isDisabled}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{question.minLabel || min}</span>
<span className="font-medium text-foreground">{scaleValue}</span>
<span>{question.maxLabel || max}</span>
</div>
</div>
)
}
case 'dropdown':
return (
<Select
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue placeholder={question.placeholder || '请选择...'} />
</SelectTrigger>
<SelectContent>
{question.options?.map((option) => (
<SelectItem key={option.id} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return <div className="text-muted-foreground"></div>
}
}
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-base font-medium">
{question.title}
{question.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{question.description && (
<p className="text-sm text-muted-foreground">{question.description}</p>
)}
</div>
{renderQuestion()}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,407 @@
/**
* 问卷渲染器组件
* 读取 JSON 配置并展示问卷界面
*/
import { useState, useCallback, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Loader2, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
import { SurveyQuestion } from './survey-question'
import { submitSurvey, checkUserSubmission } from '@/lib/survey-api'
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
export interface SurveyRendererProps {
/** 问卷配置 */
config: SurveyConfig
/** 初始答案(用于预填充,如自动填写版本号) */
initialAnswers?: QuestionAnswer[]
/** 提交成功回调 */
onSubmitSuccess?: (submissionId: string) => void
/** 提交失败回调 */
onSubmitError?: (error: string) => void
/** 是否显示进度条 */
showProgress?: boolean
/** 是否分页显示(每页一题) */
paginateQuestions?: boolean
/** 自定义类名 */
className?: string
}
type AnswerMap = Record<string, string | string[] | number | undefined>
export function SurveyRenderer({
config,
initialAnswers,
onSubmitSuccess,
onSubmitError,
showProgress = true,
paginateQuestions = false,
className
}: SurveyRendererProps) {
// 将 initialAnswers 转换为 AnswerMap
const getInitialAnswerMap = useCallback((): AnswerMap => {
if (!initialAnswers || initialAnswers.length === 0) return {}
return initialAnswers.reduce((acc, answer) => {
acc[answer.questionId] = answer.value
return acc
}, {} as AnswerMap)
}, [initialAnswers])
const [answers, setAnswers] = useState<AnswerMap>(() => getInitialAnswerMap())
const [errors, setErrors] = useState<Record<string, string>>({})
const [currentPage, setCurrentPage] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submissionId, setSubmissionId] = useState<string | null>(null)
const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false)
const [isCheckingSubmission, setIsCheckingSubmission] = useState(true)
// 当 initialAnswers 变化时更新答案(合并而非替换)
useEffect(() => {
if (initialAnswers && initialAnswers.length > 0) {
setAnswers(prev => ({
...prev,
...getInitialAnswerMap()
}))
}
}, [initialAnswers, getInitialAnswerMap])
// 检查是否已提交过
useEffect(() => {
const checkSubmission = async () => {
if (!config.settings?.allowMultiple) {
const result = await checkUserSubmission(config.id)
if (result.success && result.hasSubmitted) {
setHasAlreadySubmitted(true)
}
}
setIsCheckingSubmission(false)
}
checkSubmission()
}, [config.id, config.settings?.allowMultiple])
// 检查问卷是否在有效期内
const isWithinTimeRange = useCallback(() => {
const now = new Date()
if (config.settings?.startTime && new Date(config.settings.startTime) > now) {
return false
}
if (config.settings?.endTime && new Date(config.settings.endTime) < now) {
return false
}
return true
}, [config.settings?.startTime, config.settings?.endTime])
// 计算进度
const answeredCount = config.questions.filter(q => {
const answer = answers[q.id]
if (answer === undefined || answer === null) return false
if (Array.isArray(answer)) return answer.length > 0
if (typeof answer === 'string') return answer.trim() !== ''
return true
}).length
const progress = (answeredCount / config.questions.length) * 100
// 更新答案
const handleAnswerChange = useCallback((questionId: string, value: string | string[] | number) => {
setAnswers(prev => ({ ...prev, [questionId]: value }))
// 清除该问题的错误
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[questionId]
return newErrors
})
}, [])
// 验证答案
const validateAnswers = useCallback(() => {
const newErrors: Record<string, string> = {}
for (const question of config.questions) {
if (question.required) {
const answer = answers[question.id]
if (answer === undefined || answer === null) {
newErrors[question.id] = '此题为必填项'
continue
}
if (Array.isArray(answer) && answer.length === 0) {
newErrors[question.id] = '请至少选择一项'
continue
}
if (typeof answer === 'string' && answer.trim() === '') {
newErrors[question.id] = '此题为必填项'
continue
}
}
// 文本长度验证
if (question.minLength && typeof answers[question.id] === 'string') {
const text = answers[question.id] as string
if (text.length < question.minLength) {
newErrors[question.id] = `至少需要 ${question.minLength} 个字符`
}
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [config.questions, answers])
// 提交问卷
const handleSubmit = useCallback(async () => {
if (!validateAnswers()) {
// 如果是分页模式,跳转到第一个有错误的问题
if (paginateQuestions) {
const firstErrorIndex = config.questions.findIndex(q => errors[q.id])
if (firstErrorIndex >= 0) {
setCurrentPage(firstErrorIndex)
}
}
return
}
setIsSubmitting(true)
setSubmitError(null)
try {
// 构建答案列表
const answerList: QuestionAnswer[] = config.questions
.filter(q => answers[q.id] !== undefined)
.map(q => ({
questionId: q.id,
value: answers[q.id]!
}))
const result = await submitSurvey(
config.id,
config.version,
answerList,
{ allowMultiple: config.settings?.allowMultiple }
)
if (result.success && result.submissionId) {
setIsSubmitted(true)
setSubmissionId(result.submissionId)
onSubmitSuccess?.(result.submissionId)
} else {
const error = result.error || '提交失败'
setSubmitError(error)
onSubmitError?.(error)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '提交失败'
setSubmitError(errorMsg)
onSubmitError?.(errorMsg)
} finally {
setIsSubmitting(false)
}
}, [validateAnswers, paginateQuestions, config, answers, errors, onSubmitSuccess, onSubmitError])
// 分页导航
const goToPage = useCallback((page: number) => {
if (page >= 0 && page < config.questions.length) {
setCurrentPage(page)
}
}, [config.questions.length])
// 检查中
if (isCheckingSubmission) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
// 已提交过
if (hasAlreadySubmitted && !config.settings?.allowMultiple) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle>{config.title}</CardTitle>
</CardHeader>
<CardContent className="py-8">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}
// 不在有效期内
if (!isWithinTimeRange()) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle>{config.title}</CardTitle>
</CardHeader>
<CardContent className="py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}
// 提交成功
if (isSubmitted) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-6 w-6" />
</CardTitle>
</CardHeader>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
{config.settings?.thankYouMessage || '感谢你的参与!'}
</p>
{submissionId && (
<p className="text-center text-xs text-muted-foreground mt-4">
{submissionId}
</p>
)}
</CardContent>
</Card>
)
}
// 问卷展示
const questionsToShow = paginateQuestions
? [config.questions[currentPage]]
: config.questions
return (
<div className={cn("h-full flex flex-col", className)}>
{/* 问卷头部 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 mb-4 shrink-0">
<h2 className="text-xl font-semibold">{config.title}</h2>
{config.description && (
<p className="text-muted-foreground mt-1 text-sm">{config.description}</p>
)}
{showProgress && (
<div className="space-y-1 pt-3">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{answeredCount} / {config.questions.length}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
</div>
{/* 问卷内容 - 可滚动区域 */}
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-4 pr-4">
{questionsToShow.map((question, index) => (
<div
key={question.id}
className={cn(
"p-4 rounded-lg border bg-card",
errors[question.id] ? "border-destructive bg-destructive/5" : "border-border"
)}
>
{paginateQuestions && (
<div className="text-xs text-muted-foreground mb-2">
{currentPage + 1} / {config.questions.length}
</div>
)}
{!paginateQuestions && (
<div className="text-xs text-muted-foreground mb-2">
{index + 1}.
</div>
)}
<SurveyQuestion
question={question}
value={answers[question.id]}
onChange={(value) => handleAnswerChange(question.id, value)}
error={errors[question.id]}
disabled={isSubmitting}
/>
</div>
))}
{submitError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
{/* 提交按钮区域 */}
<div className="flex justify-between items-center py-4">
{paginateQuestions ? (
<>
<Button
variant="outline"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 0 || isSubmitting}
>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
{currentPage === config.questions.length - 1 ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
) : (
<Button
onClick={() => goToPage(currentPage + 1)}
disabled={isSubmitting}
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</>
) : (
<>
<div className="text-sm text-muted-foreground">
{Object.keys(errors).length > 0 && (
<span className="text-destructive">
{Object.keys(errors).length}
</span>
)}
</div>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="lg"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,292 @@
/**
* 问卷结果查看组件
* 展示问卷统计数据和用户提交记录
*/
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Loader2, Users, FileText, Clock, Star, BarChart3 } from 'lucide-react'
import { getSurveyStats, getUserSubmissions } from '@/lib/survey-api'
import type { SurveyConfig, SurveyStats, StoredSubmission } from '@/types/survey'
interface SurveyResultsProps {
/** 问卷配置 */
config: SurveyConfig
/** 是否显示用户提交记录 */
showUserSubmissions?: boolean
/** 自定义类名 */
className?: string
}
export function SurveyResults({
config,
showUserSubmissions = true,
className
}: SurveyResultsProps) {
const [stats, setStats] = useState<SurveyStats | null>(null)
const [userSubmissions, setUserSubmissions] = useState<StoredSubmission[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
setError(null)
try {
// 获取统计数据
const statsResult = await getSurveyStats(config.id)
if (statsResult.success && statsResult.stats) {
setStats(statsResult.stats)
}
// 获取用户提交记录
if (showUserSubmissions) {
const submissionsResult = await getUserSubmissions(config.id)
if (submissionsResult.success && submissionsResult.submissions) {
setUserSubmissions(submissionsResult.submissions)
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败')
} finally {
setIsLoading(false)
}
}
fetchData()
}, [config.id, showUserSubmissions])
if (isLoading) {
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
if (error) {
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardContent className="py-12 text-center text-muted-foreground">
{error}
</CardContent>
</Card>
)
}
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
{config.title} -
</CardTitle>
{config.description && (
<CardDescription>{config.description}</CardDescription>
)}
</CardHeader>
<CardContent>
{/* 概览统计 */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold">
{stats?.totalSubmissions || 0}
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<Users className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold">
{stats?.uniqueUsers || 0}
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<Clock className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-sm font-medium">
{stats?.lastSubmissionAt
? new Date(stats.lastSubmissionAt).toLocaleDateString()
: '-'
}
</div>
</div>
</div>
<Tabs defaultValue="stats" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stats"></TabsTrigger>
{showUserSubmissions && (
<TabsTrigger value="submissions"></TabsTrigger>
)}
</TabsList>
<TabsContent value="stats" className="mt-4">
<ScrollArea className="max-h-[60vh]">
<div className="space-y-6 pr-4">
{config.questions.map((question, index) => {
const qStats = stats?.questionStats[question.id]
return (
<div key={question.id} className="p-4 rounded-lg border">
<div className="text-xs text-muted-foreground mb-1">
{index + 1}
</div>
<div className="font-medium mb-3">{question.title}</div>
{qStats ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
{qStats.answered}
</div>
{/* 选择题统计 */}
{qStats.optionCounts && question.options && (
<div className="space-y-2">
{question.options.map(option => {
const count = qStats.optionCounts?.[option.value] || 0
const percentage = qStats.answered > 0
? (count / qStats.answered) * 100
: 0
return (
<div key={option.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span>{option.label}</span>
<span className="text-muted-foreground">
{count} ({percentage.toFixed(1)}%)
</span>
</div>
<Progress value={percentage} className="h-2" />
</div>
)
})}
</div>
)}
{/* 评分/量表统计 */}
{qStats.average !== undefined && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-400" />
<span className="text-sm">
{qStats.average.toFixed(2)}
</span>
</div>
)}
{/* 文本答案样本 */}
{qStats.sampleAnswers && qStats.sampleAnswers.length > 0 && (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
</div>
<div className="space-y-1">
{qStats.sampleAnswers.map((answer, i) => (
<div
key={i}
className="text-sm p-2 bg-muted/50 rounded text-muted-foreground"
>
"{answer}"
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
</div>
)}
</div>
)
})}
</div>
</ScrollArea>
</TabsContent>
{showUserSubmissions && (
<TabsContent value="submissions" className="mt-4">
<ScrollArea className="max-h-[60vh]">
{userSubmissions.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
</div>
) : (
<div className="space-y-4 pr-4">
{userSubmissions.map((submission) => (
<div key={submission.id} className="p-4 rounded-lg border">
<div className="flex items-center justify-between mb-3">
<Badge variant="outline">
{new Date(submission.submittedAt).toLocaleString()}
</Badge>
<span className="text-xs text-muted-foreground">
ID: {submission.id}
</span>
</div>
<div className="space-y-2">
{submission.answers.map((answer) => {
const question = config.questions.find(
q => q.id === answer.questionId
)
if (!question) return null
// 格式化答案显示
let displayValue: string
if (Array.isArray(answer.value)) {
const labels = answer.value.map(v => {
const opt = question.options?.find(o => o.value === v)
return opt?.label || v
})
displayValue = labels.join('、')
} else if (typeof answer.value === 'number') {
displayValue = answer.value.toString()
} else {
const opt = question.options?.find(
o => o.value === answer.value
)
displayValue = opt?.label || answer.value
}
return (
<div key={answer.questionId} className="text-sm">
<span className="text-muted-foreground">
{question.title}
</span>
<span>{displayValue}</span>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { ThemeProviderContext } from '@/lib/theme-context'
import type { UserThemeConfig } from '@/lib/theme/tokens'
import {
THEME_STORAGE_KEYS,
loadThemeConfig,
migrateOldKeys,
resetThemeToDefault,
saveThemePartial,
} from '@/lib/theme/storage'
import { applyThemePipeline, removeCustomCSS } from '@/lib/theme/pipeline'
type Theme = 'dark' | 'light' | 'system'
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
storageKey?: string
}
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey: _storageKey,
}: ThemeProviderProps) {
const [themeMode, setThemeMode] = useState<Theme>(() => {
const saved = localStorage.getItem(THEME_STORAGE_KEYS.MODE) as Theme | null
return saved || defaultTheme
})
const [themeConfig, setThemeConfig] = useState<UserThemeConfig>(() => loadThemeConfig())
const [systemThemeTick, setSystemThemeTick] = useState(0)
const resolvedTheme = useMemo<'dark' | 'light'>(() => {
if (themeMode !== 'system') return themeMode
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}, [themeMode, systemThemeTick])
useEffect(() => {
migrateOldKeys()
}, [])
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
if (themeMode === 'system') {
setSystemThemeTick((prev) => prev + 1)
}
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [themeMode])
useEffect(() => {
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(resolvedTheme)
const isDark = resolvedTheme === 'dark'
applyThemePipeline(themeConfig, isDark)
}, [resolvedTheme, themeConfig])
const setTheme = useCallback((mode: Theme) => {
localStorage.setItem(THEME_STORAGE_KEYS.MODE, mode)
setThemeMode(mode)
}, [])
const updateThemeConfig = useCallback((partial: Partial<UserThemeConfig>) => {
saveThemePartial(partial)
setThemeConfig((prev) => ({ ...prev, ...partial }))
}, [])
const resetTheme = useCallback(() => {
resetThemeToDefault()
removeCustomCSS()
setThemeConfig(loadThemeConfig())
}, [])
const value = useMemo(
() => ({
theme: themeMode,
resolvedTheme,
setTheme,
themeConfig,
updateThemeConfig,
resetTheme,
}),
[themeMode, resolvedTheme, setTheme, themeConfig, updateThemeConfig, resetTheme],
)
return (
<ThemeProviderContext value={value}>
{children}
</ThemeProviderContext>
)
}

View File

@@ -0,0 +1,5 @@
export { TourProvider } from './tour-provider'
export { TourRenderer } from './tour-renderer'
export { useTour } from './use-tour'
export { TourContext } from './tour-context'
export type { TourId, TourState, TourContextType } from './types'

View File

@@ -0,0 +1,4 @@
import { createContext } from 'react'
import type { TourContextType } from './types'
export const TourContext = createContext<TourContextType | null>(null)

View File

@@ -0,0 +1,177 @@
import { useState, useCallback, type ReactNode } from 'react'
import type { Step, CallBackProps, Status } from 'react-joyride'
import { TourContext } from './tour-context'
import type { TourId, TourState } from './types'
const COMPLETED_TOURS_KEY = 'maibot-completed-tours'
// 从 localStorage 读取已完成的 Tours
function getCompletedTours(): Set<TourId> {
try {
const stored = localStorage.getItem(COMPLETED_TOURS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
} catch {
return new Set()
}
}
// 保存已完成的 Tours 到 localStorage
function saveCompletedTours(tours: Set<TourId>) {
localStorage.setItem(COMPLETED_TOURS_KEY, JSON.stringify([...tours]))
}
export function TourProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<TourState>({
activeTourId: null,
stepIndex: 0,
isRunning: false,
})
// 使用 useState 存储 toursMap 对象是可变的,可以直接修改)
const [tours] = useState<Map<TourId, Step[]>>(() => new Map())
const [completedTours, setCompletedTours] = useState<Set<TourId>>(getCompletedTours)
// 用于强制重新渲染的计数器
const [, forceUpdate] = useState(0)
const registerTour = useCallback((tourId: TourId, steps: Step[]) => {
tours.set(tourId, steps)
// 强制更新以确保 context 消费者能获取到最新数据
forceUpdate(n => n + 1)
}, [tours])
const unregisterTour = useCallback((tourId: TourId) => {
tours.delete(tourId)
// 如果正在运行的 Tour 被注销,停止它
setState(prev => {
if (prev.activeTourId === tourId) {
return { ...prev, activeTourId: null, isRunning: false, stepIndex: 0 }
}
return prev
})
}, [tours])
const startTour = useCallback((tourId: TourId, startIndex = 0) => {
if (tours.has(tourId)) {
setState({
activeTourId: tourId,
stepIndex: startIndex,
isRunning: true,
})
}
}, [tours])
const stopTour = useCallback(() => {
setState(prev => ({
...prev,
isRunning: false,
}))
}, [])
const goToStep = useCallback((index: number) => {
setState(prev => ({
...prev,
stepIndex: index,
}))
}, [])
const nextStep = useCallback(() => {
setState(prev => ({
...prev,
stepIndex: prev.stepIndex + 1,
}))
}, [])
const prevStep = useCallback(() => {
setState(prev => ({
...prev,
stepIndex: Math.max(0, prev.stepIndex - 1),
}))
}, [])
const getCurrentSteps = useCallback((): Step[] => {
if (!state.activeTourId) return []
return tours.get(state.activeTourId) || []
}, [state.activeTourId, tours])
const markTourCompleted = useCallback((tourId: TourId) => {
setCompletedTours(prev => {
const next = new Set(prev)
next.add(tourId)
saveCompletedTours(next)
return next
})
}, [])
const handleJoyrideCallback = useCallback((data: CallBackProps) => {
const { action, index, status, type } = data
const finishedStatuses: Status[] = ['finished', 'skipped']
// 处理关闭按钮点击
if (action === 'close') {
setState(prev => ({
...prev,
isRunning: false,
stepIndex: 0,
}))
return
}
if (finishedStatuses.includes(status)) {
// Tour 完成或跳过
setState(prev => {
if (status === 'finished' && prev.activeTourId) {
// 使用 setTimeout 避免在 setState 中调用另一个 setState
setTimeout(() => markTourCompleted(prev.activeTourId!), 0)
}
return {
...prev,
isRunning: false,
stepIndex: 0,
}
})
} else if (type === 'step:after') {
// 步骤切换后更新索引
if (action === 'next') {
setState(prev => ({ ...prev, stepIndex: index + 1 }))
} else if (action === 'prev') {
setState(prev => ({ ...prev, stepIndex: index - 1 }))
}
}
}, [markTourCompleted])
const isTourCompleted = useCallback((tourId: TourId): boolean => {
return completedTours.has(tourId)
}, [completedTours])
const resetTourCompleted = useCallback((tourId: TourId) => {
setCompletedTours(prev => {
const next = new Set(prev)
next.delete(tourId)
saveCompletedTours(next)
return next
})
}, [])
return (
<TourContext
value={{
state,
tours,
registerTour,
unregisterTour,
startTour,
stopTour,
goToStep,
nextStep,
prevStep,
getCurrentSteps,
handleJoyrideCallback,
isTourCompleted,
markTourCompleted,
resetTourCompleted,
}}
>
{children}
</TourContext>
)
}

View File

@@ -0,0 +1,211 @@
import Joyride from 'react-joyride'
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTour } from './use-tour'
// Joyride 主题配置
const joyrideStyles = {
options: {
// 提到 portal 容器99999之上确保 overlay/spotlight/tooltip 都在最上层;
// overlay 的 z-index 由 react-joyride 内部基于 options.zIndex 推算,必须大于 floater 才能让 tooltip 按钮可点击。
zIndex: 100000,
primaryColor: 'hsl(var(--color-primary))',
textColor: 'hsl(var(--color-foreground))',
backgroundColor: 'hsl(var(--color-background))',
arrowColor: 'hsl(var(--color-background))',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
borderRadius: 'var(--radius)',
padding: '1rem',
},
tooltipContainer: {
textAlign: 'left' as const,
},
tooltipTitle: {
fontSize: '1rem',
fontWeight: 600,
marginBottom: '0.5rem',
},
tooltipContent: {
fontSize: '0.875rem',
padding: '0.5rem 0',
},
buttonNext: {
backgroundColor: 'hsl(var(--color-primary))',
color: 'hsl(var(--color-primary-foreground))',
borderRadius: 'calc(var(--radius) - 2px)',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
},
buttonBack: {
color: 'hsl(var(--color-muted-foreground))',
fontSize: '0.875rem',
marginRight: '0.5rem',
},
buttonSkip: {
color: 'hsl(var(--color-muted-foreground))',
fontSize: '0.875rem',
},
buttonClose: {
color: 'hsl(var(--color-muted-foreground))',
},
spotlight: {
borderRadius: 'var(--radius)',
},
}
// 中文本地化
const locale = {
back: '上一步',
close: '关闭',
last: '完成',
next: '下一步',
nextLabelWithProgress: '下一步 ({step}/{steps})',
open: '打开对话框',
skip: '跳过',
}
export function TourRenderer() {
const { state, getCurrentSteps, handleJoyrideCallback } = useTour()
const steps = getCurrentSteps()
const [targetReady, setTargetReady] = useState(false)
const prevStepIndexRef = useRef(state.stepIndex)
const cleanupRef = useRef<(() => void) | null>(null)
// 当步骤变化时,重置 targetReady 以强制重新检测和定位
useEffect(() => {
if (prevStepIndexRef.current !== state.stepIndex) {
setTargetReady(false)
prevStepIndexRef.current = state.stepIndex
}
}, [state.stepIndex])
// 等待当前步骤的目标元素出现
useEffect(() => {
if (!state.isRunning || steps.length === 0) {
setTargetReady(false)
return
}
const currentStep = steps[state.stepIndex]
if (!currentStep) {
setTargetReady(false)
return
}
const target = currentStep.target
if (target === 'body') {
setTargetReady(true)
return
}
// 重置状态
setTargetReady(false)
// 每次步骤变化时,先等待一段时间让 DOM 更新(弹窗关闭动画等)
const initialDelay = setTimeout(() => {
const checkTarget = () => {
const element = document.querySelector(target as string)
if (element) {
// 确保元素可见
const rect = element.getBoundingClientRect()
const isVisible = rect.width > 0 && rect.height > 0
if (isVisible) {
return true
}
}
return false
}
if (checkTarget()) {
// 找到元素后再等一小段时间,确保动画完成
setTimeout(() => setTargetReady(true), 100)
return
}
// 使用轮询检测元素
const intervalId = setInterval(() => {
if (checkTarget()) {
clearInterval(intervalId)
// 找到元素后再等一小段时间
setTimeout(() => setTargetReady(true), 100)
}
}, 100)
const timeout = setTimeout(() => {
clearInterval(intervalId)
// 超时后设置 targetReady 为 true让 Joyride 显示错误提示
setTargetReady(true)
}, 5000)
// 保存清理函数
const cleanup = () => {
clearInterval(intervalId)
clearTimeout(timeout)
}
// 将清理函数保存到 ref 中以便外部清理
cleanupRef.current = cleanup
}, 150) // 等待 150ms 让 DOM 更新和动画完成
return () => {
clearTimeout(initialDelay)
if (cleanupRef.current) {
cleanupRef.current()
cleanupRef.current = null
}
}
}, [state.isRunning, state.stepIndex, steps])
// 创建一个高层级的容器用于渲染 Joyride
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null)
useEffect(() => {
// 创建或获取 tour 专用容器
let container = document.getElementById('tour-portal-container') as HTMLDivElement | null
if (!container) {
container = document.createElement('div')
container.id = 'tour-portal-container'
container.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 99999; pointer-events: none;'
document.body.appendChild(container)
}
setPortalElement(container)
return () => {
// 组件卸载时不删除容器,因为可能还会再用
}
}, [])
if (!state.isRunning || steps.length === 0 || !targetReady) {
return null
}
const joyrideElement = (
<Joyride
key={`tour-step-${state.stepIndex}`}
steps={steps}
stepIndex={state.stepIndex}
run={state.isRunning}
continuous
showSkipButton
showProgress
disableOverlayClose
disableScrolling={false}
disableScrollParentFix={false}
callback={handleJoyrideCallback}
styles={joyrideStyles}
locale={locale}
scrollOffset={80}
scrollToFirstStep
/>
)
// 使用 Portal 渲染到高层容器
if (portalElement) {
return createPortal(joyrideElement, portalElement)
}
return joyrideElement
}

View File

@@ -0,0 +1,202 @@
import type { Placement, Step } from 'react-joyride'
export const MODEL_ASSIGNMENT_TOUR_ID = 'model-assignment-tour'
// Tour 步骤定义
export const modelAssignmentTourSteps: Step[] = [
{
target: 'body',
content: '本引导会帮你在同一个页面完成模型厂商、模型列表和功能分配配置。',
placement: 'center' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="providers-tab-trigger"]',
content: '第一步,进入"模型厂商设置"。这里用于配置要连接的模型服务厂商或模型平台。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="add-provider-button"]',
content: '点击"添加提供商"按钮,开始配置模型厂商的连接信息。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="provider-dialog"]',
content: '在这里可以选择厂商模板,填写 API Key、URL 和连接参数,保存后即可供模型引用。',
placement: 'left' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-name-input"]',
content: '这里的名称用于在后续模型配置中识别这个厂商。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-apikey-input"]',
content: '这里填写从模型厂商获取的 API Key用于验证并调用模型服务。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-url-input"]',
content: '这里填写模型厂商的 API 访问地址。不同厂商或平台的地址可能不同。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-template-select"]',
content: '如果不确定如何填写,可以从预设模板中选择常用厂商,相关信息会自动填充。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-save-button"]',
content: '填写完成后点击保存,模型厂商就配置好了。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="provider-cancel-button"]',
content: '这次只是演示流程,点击取消关闭厂商配置窗口。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="models-tab-trigger"]',
content: '厂商配置完成后,切换到"添加模型",把具体要使用的模型加入列表。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="add-model-button"]',
content: '点击"添加模型"按钮,开始添加一个可分配给功能的模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="model-dialog"]',
content: '在这里选择刚才配置好的厂商,并填写模型名称、标识符、价格和能力参数。',
placement: 'left' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-name-input"]',
content: '模型名称用于在任务分配时识别这个模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-provider-select"]',
content: '这里选择模型所属的厂商,系统会根据厂商配置获取或调用对应模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-identifier-input"]',
content: '这里填写模型标识符。不同厂商的模型标识符格式可能不同,请参考对应厂商文档。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-save-button"]',
content: '填写完成后点击保存,模型就会加入可用模型列表。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
{
target: '[data-tour="model-cancel-button"]',
content: '这次只是演示流程,点击取消关闭模型配置窗口。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="tasks-tab-trigger"]',
content: '最后切换到"为模型分配功能",为麦麦的各个组件选择合适的模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
{
target: '[data-tour="task-model-select"]',
content: '在这里可以为每个组件选择一个或多个模型,选择完成后配置会自动保存。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
]
// 需要用户点击才能继续的步骤索引0-based
export const CLICK_TO_CONTINUE_STEPS = new Set([1, 2, 9, 10, 11, 17, 18])
// 合并后所有步骤都在模型管理与分配页面内完成
export const STEP_ROUTE_MAP: Record<number, string> = Object.fromEntries(
modelAssignmentTourSteps.map((_, index) => [index, '/config/model'])
)

View File

@@ -0,0 +1,49 @@
import type { Step, CallBackProps } from 'react-joyride'
// Tour ID 类型,用于区分不同的引导流程
export type TourId = string
export interface TourState {
// 当前激活的 Tour ID
activeTourId: TourId | null
// 当前步骤索引
stepIndex: number
// Tour 是否正在运行
isRunning: boolean
}
export interface TourContextType {
// 状态
state: TourState
// 注册的所有 Tour 步骤
tours: Map<TourId, Step[]>
// 注册一个 Tour
registerTour: (tourId: TourId, steps: Step[]) => void
// 注销一个 Tour
unregisterTour: (tourId: TourId) => void
// 开始一个 Tour
startTour: (tourId: TourId, startIndex?: number) => void
// 停止当前 Tour
stopTour: () => void
// 跳转到指定步骤
goToStep: (index: number) => void
// 下一步
nextStep: () => void
// 上一步
prevStep: () => void
// 获取当前 Tour 的步骤
getCurrentSteps: () => Step[]
// Joyride 回调处理
handleJoyrideCallback: (data: CallBackProps) => void
// 检查用户是否已完成某个 Tour
isTourCompleted: (tourId: TourId) => boolean
// 标记 Tour 已完成
markTourCompleted: (tourId: TourId) => void
// 重置 Tour 完成状态
resetTourCompleted: (tourId: TourId) => void
}

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { TourContext } from './tour-context'
export function useTour() {
const context = useContext(TourContext)
if (!context) {
throw new Error('useTour must be used within a TourProvider')
}
return context
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
}
>(({ className, variant, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant }), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/heading-has-content -- content passed via spread props
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react'
import { createContext, useCallback, useContext, useRef, useState } from 'react'
type Politeness = 'polite' | 'assertive'
interface AnnouncerContextValue {
announce: (message: string, politeness?: Politeness) => void
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null)
/**
* useAnnounce — 向屏幕阅读器播报消息
*
* @example
* const announce = useAnnounce()
* announce('保存成功') // polite默认
* announce('操作失败,请重试', 'assertive') // assertive立即打断
*/
export function useAnnounce(): (message: string, politeness?: Politeness) => void {
const ctx = useContext(AnnouncerContext)
if (!ctx) {
// 未在 AnnouncerProvider 内时静默降级,不抛错
return () => {}
}
return ctx.announce
}
interface AnnouncerState {
polite: string
assertive: string
}
/**
* AnnouncerProvider — 在应用根部挂载两个 aria-live 区域
*
* 将此组件包裹在应用根节点,所有子组件即可通过 useAnnounce() 播报消息。
* aria-live 区域视觉上隐藏sr-only不影响布局。
*/
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<AnnouncerState>({ polite: '', assertive: '' })
// 用于清空 -> 重新设置,触发屏幕阅读器重新朗读相同消息
const politeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const assertiveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const announce = useCallback((message: string, politeness: Politeness = 'polite') => {
if (politeness === 'assertive') {
// 先清空,再填入,确保屏幕阅读器重新朗读
setMessages((prev: AnnouncerState) => ({ ...prev, assertive: '' }))
if (assertiveTimerRef.current) clearTimeout(assertiveTimerRef.current)
assertiveTimerRef.current = setTimeout(() => {
setMessages((prev: AnnouncerState) => ({ ...prev, assertive: message }))
}, 50)
} else {
setMessages((prev: AnnouncerState) => ({ ...prev, polite: '' }))
if (politeTimerRef.current) clearTimeout(politeTimerRef.current)
politeTimerRef.current = setTimeout(() => {
setMessages((prev: AnnouncerState) => ({ ...prev, polite: message }))
}, 50)
}
}, [])
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* aria-live 区域:视觉隐藏,屏幕阅读器可读 */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{messages.polite}
</div>
<div
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{messages.assertive}
</div>
</AnnouncerContext.Provider>
)
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,37 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@@ -0,0 +1,211 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,29 @@
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { BackgroundLayer } from '@/components/background-layer'
import { Card } from '@/components/ui/card'
import { useBackground } from '@/hooks/use-background'
type CardWithBackgroundProps = ComponentPropsWithoutRef<typeof Card>
export const CardWithBackground = forwardRef<
ElementRef<typeof Card>,
CardWithBackgroundProps
>(({ className, children, ...props }, ref) => {
const { config: bg } = useBackground('card')
return (
<Card ref={ref} className={cn('relative isolate', className)} {...props}>
<BackgroundLayer config={bg} layerId="card" />
<div className="relative z-10">
{children}
</div>
</Card>
)
})
CardWithBackground.displayName = 'CardWithBackground'

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,378 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
type ChartTooltipContentProps = React.ComponentProps<"div"> & {
active?: boolean
payload?: any[]
label?: string
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
labelFormatter?: (label: any, payload: any[]) => React.ReactNode
formatter?: (value: any, name: string, item: any, index: number, payload?: any) => React.ReactNode
color?: string
labelClassName?: string
}
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
ChartTooltipContentProps
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item: any) => item.type !== "none")
.map((item: any, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
type ChartLegendContentProps = React.ComponentProps<"div"> & {
payload?: any[]
verticalAlign?: "top" | "bottom"
hideIcon?: boolean
nameKey?: string
}
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
ChartLegendContentProps
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item: any) => item.type !== "none")
.map((item: any) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 cursor-pointer rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,152 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,197 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,29 @@
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { BackgroundLayer } from '@/components/background-layer'
import { DialogContent } from '@/components/ui/dialog'
import { useBackground } from '@/hooks/use-background'
type DialogContentWithBackgroundProps = ComponentPropsWithoutRef<typeof DialogContent>
export const DialogContentWithBackground = forwardRef<
ElementRef<typeof DialogContent>,
DialogContentWithBackgroundProps
>(({ className, children, ...props }, ref) => {
const { config: bg } = useBackground('dialog')
return (
<DialogContent ref={ref} className={cn('relative isolate', className)} {...props}>
<BackgroundLayer config={bg} layerId="dialog" />
<div className="relative z-10">
{children}
</div>
</DialogContent>
)
})
DialogContentWithBackground.displayName = 'DialogContentWithBackground'

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
import { isEditableTarget, matchesShortcut } from "@/lib/keyboard"
import { ScrollArea } from "@/components/ui/scroll-area"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/** 阻止点击外部关闭(用于 Tour 运行时) */
preventOutsideClose?: boolean
/** 隐藏默认关闭按钮(当使用自定义关闭按钮时) */
hideCloseButton?: boolean
/** 回车触发主操作按钮 */
confirmOnEnter?: boolean
}
interface DialogBodyProps extends React.ComponentPropsWithoutRef<typeof ScrollArea> {
allowHorizontalScroll?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, confirmOnEnter = false, onKeyDownCapture, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 flex w-[min(calc(100vw-2rem),var(--dialog-width,32rem))] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-hidden border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onKeyDownCapture={(event) => {
onKeyDownCapture?.(event)
if (
!confirmOnEnter ||
event.defaultPrevented ||
!matchesShortcut(event, ['enter']) ||
event.nativeEvent.isComposing ||
isEditableTarget(event.target)
) {
return
}
const confirmButton = event.currentTarget.querySelector<HTMLElement>('[data-dialog-action="confirm"]:not([disabled])')
if (!confirmButton) {
return
}
event.preventDefault()
confirmButton.click()
}}
{...props}
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogBody = React.forwardRef<HTMLDivElement, DialogBodyProps>(
({ className, children, allowHorizontalScroll = false, contentClassName, scrollbars, viewportClassName, type, ...props }, ref) => (
// 关键:在 flex-col 的 DialogContent 中DialogBody 既要在内容多时撑到 max-h 上限并滚动,
// 又要在内容少时让 dialog 自然收缩。直接在 ScrollArea Root 上 flex-1 + min-h-0 即可:
// Radix Viewport 内部 wrapper 默认 display:table 会撑开自然高度,所以需要强制 block。
<ScrollArea
ref={ref as never}
className={cn("min-h-0 flex-1 flex flex-col", className)}
contentClassName={cn(allowHorizontalScroll && "min-w-full w-max", contentClassName)}
scrollbars={scrollbars ?? (allowHorizontalScroll ? "both" : "vertical")}
viewportClassName={cn("min-h-0 flex-1 pr-4 [&>div]:!block", viewportClassName)}
type={type ?? "always"}
{...props}
>
{children}
</ScrollArea>
)
)
DialogBody.displayName = "DialogBody"
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogDescription,
}

Some files were not shown because too many files have changed in this diff Show More