Compare commits

...

18 Commits

Author SHA1 Message Date
Lofu b88e67ce8e
Merge a867b44867 into 78d853fc47 2026-01-13 21:44:09 +08:00
SengokuCola 78d853fc47 Merge branch 'dev' of https://github.com/Mai-with-u/MaiBot into dev 2026-01-13 13:15:42 +08:00
SengokuCola 84418ecfa3 feat:将theme和原始内容移除出lpmm,不分段 2026-01-13 13:15:19 +08:00
墨梓柒 ffafbf0a26
WebUI后端整体重构 2026-01-13 07:24:27 +08:00
墨梓柒 812296590e
上传完整的WebUI前端仓库 2026-01-13 06:24:35 +08:00
墨梓柒 a9187dc312
feat: 添加log_viewer到.gitignore 2026-01-13 06:18:27 +08:00
墨梓柒 25b405d191
移除对log_viewer的git追踪 2026-01-13 06:18:07 +08:00
SengokuCola f052340d21 Merge branch 'dev' of https://github.com/Mai-with-u/MaiBot into dev 2026-01-13 00:47:58 +08:00
SengokuCola 199a8a7dff feat:添加lpmm内部接口,信息抽取类和一个测试脚本 2026-01-13 00:47:55 +08:00
SengokuCola 523a7517a9 feat:planner加入黑话缓存 2026-01-13 00:47:33 +08:00
SengokuCola 465fb9d865 remove:移除记忆的 关键点 项目 2026-01-13 00:47:22 +08:00
墨梓柒 056b4df2dd
WebUI b2a259fbc4c8477f2ab01eb5e7a75969cda53a1e 2026-01-13 00:43:55 +08:00
墨梓柒 37589ebdfb
feat: 添加段落内容加载功能及相关配置 2026-01-13 00:42:49 +08:00
SengokuCola 0debe0efcf log:优化模型报错log 2026-01-12 19:05:30 +08:00
SengokuCola ccc9af57b8 ref:记忆检索不再基于问题,而是基于上下文;添加一些调试用配置 2026-01-12 19:05:07 +08:00
SengokuCola 87d4c7c38a remove:移除Planner问题配置项 2026-01-12 00:37:50 +08:00
Ronifue a867b44867 fix: 重构,避免反复创建和销毁内核对象 2025-12-24 12:06:44 +08:00
Ronifue 5b38423a89 fix: 修复了当 Runner 进程意外退出时残留 Worker 孤儿进程的问题 2025-12-24 11:56:49 +08:00
248 changed files with 50240 additions and 2245 deletions

24
.gitignore vendored
View File

@ -46,8 +46,30 @@ config/lpmm_config.toml.bak
template/compare/bot_config_template.toml
template/compare/model_config_template.toml
CLAUDE.md
MaiBot-Dashboard/
cloudflare-workers/
log_viewer/
dev/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules/
dist/
dist-ssr/
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
result.json
# Byte-compiled / optimized / DLL files
__pycache__/

58
bot.py
View File

@ -56,6 +56,7 @@ def run_runner_process():
# 设置环境变量,标记子进程为 Worker 进程
env = os.environ.copy()
env["MAIBOT_WORKER_PROCESS"] = "1"
env["MAIBOT_RUNNER_PID"] = str(os.getpid()) # 传递 Runner PID 供 Worker 监控
while True:
logger.info(f"正在启动 {script_file}...")
@ -175,6 +176,60 @@ def easter_egg():
print(rainbow_text)
def _start_parent_monitor():
"""启动父进程存活监控守护线程,检测到 Runner 终止后触发优雅退出"""
import ctypes
import signal
import threading
try:
runner_pid = int(os.environ.get("MAIBOT_RUNNER_PID", "0"))
except (ValueError, TypeError):
return
if not runner_pid:
return
def is_alive_unix(pid):
return os.getppid() == pid
def trigger_exit():
# Logger 容错:解释器关闭阶段 Logger 可能已被销毁
try:
get_logger("main").warning("检测到 Runner 进程已终止,正在触发优雅退出...")
except Exception:
print("[ParentMonitor] 检测到 Runner 进程已终止,正在触发优雅退出...")
signal.raise_signal(signal.SIGINT) # 触发 KeyboardInterrupt走正常关闭流程
def monitor():
if platform.system() == "Windows":
# Windows: 循环外获取句柄,循环内只检查退出码,减少系统调用
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, runner_pid)
if not handle:
# 进程已不存在或无权限访问,直接触发退出
return trigger_exit()
try:
exit_code = ctypes.c_ulong()
while True:
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
break # API 调用失败,假定进程已退出
if exit_code.value != STILL_ACTIVE:
break # 进程已退出
time.sleep(2)
finally:
kernel32.CloseHandle(handle)
else:
# Unix: 检测 ppid 是否变化
while is_alive_unix(runner_pid):
time.sleep(2)
trigger_exit()
threading.Thread(target=monitor, daemon=True, name="ParentMonitor").start()
async def graceful_shutdown(): # sourcery skip: use-named-expression
try:
logger.info("正在优雅关闭麦麦...")
@ -322,6 +377,9 @@ def raw_main():
if __name__ == "__main__":
exit_code = 0 # 用于记录程序最终的退出状态
try:
# 启动父进程存活监控Runner 异常退出时自动触发优雅关闭)
_start_parent_monitor()
# 获取MainSystem实例
main_system = raw_main()

1
dashboard/.gitignore vendored 100644
View File

@ -0,0 +1 @@

View File

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

661
dashboard/LICENSE 100644
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 100644
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-3.4-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 3.4 # 样式框架
├── 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 配置
├── 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 | ^3.4 | 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>

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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

View File

@ -0,0 +1,35 @@
import js from '@eslint/js'
import globals from 'globals'
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'] },
{
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': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
},
},
)

View File

@ -0,0 +1,19 @@
<!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>
</head>
<body>
<div id="root" class="notranslate"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,95 @@
{
"name": "maibot-dashboard",
"private": true,
"version": "0.11.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
},
"dependencies": {
"@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",
"@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",
"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",
"jotai": "^2.16.0",
"katex": "^0.16.27",
"lucide-react": "^0.556.0",
"react": "^19.2.1",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.1",
"react-joyride": "^2.9.3",
"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": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3",
"typescript": "~5.9.3",
"typescript-eslint": "^8.49.0",
"vite": "^7.2.7"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

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,126 @@
import { useEffect, useState } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { python } from '@codemirror/lang-python'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import { StreamLanguage } from '@codemirror/language'
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
export type Language = 'python' | 'json' | 'toml' | 'text'
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
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), jsonParseLinter()],
toml: [StreamLanguage.define(tomlMode)],
text: [],
}
export function CodeEditor({
value,
onChange,
language = 'text',
readOnly = false,
height = '400px',
minHeight,
maxHeight,
placeholder,
theme = 'dark',
className = '',
}: CodeEditorProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<div
className={`rounded-md border bg-muted animate-pulse ${className}`}
style={{ height, minHeight, maxHeight }}
/>
)
}
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))
}
return (
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
<CodeMirror
value={value}
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
theme={theme === '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>
)
}
export default CodeEditor

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.Provider value={value}>{children}</AnimationContext.Provider>
}

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,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,307 @@
import { Component } from 'react'
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 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" />
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
)
}
// 错误回退 UI
function ErrorFallback({
error,
errorInfo,
}: {
error: Error
errorInfo: ErrorInfo | null
}) {
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"></CardTitle>
<CardDescription className="text-base mt-2">
</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" />
</Button>
<Button onClick={handleGoHome} variant="outline" className="flex-1">
<Home className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 提示信息 */}
<p className="text-xs text-center text-muted-foreground pt-2">
</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,59 @@
import { useState } from 'react'
import { AlertTriangle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* HTTP
* HTTP 访
*/
export function HttpWarningBanner() {
// 直接计算初始状态,避免 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"></span>
使 <strong>HTTP</strong> 访 MaiBot WebUI
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
Token使 HTTPS 访使
</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="关闭警告"
>
<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,409 @@
import { Menu, Moon, Sun, ChevronLeft, Home, Settings, LogOut, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, Package, BookOpen, Search, Sliders, Network, Hash, LayoutGrid, Database, Activity, PieChart } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Link, useMatchRoute } from '@tanstack/react-router'
import { useTheme, toggleThemeWithTransition } from './use-theme'
import { useAuthGuard } from '@/hooks/use-auth'
import { logout } from '@/lib/fetch-with-auth'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import { SearchDialog } from '@/components/search-dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { HttpWarningBanner } from '@/components/http-warning-banner'
import { BackToTop } from '@/components/back-to-top'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { formatVersion } from '@/lib/version'
import type { ReactNode, ComponentType } from 'react'
import type { LucideProps } from 'lucide-react'
interface LayoutProps {
children: ReactNode
}
interface MenuItem {
icon: ComponentType<LucideProps>
label: string
path: string
tourId?: string
}
interface MenuSection {
title: string
items: MenuItem[]
}
export function Layout({ children }: LayoutProps) {
const { checking } = useAuthGuard() // 检查认证状态
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()
const matchRoute = useMatchRoute()
// 侧边栏状态变化时,延迟启用/禁用 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 ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setSearchOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 认证检查中,显示加载状态
if (checking) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-muted-foreground">...</div>
</div>
)
}
// 菜单项配置 - 分块结构
const menuSections: MenuSection[] = [
{
title: '概览',
items: [
{ icon: Home, label: '首页', path: '/' },
],
},
{
title: '麦麦配置编辑',
items: [
{ icon: FileText, label: '麦麦主程序配置', path: '/config/bot' },
{ icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' },
{ icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' },
],
},
{
title: '麦麦资源管理',
items: [
{ icon: Smile, label: '表情包管理', path: '/resource/emoji' },
{ icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' },
{ icon: Hash, label: '黑话管理', path: '/resource/jargon' },
{ icon: UserCircle, label: '人物信息管理', path: '/resource/person' },
{ icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' },
{ icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' },
],
},
{
title: '扩展与监控',
items: [
{ icon: Package, label: '插件市场', path: '/plugins' },
{ icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' },
{ icon: Sliders, label: '插件配置', path: '/plugin-config' },
{ icon: FileSearch, label: '日志查看器', path: '/logs' },
{ icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' },
{ icon: MessageSquare, label: '本地聊天室', path: '/chat' },
],
},
{
title: '系统',
items: [
{ icon: Settings, label: '系统设置', path: '/settings' },
],
},
]
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
}
const actualTheme = getActualTheme()
// 登出处理
const handleLogout = async () => {
await logout()
}
return (
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
'w-64 lg:w-auto',
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
{/* Logo 区域 */}
<div className="flex h-16 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 items-baseline gap-2",
!sidebarOpen && "lg:hidden"
)}>
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
<span className="text-xs text-primary/60 whitespace-nowrap">
{formatVersion()}
</span>
</div>
{/* 折叠时的 Logo - 仅桌面端显示 */}
{!sidebarOpen && (
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
)}
</div>
</div>
<ScrollArea className={cn(
"flex-1 overflow-x-hidden",
!sidebarOpen && "lg:w-16"
)}>
<nav 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">
{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) => {
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'
)}>
{item.label}
</span>
</div>
</>
)
return (
<li key={item.path} 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={() => setMobileMenuOpen(false)}
>
{menuItemContent}
</Link>
</TooltipTrigger>
{tooltipsEnabled && (
<TooltipContent side="right" className="hidden lg:block">
<p>{item.label}</p>
</TooltipContent>
)}
</Tooltip>
</li>
)
})}
</ul>
</li>
))}
</ul>
</nav>
</ScrollArea>
</aside>
{/* Mobile overlay */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
<HttpWarningBanner />
{/* Topbar */}
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
<div className="flex items-center gap-4">
{/* 移动端菜单按钮 */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="rounded-lg p-2 hover:bg-accent lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
{/* 桌面端侧边栏收起/展开按钮 */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
>
<ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
/>
</button>
</div>
<div className="flex items-center gap-2">
{/* 年度总结入口 */}
<Link to="/annual-report">
<Button
variant="ghost"
size="sm"
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
title="查看年度总结"
>
<PieChart className="h-4 w-4 text-pink-500" />
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 </span>
</Button>
</Link>
{/* 搜索框 */}
<button
onClick={() => setSearchOpen(true)}
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<span className="text-sm text-muted-foreground">...</span>
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
<span className="text-xs"></span>K
</Kbd>
</button>
{/* 搜索对话框 */}
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
{/* 麦麦文档链接 */}
<Button
variant="ghost"
size="sm"
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
className="gap-2"
title="查看麦麦文档"
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
{/* 主题切换按钮 */}
<button
onClick={(e) => {
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
toggleThemeWithTransition(newTheme, setTheme, e)
}}
className="rounded-lg p-2 hover:bg-accent"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
>
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* 分隔线 */}
<div className="h-6 w-px bg-border" />
{/* 登出按钮 */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="gap-2"
title="登出系统"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-hidden bg-background">{children}</main>
{/* Back to Top Button */}
<BackToTop />
</div>
</div>
</TooltipProvider>
)
}

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,302 @@
/**
*
*
*/
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 className="text-sm font-medium mb-2 block"></label>
<Textarea
value={userComment}
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,412 @@
/**
*
*
*
*
* 使 1: RestartProvider
* <RestartProvider>
* <App />
* <RestartOverlay />
* </RestartProvider>
*
* 使 2: 使
* <RestartOverlay
* visible={true}
* onComplete={() => navigate('/auth')}
* />
*/
import { useEffect, useState, useCallback } from 'react'
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,
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 ?? '准备重启',
description: customDescription ?? '正在发送重启请求...',
tip: '🔄 正在准备重启麦麦...',
},
restarting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? '正在重启麦麦',
description: customDescription ?? '请稍候,麦麦正在重启中...',
tip: '🔄 配置已保存,正在重启主程序...',
},
checking: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: '检查服务状态',
description: `等待服务恢复... (${checkAttempts}/${maxAttempts})`,
tip: '⏳ 正在等待服务恢复,请勿关闭页面...',
},
success: {
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
title: '重启成功',
description: '正在跳转到登录页面...',
tip: '✅ 配置已生效,服务运行正常',
},
failed: {
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
title: '重启超时',
description: '服务未能在预期时间内恢复',
tip: '⚠️ 如果长时间无响应,请尝试手动重启',
},
}
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
// 回调处理
useEffect(() => {
if (status === 'success' && onComplete) {
onComplete()
} else if (status === 'failed' && onFailed) {
onFailed()
}
}, [status, onComplete, onFailed])
const config = getStatusConfig(
status,
checkAttempts,
maxAttempts,
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>: {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" />
</Button>
<Button onClick={onRetry} variant="secondary" className="flex-1">
<RotateCcw className="mr-2 h-4 w-4" />
</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,237 @@
import { useState, useCallback } from 'react'
import { Search, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, BarChart3, Package, Settings, Home, Hash } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface SearchItem {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
path: string
category: string
}
const searchItems: SearchItem[] = [
{
icon: Home,
title: '首页',
description: '查看仪表板概览',
path: '/',
category: '概览',
},
{
icon: FileText,
title: '麦麦主程序配置',
description: '配置麦麦的核心设置',
path: '/config/bot',
category: '配置',
},
{
icon: Server,
title: '麦麦模型提供商配置',
description: '配置模型提供商',
path: '/config/modelProvider',
category: '配置',
},
{
icon: Boxes,
title: '麦麦模型配置',
description: '配置模型参数',
path: '/config/model',
category: '配置',
},
{
icon: Smile,
title: '表情包管理',
description: '管理麦麦的表情包',
path: '/resource/emoji',
category: '资源',
},
{
icon: MessageSquare,
title: '表达方式管理',
description: '管理麦麦的表达方式',
path: '/resource/expression',
category: '资源',
},
{
icon: UserCircle,
title: '人物信息管理',
description: '管理人物信息',
path: '/resource/person',
category: '资源',
},
{
icon: Hash,
title: '黑话管理',
description: '管理麦麦学习到的黑话和俚语',
path: '/resource/jargon',
category: '资源',
},
{
icon: BarChart3,
title: '统计信息',
description: '查看使用统计',
path: '/statistics',
category: '监控',
},
{
icon: Package,
title: '插件市场',
description: '浏览和安装插件',
path: '/plugins',
category: '扩展',
},
{
icon: FileSearch,
title: '日志查看器',
description: '查看系统日志',
path: '/logs',
category: '监控',
},
{
icon: Settings,
title: '系统设置',
description: '配置系统参数',
path: '/settings',
category: '系统',
},
]
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const navigate = useNavigate()
// 过滤搜索结果
const filteredItems = searchItems.filter(
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.category.toLowerCase().includes(searchQuery.toLowerCase())
)
// 导航到页面
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()
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
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">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="sr-only"></DialogTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setSelectedIndex(0)
}}
onKeyDown={handleKeyDown}
placeholder="搜索页面..."
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
autoFocus
/>
</div>
</DialogHeader>
<div className="border-t">
<ScrollArea className="h-[400px]">
{filteredItems.length > 0 ? (
<div className="p-2">
{filteredItems.map((item, index) => {
const Icon = item.icon
return (
<button
key={item.path}
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 flex-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 ? '未找到匹配的页面' : '输入关键词开始搜索'}
</p>
</div>
)}
</ScrollArea>
</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">
<kbd className="px-1.5 py-0.5 bg-muted rounded border"></kbd>
<kbd className="px-1.5 py-0.5 bg-muted rounded border"></kbd>
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Enter</kbd>
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Esc</kbd>
</span>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,684 @@
/**
* 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,
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 { ScrollArea } from '@/components/ui/scroll-area'
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 max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
{step} / {totalSteps}
{step === 1 && '选择要分享的配置'}
{step === 2 && '填写模板信息'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[calc(85vh-220px)] pr-4">
{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>
)}
</>
)}
</ScrollArea>
<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
onClick={() => setStep(step + 1)}
disabled={
loading ||
(selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0)
}
>
</Button>
) : (
<Button 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,139 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { ThemeProviderContext } from '@/lib/theme-context'
type Theme = 'dark' | 'light' | 'system'
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
storageKey?: string
}
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
// 应用保存的主题色
useEffect(() => {
const savedAccentColor = localStorage.getItem('accent-color')
if (savedAccentColor) {
const root = document.documentElement
const colors = {
blue: {
hsl: '221.2 83.2% 53.3%',
darkHsl: '217.2 91.2% 59.8%',
gradient: null
},
purple: {
hsl: '271 91% 65%',
darkHsl: '270 95% 75%',
gradient: null
},
green: {
hsl: '142 71% 45%',
darkHsl: '142 76% 36%',
gradient: null
},
orange: {
hsl: '25 95% 53%',
darkHsl: '20 90% 48%',
gradient: null
},
pink: {
hsl: '330 81% 60%',
darkHsl: '330 85% 70%',
gradient: null
},
red: {
hsl: '0 84% 60%',
darkHsl: '0 90% 70%',
gradient: null
},
// 渐变色
'gradient-sunset': {
hsl: '15 95% 60%',
darkHsl: '15 95% 65%',
gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)'
},
'gradient-ocean': {
hsl: '200 90% 55%',
darkHsl: '200 90% 60%',
gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)'
},
'gradient-forest': {
hsl: '150 70% 45%',
darkHsl: '150 75% 40%',
gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)'
},
'gradient-aurora': {
hsl: '310 85% 65%',
darkHsl: '310 90% 70%',
gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)'
},
'gradient-fire': {
hsl: '15 95% 55%',
darkHsl: '15 95% 60%',
gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)'
},
'gradient-twilight': {
hsl: '250 90% 60%',
darkHsl: '250 95% 65%',
gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)'
},
}
const selectedColor = colors[savedAccentColor as keyof typeof colors]
if (selectedColor) {
root.style.setProperty('--primary', selectedColor.hsl)
// 设置渐变(如果有)
if (selectedColor.gradient) {
root.style.setProperty('--primary-gradient', selectedColor.gradient)
root.classList.add('has-gradient')
} else {
root.style.removeProperty('--primary-gradient')
root.classList.remove('has-gradient')
}
}
}
}, [])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}

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.Provider
value={{
state,
tours,
registerTour,
unregisterTour,
startTour,
stopTour,
goToStep,
nextStep,
prevStep,
getCurrentSteps,
handleJoyrideCallback,
isTourCompleted,
markTourCompleted,
resetTourCompleted,
}}
>
{children}
</TourContext.Provider>
)
}

View File

@ -0,0 +1,217 @@
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: {
zIndex: 10000,
primaryColor: 'hsl(var(--primary))',
textColor: 'hsl(var(--foreground))',
backgroundColor: 'hsl(var(--background))',
arrowColor: 'hsl(var(--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(--primary))',
color: 'hsl(var(--primary-foreground))',
borderRadius: 'calc(var(--radius) - 2px)',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
},
buttonBack: {
color: 'hsl(var(--muted-foreground))',
fontSize: '0.875rem',
marginRight: '0.5rem',
},
buttonSkip: {
color: 'hsl(var(--muted-foreground))',
fontSize: '0.875rem',
},
buttonClose: {
color: 'hsl(var(--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
floaterProps={{
styles: {
floater: {
zIndex: 99999,
},
},
disableAnimation: true,
}}
/>
)
// 使用 Portal 渲染到高层容器
if (portalElement) {
return createPortal(joyrideElement, portalElement)
}
return joyrideElement
}

View File

@ -0,0 +1,244 @@
import type { Step, Placement } from 'react-joyride'
export const MODEL_ASSIGNMENT_TOUR_ID = 'model-assignment-tour'
// Tour 步骤定义
export const modelAssignmentTourSteps: Step[] = [
// Step 1: 全屏介绍
{
target: 'body',
content: '本引导旨在帮助你配置模型提供商和对应的模型,并为麦麦的各个组件分配合适的模型。',
placement: 'center' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 2: 侧边栏 - 模型提供商按钮(点击下一步会自动导航)
{
target: '[data-tour="sidebar-model-provider"]',
content: '第一步,你需要配置模型提供商。模型提供商决定了你要使用谁家的模型,无论是单一厂商(如 DeepSeek还是模型平台如 Siliconflow都可以在这里进行配置。点击"下一步"进入配置页面。',
placement: 'right' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 3: 添加提供商按钮
{
target: '[data-tour="add-provider-button"]',
content: '点击"添加提供商"按钮,开始配置你的模型提供商。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
// Step 4: 添加提供商弹窗
{
target: '[data-tour="provider-dialog"]',
content: '在这里,你可以选择你想要配置的模型提供商,填写相关信息后保存即可。',
placement: 'left' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 5: 名称输入框
{
target: '[data-tour="provider-name-input"]',
content: '这里的名称是你为这个模型提供商起的一个名字,方便你在后续使用时识别它。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 6: API 密钥输入框
{
target: '[data-tour="provider-apikey-input"]',
content: '这里需要填写你从模型提供商那里获取的 API 密钥,用于验证和调用模型服务。对于不同的提供商,获取 API 密钥的方式可能有所不同,请参考对应提供商的文档。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 7: URL 输入框
{
target: '[data-tour="provider-url-input"]',
content: '这里需要填写模型提供商的 API 访问地址确保填写正确以便系统能够连接到模型服务。对于不同的提供商API 地址可能有所不同,请参考对应提供商的文档。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 8: 模板选择下拉框
{
target: '[data-tour="provider-template-select"]',
content: '当然,如果你不知道如何填写这些信息,很多模型提供商在这里都提供了预设的模板供你选择,选择对应的模板后,相关信息会自动填充。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 9: 保存按钮
{
target: '[data-tour="provider-save-button"]',
content: '填写完所有信息后,点击保存按钮,模型提供商就配置完成了。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 10: 取消按钮
{
target: '[data-tour="provider-cancel-button"]',
content: '因为这次咱们什么都没有填写,所以点击取消按钮退出吧。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
// Step 11: 侧边栏 - 模型管理与分配按钮(点击下一步会自动导航)
{
target: '[data-tour="sidebar-model-management"]',
content: '配置好模型提供商后,接下来我们需要为麦麦添加模型并分配功能。点击"下一步"进入模型管理页面。',
placement: 'right' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 12: 添加模型按钮
{
target: '[data-tour="add-model-button"]',
content: '在为麦麦的组件分配模型之前,首先需要添加你想要分配的模型,点击"添加模型"按钮开始添加。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
// Step 13: 添加模型弹窗
{
target: '[data-tour="model-dialog"]',
content: '在这里,你可以选择你之前配置好的模型提供商,然后选择对应的模型来添加。',
placement: 'left' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 14: 模型名称输入框
{
target: '[data-tour="model-name-input"]',
content: '这里的模型名称是你为这个模型起的一个名字,方便你在后续使用时识别它。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 15: API 提供商下拉框
{
target: '[data-tour="model-provider-select"]',
content: '在这里选择你之前配置好的模型提供商,这样系统才能知道你要添加哪个提供商的模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 16: 模型标识符输入框
{
target: '[data-tour="model-identifier-input"]',
content: '这里需要填写你想要添加的模型的标识符,不同的模型提供商可能有不同的标识符格式,请参考对应提供商的文档。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 17: 保存按钮
{
target: '[data-tour="model-save-button"]',
content: '填写完所有信息后,点击保存按钮,模型就添加完成了。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
// Step 18: 取消按钮
{
target: '[data-tour="model-cancel-button"]',
content: '当然,因为这次咱们什么都没有填写,所以直接点击取消按钮退出吧,等你准备好了再来添加模型。',
placement: 'top' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
// Step 19: 为模型分配功能标签页
{
target: '[data-tour="tasks-tab-trigger"]',
content: '最后一步,添加好模型后,切换到"为模型分配功能"标签页,为麦麦的各个组件分配合适的模型。',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: true,
hideFooter: true,
},
// Step 20: 组件模型卡片的模型选择
{
target: '[data-tour="task-model-select"]',
content: '在这里,你可以为每个组件选择一个或多个合适的模型,选择完成后配置会自动保存。恭喜你完成了模型配置的学习!',
placement: 'bottom' as Placement,
disableBeacon: true,
disableOverlayClose: true,
hideCloseButton: false,
spotlightClicks: false,
},
]
// 需要用户点击才能继续的步骤索引0-based
// Step 2 (index 2): 点击添加提供商按钮
// Step 9 (index 9): 点击取消按钮关闭提供商弹窗
// Step 11 (index 11): 点击添加模型按钮
// Step 17 (index 17): 点击取消按钮关闭模型弹窗
// Step 18 (index 18): 点击标签页切换
export const CLICK_TO_CONTINUE_STEPS = new Set([2, 9, 11, 17, 18])
// 步骤与路由的映射
export const STEP_ROUTE_MAP: Record<number, string> = {
0: '/config/model', // 起始页面
1: '/config/model', // 侧边栏可见
2: '/config/modelProvider', // 需要在模型提供商页面
3: '/config/modelProvider',
4: '/config/modelProvider',
5: '/config/modelProvider',
6: '/config/modelProvider',
7: '/config/modelProvider',
8: '/config/modelProvider',
9: '/config/modelProvider',
10: '/config/modelProvider',
11: '/config/model', // 需要在模型管理页面
12: '/config/model',
13: '/config/model',
14: '/config/model',
15: '/config/model',
16: '/config/model',
17: '/config/model',
18: '/config/model',
19: '/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,59 @@
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) => (
<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,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 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,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.Provider 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.Provider>
)
})
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 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-default 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,131 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
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
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.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
)}
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
{...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">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
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,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.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 data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.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",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.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 transition-colors 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">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.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 transition-colors 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">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,76 @@
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { KeyValueEditor } from "@/components/ui/key-value-editor"
interface ExtraParamsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
}
export function ExtraParamsDialog({
open,
onOpenChange,
value,
onChange,
}: ExtraParamsDialogProps) {
const [editingValue, setEditingValue] = useState<Record<string, unknown>>(value)
// 当对话框打开状态改变时的处理
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
// 打开时同步最新的 value
setEditingValue(value)
}
onOpenChange(newOpen)
}
const handleSave = () => {
onChange(editingValue)
onOpenChange(false)
}
const handleCancel = () => {
setEditingValue(value) // 恢复原始值
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<KeyValueEditor
value={editingValue}
onChange={setEditingValue}
placeholder="添加额外参数(如 thinking、top_p 等)..."
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,63 @@
import * as React from "react"
import { HelpCircle } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface HelpTooltipProps {
content: React.ReactNode
className?: string
iconClassName?: string
side?: "top" | "right" | "bottom" | "left"
align?: "start" | "center" | "end"
maxWidth?: string
}
export function HelpTooltip({
content,
className,
iconClassName,
side = "top",
align = "center",
maxWidth = "300px",
}: HelpTooltipProps) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground",
"transition-colors cursor-help",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
onClick={(e) => e.preventDefault()}
>
<HelpCircle className={cn("h-4 w-4", iconClassName)} />
<span className="sr-only"></span>
</button>
</TooltipTrigger>
<TooltipContent
side={side}
align={align}
className={cn(
"max-w-[var(--max-width)] text-sm leading-relaxed",
"bg-background text-foreground",
"border-2 border-primary shadow-lg",
"p-4"
)}
style={{ "--max-width": maxWidth } as React.CSSProperties}
>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const kbdVariants = cva(
"pointer-events-none inline-flex select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono font-medium opacity-100",
{
variants: {
size: {
sm: "h-5 text-[10px]",
default: "h-6 text-xs",
lg: "h-7 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
export interface KbdProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof kbdVariants> {
abbrTitle?: string
}
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
({ className, size, abbrTitle, children, ...props }, ref) => {
return (
<kbd
className={cn(kbdVariants({ size, className }))}
ref={ref}
{...props}
>
{abbrTitle ? <abbr title={abbrTitle}>{children}</abbr> : children}
</kbd>
)
}
)
Kbd.displayName = "Kbd"
export { Kbd }

View File

@ -0,0 +1,180 @@
"use client"
import { useState, useEffect, useCallback, useMemo } from "react"
import { AlertCircle, Check } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { NestedKeyValueEditor } from "./nested-key-value-editor"
interface KeyValueEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
className?: string
placeholder?: string
}
// 验证 JSON 字符串
function validateJson(jsonStr: string): { valid: boolean; error?: string; parsed?: Record<string, unknown> } {
if (!jsonStr.trim()) {
return { valid: true, parsed: {} }
}
try {
const parsed = JSON.parse(jsonStr)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { valid: false, error: '必须是一个 JSON 对象 {}' }
}
// 支持任意 JSON 值类型(包括嵌套对象和数组)
return { valid: true, parsed: parsed as Record<string, unknown> }
} catch {
return { valid: false, error: 'JSON 格式错误' }
}
}
export function KeyValueEditor({
value,
onChange,
className,
placeholder = "添加额外参数...",
}: KeyValueEditorProps) {
const [mode, setMode] = useState<'list' | 'json'>('list')
const initialJsonText = useMemo(() =>
Object.keys(value || {}).length > 0 ? JSON.stringify(value, null, 2) : '',
[value]
)
const [editingJsonText, setEditingJsonText] = useState(initialJsonText)
const [jsonError, setJsonError] = useState<string | null>(null)
// 当 value 变化时重置编辑状态
useEffect(() => {
setEditingJsonText(initialJsonText)
}, [initialJsonText])
// JSON 预览数据
const previewData = useMemo(() => {
const validation = validateJson(editingJsonText)
if (validation.valid && validation.parsed) {
return { success: true, data: validation.parsed }
}
return { success: false, data: {} }
}, [editingJsonText])
// 切换模式时同步数据
const handleModeChange = useCallback((newMode: string) => {
const targetMode = newMode as 'list' | 'json'
if (targetMode === 'json' && mode === 'list') {
// 从列表模式切换到 JSON 模式将当前value转换为JSON
setEditingJsonText(Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : '')
setJsonError(null)
}
setMode(targetMode)
}, [mode, value])
// JSON 文本变化
const handleJsonChange = useCallback((text: string) => {
setEditingJsonText(text)
const validation = validateJson(text)
if (validation.valid && validation.parsed) {
setJsonError(null)
onChange(validation.parsed)
} else {
setJsonError(validation.error || 'JSON 格式错误')
}
}, [onChange])
return (
<div className={cn("h-full flex flex-col", className)}>
<Tabs value={mode} onValueChange={handleModeChange} className="w-full flex-1 flex flex-col">
<TabsList className="h-8 p-0.5 bg-muted/60 w-fit">
<TabsTrigger
value="list"
className="h-7 px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="json"
className="h-7 px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
JSON
</TabsTrigger>
</TabsList>
{/* 可视化编辑模式(嵌套键值对) */}
<TabsContent
value="list"
className="mt-2 flex-1 flex flex-col overflow-hidden data-[state=inactive]:hidden data-[state=inactive]:h-0"
>
<NestedKeyValueEditor
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</TabsContent>
{/* JSON 编辑模式 - 左右分栏 */}
<TabsContent
value="json"
className="mt-2 flex-1 flex flex-col overflow-hidden data-[state=inactive]:hidden data-[state=inactive]:h-0"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 overflow-hidden">
{/* 左侧JSON 编辑器 */}
<div className="flex flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"></span>
{jsonError ? (
<div className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
<span className="truncate max-w-[150px]">{jsonError}</span>
</div>
) : editingJsonText.trim() && (
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
<span></span>
</div>
)}
</div>
<Textarea
value={editingJsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={'{\n "key": "value"\n}'}
className={cn(
"font-mono text-sm flex-1 resize-none",
jsonError && "border-destructive focus-visible:ring-destructive"
)}
/>
<p className="text-xs text-muted-foreground">
JSON
</p>
</div>
{/* 右侧:预览 */}
<div className="flex flex-col gap-2 overflow-hidden">
<span className="text-xs text-muted-foreground"></span>
<div className="flex-1 rounded-md border bg-muted/30 p-3 overflow-auto">
{previewData.success && Object.keys(previewData.data).length > 0 ? (
<pre className="font-mono text-xs whitespace-pre-wrap break-words">
{JSON.stringify(previewData.data, null, 2)}
</pre>
) : previewData.success ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
</div>
) : (
<div className="flex items-center justify-center h-full text-sm text-destructive">
JSON
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,28 @@
import { MarkdownRenderer } from '@/components/markdown-renderer'
interface MarkdownProps {
children: string
className?: string
}
/**
* Markdown - Markdown GFM LaTeX
*
* @example
* ```tsx
* <Markdown>
* #
* ****
*
* $E = mc^2$
*
*
* $$
* \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
* $$
* </Markdown>
* ```
*/
export function Markdown({ children, className }: MarkdownProps) {
return <MarkdownRenderer content={children} className={className} />
}

View File

@ -0,0 +1,259 @@
/**
*
*
*/
import * as React from 'react'
import { X, Check, ChevronsUpDown, GripVertical } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
export interface MultiSelectOption {
label: string
value: string
}
interface MultiSelectProps {
options: MultiSelectOption[]
selected: string[]
onChange: (values: string[]) => void
placeholder?: string
emptyText?: string
className?: string
}
// 可排序的标签组件
function SortableBadge({
value,
label,
onRemove,
}: {
value: string
label: string
onRemove: (value: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
// 处理删除按钮点击,阻止事件冒泡和默认行为
const handleRemoveClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onRemove(value)
}
// 阻止删除按钮上的指针事件被 DndContext 捕获
const handleRemovePointerDown = (e: React.PointerEvent) => {
e.stopPropagation()
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'inline-flex items-center gap-1',
isDragging && 'shadow-lg'
)}
>
<Badge
variant="secondary"
className="cursor-move hover:bg-secondary/80 flex items-center gap-1"
>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing flex items-center"
>
<GripVertical className="h-3 w-3 text-muted-foreground" />
</div>
<span>{label}</span>
<span
role="button"
tabIndex={0}
className="ml-1 rounded-sm hover:bg-destructive/20 focus:outline-none focus:ring-1 focus:ring-destructive cursor-pointer"
onClick={handleRemoveClick}
onPointerDown={handleRemovePointerDown}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleRemoveClick(e as any)
}
}}
>
<X
className="h-3 w-3 hover:text-destructive"
strokeWidth={2}
fill="none"
/>
</span>
</Badge>
</div>
)
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = '选择选项...',
emptyText = '未找到选项',
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 拖动至少8px才触发避免与点击冲突
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleSelect = (value: string) => {
if (selected.includes(value)) {
// 取消选择
onChange(selected.filter((item) => item !== value))
} else {
// 添加选择
onChange([...selected, value])
}
}
const handleRemove = (value: string) => {
onChange(selected.filter((item) => item !== value))
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = selected.indexOf(active.id as string)
const newIndex = selected.indexOf(over.id as string)
onChange(arrayMove(selected, oldIndex, newIndex))
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between min-h-10 h-auto', className)}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selected}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-1 flex-wrap flex-1">
{selected.length === 0 ? (
<span className="text-muted-foreground">{placeholder}</span>
) : (
selected.map((value) => {
const option = options.find((opt) => opt.value === value)
return (
<SortableBadge
key={value}
value={value}
label={option?.label || value}
onRemove={handleRemove}
/>
)
})
)}
</div>
</SortableContext>
</DndContext>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" strokeWidth={2} fill="none" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="搜索..." className="h-9" />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}
>
<Check className="h-3 w-3" strokeWidth={2} fill="none" />
</div>
<span>{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,475 @@
"use client"
import { useState, useCallback } from "react"
import { Plus, Trash2, ChevronRight, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
// 生成唯一 ID
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`
}
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
interface TreeNode {
id: string
key: string
value: unknown
type: ValueType
expanded?: boolean
children?: TreeNode[]
}
interface NestedKeyValueEditorProps {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
placeholder?: string
}
// 推断值的类型
function inferType(value: unknown): ValueType {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
if (typeof value === 'object') return 'object'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
return 'string'
}
// 将 Record 转换为树节点数组
function recordToTree(record: Record<string, unknown>): TreeNode[] {
return Object.entries(record).map(([key, value]) => {
const type = inferType(value)
const node: TreeNode = {
id: generateId(),
key,
value,
type,
expanded: true,
}
if (type === 'object' && value && typeof value === 'object') {
node.children = recordToTree(value as Record<string, unknown>)
} else if (type === 'array' && Array.isArray(value)) {
node.children = value.map((item, index) => {
const itemType = inferType(item)
const childNode: TreeNode = {
id: generateId(),
key: String(index),
value: item,
type: itemType,
expanded: true,
}
if (itemType === 'object' && item && typeof item === 'object') {
childNode.children = recordToTree(item as Record<string, unknown>)
} else if (itemType === 'array' && Array.isArray(item)) {
childNode.children = item.map((subItem, subIndex) => ({
id: generateId(),
key: String(subIndex),
value: subItem,
type: inferType(subItem),
expanded: true,
}))
}
return childNode
})
}
return node
})
}
// 将树节点数组转换为 Record
function treeToRecord(nodes: TreeNode[]): Record<string, unknown> {
const record: Record<string, unknown> = {}
for (const node of nodes) {
if (!node.key.trim()) continue
if (node.type === 'object' && node.children) {
record[node.key] = treeToRecord(node.children)
} else if (node.type === 'array' && node.children) {
record[node.key] = node.children.map(child => {
if (child.type === 'object' && child.children) {
return treeToRecord(child.children)
} else if (child.type === 'array' && child.children) {
return child.children.map(c => c.value)
}
return child.value
})
} else if (node.type === 'null') {
record[node.key] = null
} else {
record[node.key] = node.value
}
}
return record
}
// 转换简单值
function convertSimpleValue(value: string, type: ValueType): unknown {
switch (type) {
case 'boolean':
return value === 'true'
case 'number': {
const num = parseFloat(value)
return isNaN(num) ? 0 : num
}
case 'null':
return null
default:
return value
}
}
// 树节点组件
function TreeNodeItem({
node,
level,
onUpdate,
onRemove,
onAddChild,
onToggleExpand,
}: {
node: TreeNode
level: number
onUpdate: (id: string, field: 'key' | 'value' | 'type', value: unknown) => void
onRemove: (id: string) => void
onAddChild: (parentId: string) => void
onToggleExpand: (id: string) => void
}) {
const isContainer = node.type === 'object' || node.type === 'array'
const hasChildren = node.children && node.children.length > 0
return (
<div className="space-y-1">
<div
className="grid gap-2 items-center"
style={{
gridTemplateColumns: isContainer
? '32px 1fr 90px 64px'
: '32px 1fr 1fr 90px 32px',
paddingLeft: `${level * 20}px`,
}}
>
{/* 展开/折叠按钮 */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onToggleExpand(node.id)}
disabled={!isContainer || !hasChildren}
>
{isContainer && hasChildren ? (
node.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)
) : (
<span className="w-4" />
)}
</Button>
{/* 键名 */}
<Input
value={node.key}
onChange={(e) => onUpdate(node.id, 'key', e.target.value)}
placeholder="key"
className="h-8 text-sm"
/>
{/* 值(仅简单类型显示) */}
{!isContainer && (
<>
{node.type === 'boolean' ? (
<div className="flex items-center h-8 px-3 border rounded-md bg-background">
<Switch
checked={node.value === true}
onCheckedChange={(checked) => onUpdate(node.id, 'value', checked)}
/>
<span className="ml-2 text-sm text-muted-foreground">
{node.value ? 'true' : 'false'}
</span>
</div>
) : node.type === 'null' ? (
<div className="flex items-center h-8 px-3 border rounded-md bg-muted text-sm text-muted-foreground">
null
</div>
) : (
<Input
type={node.type === 'number' ? 'number' : 'text'}
value={node.value as string | number}
onChange={(e) => onUpdate(node.id, 'value', e.target.value)}
placeholder="value"
className="h-8 text-sm"
step={node.type === 'number' ? 'any' : undefined}
/>
)}
</>
)}
{/* 类型选择 */}
<Select
value={node.type}
onValueChange={(v) => onUpdate(node.id, 'type', v as ValueType)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
<SelectItem value="null">Null</SelectItem>
<SelectItem value="object"></SelectItem>
<SelectItem value="array"></SelectItem>
</SelectContent>
</Select>
{/* 操作按钮 */}
<div className="flex gap-1 justify-end">
{isContainer && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={() => onAddChild(node.id)}
title="添加子项"
>
<Plus className="h-4 w-4" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(node.id)}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 子节点 */}
{isContainer && node.expanded && node.children && node.children.length > 0 && (
<div className="space-y-1">
{node.children.map((child) => (
<TreeNodeItem
key={child.id}
node={child}
level={level + 1}
onUpdate={onUpdate}
onRemove={onRemove}
onAddChild={onAddChild}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)}
</div>
)
}
export function NestedKeyValueEditor({
value,
onChange,
placeholder = "添加参数...",
}: NestedKeyValueEditorProps) {
const [nodes, setNodes] = useState<TreeNode[]>(() => recordToTree(value || {}))
// 同步到父组件
const syncToParent = useCallback(
(newNodes: TreeNode[]) => {
setNodes(newNodes)
onChange(treeToRecord(newNodes))
},
[onChange]
)
// 添加根节点
const addRootNode = useCallback(() => {
const newNode: TreeNode = {
id: generateId(),
key: '',
value: '',
type: 'string',
expanded: false,
}
syncToParent([...nodes, newNode])
}, [nodes, syncToParent])
// 更新节点
const updateNode = useCallback(
(id: string, field: 'key' | 'value' | 'type', newValue: unknown) => {
const updateRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((node) => {
if (node.id === id) {
if (field === 'type') {
const newType = newValue as ValueType
if (newType === 'object') {
return { ...node, type: newType, value: {}, children: [] }
} else if (newType === 'array') {
return { ...node, type: newType, value: [], children: [] }
} else if (newType === 'null') {
return { ...node, type: newType, value: null }
} else {
const converted = convertSimpleValue(String(node.value), newType)
return { ...node, type: newType, value: converted, children: undefined }
}
} else if (field === 'value') {
const converted = convertSimpleValue(String(newValue), node.type)
return { ...node, value: converted }
} else {
return { ...node, [field]: String(newValue) }
}
}
if (node.children) {
return { ...node, children: updateRecursive(node.children) }
}
return node
})
}
syncToParent(updateRecursive(nodes))
},
[nodes, syncToParent]
)
// 删除节点
const removeNode = useCallback(
(id: string) => {
const removeRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.filter((node) => node.id !== id)
.map((node) => {
if (node.children) {
return { ...node, children: removeRecursive(node.children) }
}
return node
})
}
syncToParent(removeRecursive(nodes))
},
[nodes, syncToParent]
)
// 添加子节点
const addChildNode = useCallback(
(parentId: string) => {
const addRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((node) => {
if (node.id === parentId) {
const newChild: TreeNode = {
id: generateId(),
key: node.type === 'array' ? String(node.children?.length || 0) : '',
value: '',
type: 'string',
expanded: true,
}
return {
...node,
children: [...(node.children || []), newChild],
}
}
if (node.children) {
return { ...node, children: addRecursive(node.children) }
}
return node
})
}
syncToParent(addRecursive(nodes))
},
[nodes, syncToParent]
)
// 切换展开/折叠
const toggleExpand = useCallback(
(id: string) => {
const toggleRecursive = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((node) => {
if (node.id === id) {
return { ...node, expanded: !node.expanded }
}
if (node.children) {
return { ...node, children: toggleRecursive(node.children) }
}
return node
})
}
setNodes(toggleRecursive(nodes))
},
[nodes]
)
return (
<div className="h-full flex flex-col gap-2">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{nodes.length}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={addRootNode}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto space-y-1">
{nodes.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded-md">
{placeholder}
</div>
) : (
<div className="space-y-1">
{/* 表头 */}
<div
className="grid gap-2 text-xs text-muted-foreground px-1 sticky top-0 bg-background z-10"
style={{
gridTemplateColumns: '32px 1fr 1fr 90px 32px',
}}
>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{nodes.map((node) => (
<TreeNodeItem
key={node.id}
node={node}
level={0}
onUpdate={updateNode}
onRemove={removeNode}
onAddChild={addChildNode}
onToggleExpand={toggleExpand}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span></span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span></span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,41 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,51 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
viewportRef?: React.RefObject<HTMLDivElement | null>
}
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
ScrollAreaProps
>(({ className, children, viewportRef, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar orientation="horizontal" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-[100] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-hidden rounded-md border border-border bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 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-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-2 pl-2 pr-8 text-sm outline-none bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:animate-in data-[state=active]:fade-in data-[state=active]:duration-300",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,110 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends React.ComponentProps<"textarea"> {
/**
*
* @default true
*/
autoResize?: boolean
/**
* autoResize=true
* @default 60
*/
minHeight?: number
/**
* autoResize=true
* undefined 0
*/
maxHeight?: number
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, autoResize = true, minHeight = 60, maxHeight, value, onChange, ...props }, ref) => {
const innerRef = React.useRef<HTMLTextAreaElement>(null)
const [hasFixedHeight, setHasFixedHeight] = React.useState(false)
// 合并 ref
React.useImperativeHandle(ref, () => innerRef.current!)
// 检测是否设置了固定高度
React.useEffect(() => {
if (className) {
// 检查是否包含固定高度的类(如 h-20, h-[200px], min-h-[xxx] 等)
const hasFixedHeightClass = /\b(h-\d+|h-\[[\d.]+(?:px|rem|em)\]|min-h-\[[\d.]+(?:px|rem|em)\])\b/.test(className)
setHasFixedHeight(hasFixedHeightClass)
}
}, [className])
// 自动调整高度函数
const adjustHeight = React.useCallback(() => {
const textarea = innerRef.current
if (!textarea || !autoResize || hasFixedHeight) return
// 重置高度以获取真实的 scrollHeight
textarea.style.height = 'auto'
// 计算新高度
const scrollHeight = textarea.scrollHeight
let newHeight = Math.max(scrollHeight, minHeight)
// 应用最大高度限制
if (maxHeight && maxHeight > 0) {
newHeight = Math.min(newHeight, maxHeight)
}
textarea.style.height = `${newHeight}px`
// 如果内容超过最大高度,启用滚动
if (maxHeight && maxHeight > 0 && scrollHeight > maxHeight) {
textarea.style.overflowY = 'auto'
} else {
textarea.style.overflowY = 'hidden'
}
}, [autoResize, hasFixedHeight, minHeight, maxHeight])
// 监听 value 变化并调整高度
React.useEffect(() => {
adjustHeight()
}, [value, adjustHeight])
// 组件挂载时调整高度
React.useEffect(() => {
adjustHeight()
}, [adjustHeight])
// 处理 onChange 事件
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e)
// 延迟调整高度,确保值已更新
requestAnimationFrame(() => {
adjustHeight()
})
},
[onChange, adjustHeight]
)
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"custom-scrollbar",
autoResize && !hasFixedHeight && "resize-none overflow-hidden",
className
)}
ref={innerRef}
value={value}
onChange={handleChange}
style={{
minHeight: autoResize && !hasFixedHeight ? `${minHeight}px` : undefined,
}}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,142 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
import { useIsMobile } from "@/hooks/use-media-query"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => {
const isMobile = useIsMobile()
return (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed z-[100] flex max-h-screen w-full gap-2 p-4",
isMobile
? "top-0 left-0 right-0 flex-col items-center"
: "bottom-0 right-0 flex-col-reverse sm:max-w-[420px]",
className
)}
{...props}
/>
)
})
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all",
{
variants: {
variant: {
default: "border bg-primary/5 text-foreground backdrop-blur-sm",
destructive:
"destructive group border-destructive bg-destructive/10 text-destructive-foreground backdrop-blur-sm",
},
position: {
desktop: "data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-in-from-right data-[state=open]:animate-fade-in data-[state=closed]:animate-slide-out-to-right data-[state=closed]:animate-fade-out data-[swipe=end]:animate-slide-out-to-right",
mobile: "data-[swipe=cancel]:translate-y-0 data-[swipe=end]:translate-y-[var(--radix-toast-swipe-end-y)] data-[swipe=move]:translate-y-[var(--radix-toast-swipe-move-y)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-in-from-top data-[state=open]:animate-fade-in data-[state=closed]:animate-slide-out-to-top data-[state=closed]:animate-fade-out data-[swipe=end]:animate-slide-out-to-top",
},
},
defaultVariants: {
variant: "default",
position: "desktop",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
const isMobile = useIsMobile()
const position = isMobile ? "mobile" : "desktop"
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant, position }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,35 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useIsMobile } from "@/hooks/use-media-query"
export function Toaster() {
const { toasts } = useToast()
const isMobile = useIsMobile()
return (
<ToastProvider swipeDirection={isMobile ? "up" : "right"}>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,47 @@
import { useContext } from 'react'
import { ThemeProviderContext } from '@/lib/theme-context'
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
return context
}
export const toggleThemeWithTransition = (
theme: 'dark' | 'light' | 'system',
setTheme: (theme: 'dark' | 'light' | 'system') => void,
event: React.MouseEvent
) => {
// 检查是否禁用动画
const animationsDisabled = document.documentElement.classList.contains('no-animations')
// 检查浏览器是否支持 View Transitions API
if (!document.startViewTransition || animationsDisabled) {
setTheme(theme)
return
}
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
const transition = document.startViewTransition(() => {
setTheme(theme)
})
transition.ready.then(() => {
// 始终在新内容层应用动画(z-index: 999)
document.documentElement.animate(
{
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
)
})
}

View File

@ -0,0 +1,382 @@
import { useEffect, useRef, useState } from 'react'
// 生成一个固定的随机种子(在模块加载时生成一次)
const NOISE_SEED = (() => {
// 使用时间戳的一部分作为种子,但在开发环境中使用固定值以保持一致性
if (import.meta.env.DEV) {
return 42 // 开发环境使用固定种子
}
return Date.now() % 1000000
})()
// Perlin Noise implementation
class Noise {
private grad3: number[][]
private p: number[]
private perm: number[]
constructor(seed = 0) {
// Use seed to ensure deterministic noise (seed is used implicitly in shuffle)
void seed
this.grad3 = [
[1, 1, 0],
[-1, 1, 0],
[1, -1, 0],
[-1, -1, 0],
[1, 0, 1],
[-1, 0, 1],
[1, 0, -1],
[-1, 0, -1],
[0, 1, 1],
[0, -1, 1],
[0, 1, -1],
[0, -1, -1],
]
this.p = []
for (let i = 0; i < 256; i++) {
this.p[i] = Math.floor(Math.random() * 256)
}
this.perm = []
for (let i = 0; i < 512; i++) {
this.perm[i] = this.p[i & 255]
}
}
dot(g: number[], x: number, y: number) {
return g[0] * x + g[1] * y
}
mix(a: number, b: number, t: number) {
return (1 - t) * a + t * b
}
fade(t: number) {
return t * t * t * (t * (t * 6 - 15) + 10)
}
perlin2(x: number, y: number) {
const X = Math.floor(x) & 255
const Y = Math.floor(y) & 255
x -= Math.floor(x)
y -= Math.floor(y)
const u = this.fade(x)
const v = this.fade(y)
const A = this.perm[X] + Y
const AA = this.perm[A]
const AB = this.perm[A + 1]
const B = this.perm[X + 1] + Y
const BA = this.perm[B]
const BB = this.perm[B + 1]
return this.mix(
this.mix(
this.dot(this.grad3[AA % 12], x, y),
this.dot(this.grad3[BA % 12], x - 1, y),
u
),
this.mix(
this.dot(this.grad3[AB % 12], x, y - 1),
this.dot(this.grad3[BB % 12], x - 1, y - 1),
u
),
v
)
}
}
interface Point {
x: number
y: number
wave: { x: number; y: number }
cursor: { x: number; y: number; vx: number; vy: number }
}
export function WavesBackground() {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | undefined>(undefined)
const [noiseInstance] = useState(() => new Noise(NOISE_SEED))
const dataRef = useRef<{
mouse: {
x: number
y: number
lx: number
ly: number
sx: number
sy: number
v: number
vs: number
a: number
set: boolean
}
lines: Point[][]
paths: SVGPathElement[]
noise: Noise
bounding: DOMRect | null
}>({
mouse: {
x: -10,
y: 0,
lx: 0,
ly: 0,
sx: 0,
sy: 0,
v: 0,
vs: 0,
a: 0,
set: false,
},
lines: [],
paths: [],
noise: noiseInstance,
bounding: null,
})
useEffect(() => {
const container = containerRef.current
const svg = svgRef.current
if (!container || !svg) return
const data = dataRef.current
// 将 noiseInstance 赋值给 dataRef
data.noise = noiseInstance
// Set size
const setSize = () => {
const bounding = container.getBoundingClientRect()
data.bounding = bounding
svg.style.width = `${bounding.width}px`
svg.style.height = `${bounding.height}px`
}
// Set lines
const setLines = () => {
if (!data.bounding) return
const { width, height } = data.bounding
data.lines = []
data.paths.forEach((path) => path.remove())
data.paths = []
const xGap = 10
const yGap = 32
const oWidth = width + 200
const oHeight = height + 30
const totalLines = Math.ceil(oWidth / xGap)
const totalPoints = Math.ceil(oHeight / yGap)
const xStart = (width - xGap * totalLines) / 2
const yStart = (height - yGap * totalPoints) / 2
for (let i = 0; i <= totalLines; i++) {
const points: Point[] = []
for (let j = 0; j <= totalPoints; j++) {
const point: Point = {
x: xStart + xGap * i,
y: yStart + yGap * j,
wave: { x: 0, y: 0 },
cursor: { x: 0, y: 0, vx: 0, vy: 0 },
}
points.push(point)
}
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
svg.appendChild(path)
data.paths.push(path)
data.lines.push(points)
}
}
// Move points
const movePoints = (time: number) => {
const { lines, mouse, noise } = data
lines.forEach((points) => {
points.forEach((p) => {
// Wave movement
const move =
noise.perlin2((p.x + time * 0.0125) * 0.002, (p.y + time * 0.005) * 0.0015) * 12
p.wave.x = Math.cos(move) * 32
p.wave.y = Math.sin(move) * 16
// Mouse effect
const dx = p.x - mouse.sx
const dy = p.y - mouse.sy
const d = Math.hypot(dx, dy)
const l = Math.max(175, mouse.vs)
if (d < l) {
const s = 1 - d / l
const f = Math.cos(d * 0.001) * s
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065
}
p.cursor.vx += (0 - p.cursor.x) * 0.005
p.cursor.vy += (0 - p.cursor.y) * 0.005
p.cursor.vx *= 0.925
p.cursor.vy *= 0.925
p.cursor.x += p.cursor.vx * 2
p.cursor.y += p.cursor.vy * 2
p.cursor.x = Math.min(100, Math.max(-100, p.cursor.x))
p.cursor.y = Math.min(100, Math.max(-100, p.cursor.y))
})
})
}
// Get moved point
const moved = (point: Point, withCursorForce = true) => {
const coords = {
x: point.x + point.wave.x + (withCursorForce ? point.cursor.x : 0),
y: point.y + point.wave.y + (withCursorForce ? point.cursor.y : 0),
}
coords.x = Math.round(coords.x * 10) / 10
coords.y = Math.round(coords.y * 10) / 10
return coords
}
// Draw lines
const drawLines = () => {
const { lines, paths } = data
lines.forEach((points, lIndex) => {
let p1 = moved(points[0], false)
let d = `M ${p1.x} ${p1.y}`
points.forEach((point, pIndex) => {
const isLast = pIndex === points.length - 1
p1 = moved(point, !isLast)
d += `L ${p1.x} ${p1.y}`
})
paths[lIndex].setAttribute('d', d)
})
}
// Tick
const tick = (time: number) => {
const { mouse } = data
mouse.sx += (mouse.x - mouse.sx) * 0.1
mouse.sy += (mouse.y - mouse.sy) * 0.1
const dx = mouse.x - mouse.lx
const dy = mouse.y - mouse.ly
const d = Math.hypot(dx, dy)
mouse.v = d
mouse.vs += (d - mouse.vs) * 0.1
mouse.vs = Math.min(100, mouse.vs)
mouse.lx = mouse.x
mouse.ly = mouse.y
mouse.a = Math.atan2(dy, dx)
if (container) {
container.style.setProperty('--x', `${mouse.sx}px`)
container.style.setProperty('--y', `${mouse.sy}px`)
}
movePoints(time)
drawLines()
animationRef.current = requestAnimationFrame(tick)
}
// Event handlers
const handleMouseMove = (e: MouseEvent) => {
if (!data.bounding) return
const { mouse } = data
mouse.x = e.pageX - data.bounding.left
mouse.y = e.pageY - data.bounding.top + window.scrollY
if (!mouse.set) {
mouse.sx = mouse.x
mouse.sy = mouse.y
mouse.lx = mouse.x
mouse.ly = mouse.y
mouse.set = true
}
}
const handleResize = () => {
setSize()
setLines()
}
// Init
setSize()
setLines()
window.addEventListener('resize', handleResize)
window.addEventListener('mousemove', handleMouseMove)
animationRef.current = requestAnimationFrame(tick)
return () => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('mousemove', handleMouseMove)
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [noiseInstance])
return (
<div
ref={containerRef}
className="waves-background"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'none',
}}
>
<div
className="waves-cursor"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '0.5rem',
height: '0.5rem',
background: 'hsl(var(--primary) / 0.3)',
borderRadius: '50%',
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
willChange: 'transform',
pointerEvents: 'none',
}}
/>
<svg
ref={svgRef}
style={{
display: 'block',
width: '100%',
height: '100%',
}}
>
<style>{`
path {
fill: none;
stroke: hsl(var(--primary) / 0.20);
stroke-width: 1px;
}
`}</style>
</svg>
</div>
)
}

View File

@ -0,0 +1,2 @@
export { webuiFeedbackSurvey } from './webui-feedback'
export { maibotFeedbackSurvey } from './maibot-feedback'

View File

@ -0,0 +1,103 @@
import type { SurveyConfig } from '@/types/survey'
export const maibotFeedbackSurvey: SurveyConfig = {
id: 'maibot-feedback-v1',
version: '1.0.0',
title: '麦麦使用体验反馈问卷',
description: '感谢您使用麦麦!您的反馈将帮助我们打造更好的 AI 伙伴。',
questions: [
{
id: 'maibot_version',
type: 'text',
title: '你正在使用的麦麦版本',
description: '此项由系统自动填写',
required: true,
readOnly: true,
placeholder: '自动检测中...',
},
{
id: 'improvement_areas',
type: 'textarea',
title: '你认为麦麦还有哪些部分可以改进?',
required: true,
placeholder: '请分享你认为可以改进的方面...',
maxLength: 1000,
},
{
id: 'problems_encountered',
type: 'multiple',
title: '你在使用麦麦时遇到过哪些问题?',
description: '可多选',
required: true,
options: [
{ id: 'incomplete', label: '功能不完整', value: 'incomplete' },
{ id: 'slow_response', label: '响应速度慢', value: 'slow_response' },
{ id: 'complex', label: '操作复杂', value: 'complex' },
{ id: 'unstable', label: '运行不稳定', value: 'unstable' },
{ id: 'config_difficult', label: '配置困难', value: 'config_difficult' },
{ id: 'none', label: '没有遇到问题', value: 'none' },
{ id: 'other', label: '其他', value: 'other' },
],
},
{
id: 'problems_other',
type: 'text',
title: '如选择"其他",请说明遇到的问题',
required: false,
placeholder: '请描述你遇到的其他问题...',
maxLength: 500,
},
{
id: 'helpful_features',
type: 'textarea',
title: '你觉得麦麦的哪些功能对你最有帮助?',
required: true,
placeholder: '请分享对你最有帮助的功能...',
maxLength: 1000,
},
{
id: 'feature_requests',
type: 'textarea',
title: '你希望在未来的版本中增加哪些功能?',
required: true,
placeholder: '请告诉我们你期望的新功能...',
maxLength: 1000,
},
{
id: 'overall_satisfaction',
type: 'single',
title: '你对麦麦的整体满意度如何?',
required: true,
options: [
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
{ id: 'satisfied', label: '满意', value: 'satisfied' },
{ id: 'neutral', label: '一般', value: 'neutral' },
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
],
},
{
id: 'would_recommend',
type: 'single',
title: '你愿意推荐麦麦给其他人使用吗?',
required: true,
options: [
{ id: 'yes', label: '是', value: 'yes' },
{ id: 'no', label: '否', value: 'no' },
],
},
{
id: 'other_suggestions',
type: 'textarea',
title: '其他建议或意见',
description: '此项为选填',
required: false,
placeholder: '如果你有任何其他想法或建议,请在此分享...',
maxLength: 2000,
},
],
settings: {
allowMultiple: false,
thankYouMessage: '感谢你的反馈!你的意见对麦麦的成长非常重要,我们会认真考虑每一条建议。',
},
}

View File

@ -0,0 +1,107 @@
import type { SurveyConfig } from '@/types/survey'
export const webuiFeedbackSurvey: SurveyConfig = {
id: 'webui-feedback-v1',
version: '1.0.0',
title: '麦麦 WebUI 使用反馈问卷',
description: '感谢您使用麦麦 WebUI您的反馈将帮助我们不断改进产品体验。',
questions: [
{
id: 'webui_version',
type: 'text',
title: '你正在使用的 WebUI 版本',
description: '此项由系统自动填写',
required: true,
readOnly: true,
placeholder: '自动检测中...',
},
{
id: 'ui_design_satisfaction',
type: 'single',
title: '你觉得当前的 WebUI 界面设计如何?',
required: true,
options: [
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
{ id: 'satisfied', label: '满意', value: 'satisfied' },
{ id: 'neutral', label: '一般', value: 'neutral' },
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
],
},
{
id: 'problems_encountered',
type: 'multiple',
title: '你在使用 WebUI 时遇到过哪些问题?',
description: '可多选',
required: true,
options: [
{ id: 'lag', label: '界面卡顿', value: 'lag' },
{ id: 'incomplete', label: '功能不完整', value: 'incomplete' },
{ id: 'complex', label: '操作复杂', value: 'complex' },
{ id: 'bugs', label: '存在 Bug', value: 'bugs' },
{ id: 'none', label: '没有遇到问题', value: 'none' },
{ id: 'other', label: '其他', value: 'other' },
],
},
{
id: 'problems_other',
type: 'text',
title: '如选择"其他",请说明遇到的问题',
required: false,
placeholder: '请描述你遇到的其他问题...',
maxLength: 500,
},
{
id: 'useful_features',
type: 'textarea',
title: '你觉得哪些功能是最有用的?',
required: true,
placeholder: '请分享你认为最有价值的功能...',
maxLength: 1000,
},
{
id: 'feature_requests',
type: 'textarea',
title: '你希望在未来的版本中增加哪些功能?',
required: true,
placeholder: '请告诉我们你期望的新功能...',
maxLength: 1000,
},
{
id: 'overall_satisfaction',
type: 'single',
title: '你对麦麦 WebUI 的整体满意度如何?',
required: true,
options: [
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
{ id: 'satisfied', label: '满意', value: 'satisfied' },
{ id: 'neutral', label: '一般', value: 'neutral' },
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
],
},
{
id: 'would_recommend',
type: 'single',
title: '你愿意推荐麦麦 WebUI 给其他人使用吗?',
required: true,
options: [
{ id: 'yes', label: '是', value: 'yes' },
{ id: 'no', label: '否', value: 'no' },
],
},
{
id: 'other_suggestions',
type: 'textarea',
title: '其他建议或意见',
description: '此项为选填',
required: false,
placeholder: '如果你有任何其他想法或建议,请在此分享...',
maxLength: 2000,
},
],
settings: {
allowMultiple: false,
thankYouMessage: '感谢你的反馈!你的意见对我们非常重要,我们会认真考虑每一条建议。',
},
}

View File

@ -0,0 +1,12 @@
import { useContext } from 'react'
import { AnimationContext } from '@/lib/animation-context'
export const useAnimation = () => {
const context = useContext(AnimationContext)
if (context === undefined) {
throw new Error('useAnimation must be used within an AnimationProvider')
}
return context
}

View File

@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { checkAuthStatus } from '@/lib/fetch-with-auth'
export function useAuthGuard() {
const navigate = useNavigate()
const [checking, setChecking] = useState(true)
useEffect(() => {
let cancelled = false
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (!cancelled && !isAuth) {
navigate({ to: '/auth' })
}
} catch {
// 发生错误时也跳转到登录页
if (!cancelled) {
navigate({ to: '/auth' })
}
} finally {
if (!cancelled) {
setChecking(false)
}
}
}
verifyAuth()
return () => {
cancelled = true
}
}, [navigate])
return { checking }
}
/**
*
*/
export async function checkAuth(): Promise<boolean> {
return await checkAuthStatus()
}
/**
*
*/
export async function checkFirstSetup(): Promise<boolean> {
try {
const response = await fetch('/api/webui/setup/status', {
method: 'GET',
credentials: 'include',
})
const data = await response.json()
if (response.ok) {
return data.is_first_setup
}
return false
} catch (error) {
console.error('检查首次配置状态失败:', error)
return false
}
}

View File

@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
})
useEffect(() => {
if (typeof window === 'undefined') {
return
}
const mediaQuery = window.matchMedia(query)
const handleChange = (event: MediaQueryListEvent) => {
setMatches(event.matches)
}
setMatches(mediaQuery.matches)
mediaQuery.addEventListener('change', handleChange)
return () => {
mediaQuery.removeEventListener('change', handleChange)
}
}, [query])
return matches
}
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 768px)')
}

View File

@ -0,0 +1,192 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 5
const TOAST_REMOVE_DELAY = 5000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = {
ADD_TOAST: "ADD_TOAST"
UPDATE_TOAST: "UPDATE_TOAST"
DISMISS_TOAST: "DISMISS_TOAST"
REMOVE_TOAST: "REMOVE_TOAST"
}
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -0,0 +1,205 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* JetBrains Mono 字体 - 用于代码编辑器 */
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--primary-gradient: none; /* 默认无渐变 */
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--chart-1: 221.2 83.2% 53.3%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 210 40% 98%;
--primary-gradient: none; /* 默认无渐变 */
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--chart-1: 217.2 91.2% 59.8%;
--chart-2: 160 60% 50%;
--chart-3: 30 80% 60%;
--chart-4: 280 65% 65%;
--chart-5: 340 75% 60%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
/* 隐藏数字输入框的默认上下箭头 */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
}
@layer utilities {
/* 渐变色背景工具类 */
.bg-primary-gradient {
background: var(--primary-gradient, hsl(var(--primary)));
}
/* 渐变色文字工具类 - 默认使用普通文字颜色 */
.text-primary-gradient {
color: hsl(var(--primary));
}
/* 当应用了 has-gradient 类时,使用渐变文字效果 */
.has-gradient .text-primary-gradient {
background: var(--primary-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* 渐变色边框工具类 */
.border-primary-gradient {
border-image: var(--primary-gradient, linear-gradient(to right, hsl(var(--primary)), hsl(var(--primary)))) 1;
}
}
/* 禁用动效时的样式 */
.no-animations *,
.no-animations *::before,
.no-animations *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* 保留基本的 hover 反馈 */
.no-animations *:hover {
transition-duration: 0.01ms !important;
}
/* View Transition API 动画 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* 默认情况下(亮色→暗色),新内容在上层 */
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 999;
}
/* React Joyride Tour 样式 - 确保在 Dialog 之上 */
.__floater {
z-index: 99999 !important;
pointer-events: auto !important;
}
.react-joyride__overlay {
z-index: 99998 !important;
}
.react-joyride__spotlight {
z-index: 99998 !important;
}
/* Tour tooltip 内的按钮需要可点击 */
.react-joyride__tooltip {
pointer-events: auto !important;
}
#tour-portal-container * {
pointer-events: auto;
}
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background-clip: content-box;
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}

View File

@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import './index.css'
import { router } from './router'
import { ThemeProvider } from './components/theme-provider'
import { AnimationProvider } from './components/animation-provider'
import { TourProvider, TourRenderer } from './components/tour'
import { Toaster } from './components/ui/toaster'
import { ErrorBoundary } from './components/error-boundary'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<ThemeProvider defaultTheme="system">
<AnimationProvider>
<TourProvider>
<RouterProvider router={router} />
<TourRenderer />
<Toaster />
</TourProvider>
</AnimationProvider>
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
)

View File

@ -0,0 +1,304 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { IndexPage } from './routes/index'
import { SettingsPage } from './routes/settings'
import { AuthPage } from './routes/auth'
import { SetupPage } from './routes/setup'
import { NotFoundPage } from './routes/404'
import { BotConfigPage } from './routes/config/bot'
import { ModelProviderConfigPage } from './routes/config/modelProvider'
import { ModelConfigPage } from './routes/config/model'
import { AdapterConfigPage } from './routes/config/adapter'
import { EmojiManagementPage } from './routes/resource/emoji'
import { ExpressionManagementPage } from './routes/resource/expression'
import { JargonManagementPage } from './routes/resource/jargon'
import { PersonManagementPage } from './routes/person'
import { KnowledgeGraphPage } from './routes/resource/knowledge-graph'
import { KnowledgeBasePage } from './routes/resource/knowledge-base'
import { LogViewerPage } from './routes/logs'
import { PlannerMonitorPage } from './routes/monitor'
import { PluginsPage } from './routes/plugins'
import { ModelPresetsPage } from './routes/model-presets'
import { PluginConfigPage } from './routes/plugin-config'
import { PluginMirrorsPage } from './routes/plugin-mirrors'
import { PluginDetailPage } from './routes/plugin-detail'
import { ChatPage } from './routes/chat'
import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey'
import { AnnualReportPage } from './routes/annual-report'
import PackMarketPage from './routes/config/pack-market'
import PackDetailPage from './routes/config/pack-detail'
import { Layout } from './components/layout'
import { checkAuth } from './hooks/use-auth'
import { RouteErrorBoundary } from './components/error-boundary'
// Root 路由
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
{import.meta.env.DEV && <TanStackRouterDevtools />}
</>
),
beforeLoad: () => {
// 如果访问根路径且未认证,重定向到认证页面
if (window.location.pathname === '/' && !checkAuth()) {
throw redirect({ to: '/auth' })
}
},
})
// 认证路由(无 Layout
const authRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth',
component: AuthPage,
})
// 首次配置路由(无 Layout
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup',
component: SetupPage,
})
// 受保护的路由 Root带 Layout
const protectedRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'protected',
component: () => (
<Layout>
<Outlet />
</Layout>
),
errorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
})
// 首页路由
const indexRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/',
component: IndexPage,
})
// 配置路由 - 麦麦主程序配置
const botConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/bot',
component: BotConfigPage,
})
// 配置路由 - 麦麦模型提供商配置
const modelProviderConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/modelProvider',
component: ModelProviderConfigPage,
})
// 配置路由 - 麦麦模型配置
const modelConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/model',
component: ModelConfigPage,
})
// 配置路由 - 麦麦适配器配置
const adapterConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/adapter',
component: AdapterConfigPage,
})
// 资源管理路由 - 表情包管理
const emojiManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/emoji',
component: EmojiManagementPage,
})
// 资源管理路由 - 表达方式管理
const expressionManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/expression',
component: ExpressionManagementPage,
})
// 资源管理路由 - 人物信息管理
const personManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/person',
component: PersonManagementPage,
})
// 资源管理路由 - 黑话管理
const jargonManagementRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/jargon',
component: JargonManagementPage,
})
// 资源管理路由 - 知识库图谱可视化
const knowledgeGraphRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/knowledge-graph',
component: KnowledgeGraphPage,
})
// 资源管理路由 - 麦麦知识库管理
const knowledgeBaseRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/resource/knowledge-base',
component: KnowledgeBasePage,
})
// 日志查看器路由
const logsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/logs',
component: LogViewerPage,
})
// 计划器&恢复器监控路由
const plannerMonitorRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/planner-monitor',
component: PlannerMonitorPage,
})
// 本地聊天室路由
const chatRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/chat',
component: ChatPage,
})
// 插件市场路由
const pluginsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugins',
component: PluginsPage,
})
// 插件详情路由
const pluginDetailRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugin-detail',
component: PluginDetailPage,
})
// 模型分配预设市场路由
const modelPresetsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/model-presets',
component: ModelPresetsPage,
})
// 插件配置路由
const pluginConfigRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugin-config',
component: PluginConfigPage,
})
// 插件镜像源配置路由
const pluginMirrorsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/plugin-mirrors',
component: PluginMirrorsPage,
})
// 设置页路由
const settingsRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/settings',
component: SettingsPage,
})
// 配置模板市场路由
const packMarketRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/pack-market',
component: PackMarketPage,
})
// 配置模板详情路由
export const packDetailRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/config/pack-market/$packId',
component: PackDetailPage,
})
// 问卷调查路由 - WebUI 反馈
const webuiFeedbackSurveyRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/survey/webui-feedback',
component: WebUIFeedbackSurveyPage,
})
// 问卷调查路由 - 麦麦体验反馈
const maibotFeedbackSurveyRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/survey/maibot-feedback',
component: MaiBotFeedbackSurveyPage,
})
// 年度报告路由
const annualReportRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/annual-report',
component: AnnualReportPage,
})
// 404 路由
const notFoundRoute = createRoute({
getParentRoute: () => rootRoute,
path: '*',
component: NotFoundPage,
})
// 路由树
const routeTree = rootRoute.addChildren([
authRoute,
setupRoute,
protectedRoute.addChildren([
indexRoute,
botConfigRoute,
modelProviderConfigRoute,
modelConfigRoute,
adapterConfigRoute,
emojiManagementRoute,
expressionManagementRoute,
jargonManagementRoute,
personManagementRoute,
knowledgeGraphRoute,
knowledgeBaseRoute,
pluginsRoute,
pluginDetailRoute,
modelPresetsRoute,
pluginConfigRoute,
pluginMirrorsRoute,
logsRoute,
plannerMonitorRoute,
chatRoute,
settingsRoute,
packMarketRoute,
packDetailRoute,
webuiFeedbackSurveyRoute,
maibotFeedbackSurveyRoute,
annualReportRoute,
]),
notFoundRoute,
])
// 创建路由器
export const router = createRouter({
routeTree,
defaultNotFoundComponent: NotFoundPage,
defaultErrorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
})
// 类型声明
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

View File

@ -0,0 +1,61 @@
import { useNavigate } from '@tanstack/react-router'
import { Home, Search, ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-2xl text-center">
{/* 404 大标题 */}
<div className="relative mb-8">
<h1 className="text-[150px] font-black leading-none text-primary/10 select-none sm:text-[200px]">
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 text-primary/30 sm:h-24 sm:w-24" />
</div>
</div>
{/* 错误信息 */}
<div className="space-y-4 mb-8">
<h2 className="text-2xl font-bold text-foreground sm:text-3xl">
</h2>
<p className="text-base text-muted-foreground sm:text-lg max-w-md mx-auto">
访 URL
</p>
</div>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button
size="lg"
onClick={() => navigate({ to: '/' })}
className="gap-2 w-full sm:w-auto"
>
<Home className="h-4 w-4" />
</Button>
<Button
size="lg"
variant="outline"
onClick={() => window.history.back()}
className="gap-2 w-full sm:w-auto"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
{/* 提示信息 */}
<div className="mt-12 pt-8 border-t border-border">
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,883 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { getAnnualReport, type AnnualReportData } from '@/lib/annual-report-api'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
import { toPng } from 'html-to-image'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import {
Clock,
Users,
Brain,
Smile,
Trophy,
Calendar,
MessageSquare,
Zap,
Moon,
Sun,
AtSign,
Heart,
Image as ImageIcon,
Bot,
Download,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// 颜色常量
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']
// 动态比喻生成函数
function getOnlineHoursMetaphor(hours: number): string {
if (hours >= 8760) return "相当于全年无休7x24小时在线"
if (hours >= 5000) return "相当于一位全职员工的年工作时长"
if (hours >= 2000) return "相当于看完了 1000 部电影"
if (hours >= 1000) return "相当于环球飞行 80 次"
if (hours >= 500) return "相当于读完了 100 本书"
if (hours >= 100) return "相当于马拉松跑了 25 次"
return "虽然不多,但每一刻都很珍贵"
}
function getMidnightMetaphor(count: number): string {
if (count >= 1000) return "夜深人静时的知心好友"
if (count >= 500) return "午夜场的常客"
if (count >= 100) return "偶尔熬夜的小伙伴"
if (count >= 50) return "深夜有时也会陪你聊聊"
return "早睡早起,健康作息"
}
function getTokenMetaphor(tokens: number): string {
const millions = tokens / 1000000
if (millions >= 100) return "思考量堪比一座图书馆"
if (millions >= 50) return "相当于写了一部百科全书"
if (millions >= 10) return "脑细胞估计消耗了不少"
if (millions >= 1) return "也算是费了一番脑筋"
return "轻轻松松,游刃有余"
}
function getCostMetaphor(cost: number): string {
if (cost >= 1000) return "这钱够吃一年的泡面了"
if (cost >= 500) return "相当于买了一台游戏机"
if (cost >= 100) return "够请大家喝几杯奶茶"
if (cost >= 50) return "一顿火锅的钱"
if (cost >= 10) return "几杯咖啡的价格"
return "省钱小能手"
}
function getSilenceMetaphor(rate: number): string {
if (rate >= 80) return "沉默是金,惜字如金"
if (rate >= 60) return "话不多但句句到位"
if (rate >= 40) return "该说的时候才开口"
if (rate >= 20) return "能聊的都聊了"
return "话痨本痨,有问必答"
}
function getImageMetaphor(count: number): string {
if (count >= 10000) return "眼睛都快看花了"
if (count >= 5000) return "堪比专业摄影师的阅片量"
if (count >= 1000) return "看图小达人"
if (count >= 500) return "图片鉴赏家"
if (count >= 100) return "偶尔欣赏一下美图"
return "图片?有空再看"
}
function getRejectedMetaphor(count: number): string {
if (count >= 500) return "在不断的纠正中成长"
if (count >= 200) return "学习永无止境"
if (count >= 100) return "虚心接受,积极改正"
if (count >= 50) return "偶尔也会犯错"
if (count >= 10) return "表现还算不错"
return "完美表达,无需纠正"
}
function getExpensiveThinkingMetaphor(cost: number): string {
if (cost >= 1) return "这次思考的价值堪比一顿大餐!"
if (cost >= 0.5) return "为了这个问题,我可是认真思考了!"
if (cost >= 0.1) return "下了点功夫,值得的!"
if (cost >= 0.01) return "花了点小钱,但很值得"
return "小小思考,不足挂齿"
}
function getFavoriteReplyMetaphor(count: number, botName: string): string {
if (count >= 100) return "这句话简直是万能钥匙!"
if (count >= 50) return "百试不爽的经典回复"
if (count >= 20) return `${botName}的口头禅`
if (count >= 10) return "常用语录之一"
return "偶尔用用的小确幸"
}
function getNightOwlMetaphor(isNightOwl: boolean, midnightCount: number): string {
if (isNightOwl) {
if (midnightCount >= 1000) return "深夜的守护者,黑暗中的光芒"
if (midnightCount >= 500) return "月亮是我的好朋友"
if (midnightCount >= 100) return "越夜越精神,夜晚才是主场"
return "偶尔熬夜,享受宁静时光"
} else {
if (midnightCount <= 10) return "作息规律,健康生活的典范"
if (midnightCount <= 50) return "早睡早起,偶尔也会熬个夜"
return "虽然是早起鸟,但也会守候深夜"
}
}
function getBusiestDayMetaphor(count: number): string {
if (count >= 1000) return "忙到飞起,键盘都要冒烟了"
if (count >= 500) return "这天简直是话痨附体"
if (count >= 200) return "社交达人上线"
if (count >= 100) return "比平时活跃不少"
if (count >= 50) return "小忙一下"
return "还算轻松的一天"
}
export function AnnualReportPage() {
const [year] = useState(2025)
const [data, setData] = useState<AnnualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [error, setError] = useState<Error | null>(null)
const reportRef = useRef<HTMLDivElement>(null)
const { toast } = useToast()
const loadReport = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const result = await getAnnualReport(year)
setData(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('获取年度报告失败'))
} finally {
setIsLoading(false)
}
}, [year])
// 导出为图片
const handleExport = useCallback(async () => {
if (!reportRef.current || !data) return
setIsExporting(true)
toast({
title: '正在生成图片',
description: '请稍候...',
})
try {
const element = reportRef.current
// 获取当前主题的背景色
const computedStyle = getComputedStyle(document.documentElement)
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
? `hsl(${computedStyle.getPropertyValue('--background').trim()})`
: (document.documentElement.classList.contains('dark') ? '#0a0a0a' : '#ffffff')
// 保存原始样式
const originalWidth = element.style.width
const originalMaxWidth = element.style.maxWidth
// 临时设置固定宽度以去除左右空白
element.style.width = '1024px'
element.style.maxWidth = '1024px'
const dataUrl = await toPng(element, {
quality: 1,
pixelRatio: 2,
backgroundColor,
cacheBust: true,
filter: (node) => {
// 过滤掉导出按钮
if (node instanceof HTMLElement && node.hasAttribute('data-export-btn')) {
return false
}
return true
},
})
// 恢复原始样式
element.style.width = originalWidth
element.style.maxWidth = originalMaxWidth
// 创建下载链接
const link = document.createElement('a')
link.download = `${data.bot_name}_${data.year}_年度总结.png`
link.href = dataUrl
link.click()
toast({
title: '导出成功',
description: '年度报告已保存为图片',
})
} catch (err) {
console.error('导出图片失败:', err)
toast({
title: '导出失败',
description: '请重试',
variant: 'destructive',
})
} finally {
setIsExporting(false)
}
}, [data, toast])
useEffect(() => {
loadReport()
}, [loadReport])
if (isLoading) {
return <LoadingSkeleton />
}
if (error) {
return (
<div className="flex h-screen items-center justify-center text-red-500">
: {error.message}
</div>
)
}
if (!data) return null
return (
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="min-h-screen bg-gradient-to-b from-background to-muted/50 p-4 md:p-8 print:p-0" ref={reportRef}>
<div className="mx-auto max-w-5xl space-y-8 print:space-y-4">
{/* 头部 Hero */}
<header className="relative overflow-hidden rounded-3xl bg-primary p-8 text-primary-foreground shadow-2xl print:rounded-none print:shadow-none">
{/* 导出按钮 */}
<div className="absolute right-4 top-4 z-20 print:hidden" data-export-btn>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
disabled={isExporting}
className="gap-2 bg-white/20 hover:bg-white/30 text-white border-white/30"
>
{isExporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Download className="h-4 w-4" />
</>
)}
</Button>
</div>
<div className="relative z-10 flex flex-col items-center text-center">
<Bot className="mb-4 h-16 w-16 animate-bounce" />
<h1 className="text-4xl font-bold tracking-tighter sm:text-6xl">
{data.bot_name} {data.year}
</h1>
<p className="mt-4 max-w-2xl text-lg opacity-90">
· Connection & Growth
</p>
<div className="mt-6 flex items-center gap-2 text-sm opacity-75">
<Calendar className="h-4 w-4" />
<span>: {data.generated_at}</span>
</div>
</div>
{/* 背景装饰 */}
<div className="absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
</header>
{/* 维度一:时光足迹 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Clock className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="年度在线时长"
value={`${data.time_footprint.total_online_hours} 小时`}
description={getOnlineHoursMetaphor(data.time_footprint.total_online_hours)}
icon={<Clock className="h-4 w-4" />}
/>
<StatCard
title="最忙碌的一天"
value={data.time_footprint.busiest_day || 'N/A'}
description={getBusiestDayMetaphor(data.time_footprint.busiest_day_count)}
icon={<Calendar className="h-4 w-4" />}
/>
<StatCard
title="深夜互动 (0-4点)"
value={`${data.time_footprint.midnight_chat_count}`}
description={getMidnightMetaphor(data.time_footprint.midnight_chat_count)}
icon={<Moon className="h-4 w-4" />}
/>
<StatCard
title="作息属性"
value={data.time_footprint.is_night_owl ? '夜猫子' : '早起鸟'}
description={getNightOwlMetaphor(data.time_footprint.is_night_owl, data.time_footprint.midnight_chat_count)}
icon={data.time_footprint.is_night_owl ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
/>
</div>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>24</CardTitle>
<CardDescription>{data.bot_name}</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.time_footprint.hourly_distribution.map((count: number, hour: number) => ({ hour: `${hour}`, count }))}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
cursor={{ fill: 'transparent' }}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{data.time_footprint.first_message_time && (
<Card className="bg-muted/30 border-dashed">
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
<p className="text-muted-foreground mb-2">2025</p>
<div className="text-xl font-bold text-primary mb-1">{data.time_footprint.first_message_time}</div>
<p className="text-lg">
<span className="font-semibold text-foreground">{data.time_footprint.first_message_user}</span>
<span className="italic text-muted-foreground">"{data.time_footprint.first_message_content}"</span>
</p>
</CardContent>
</Card>
)}
</section>
{/* 维度二:社交网络 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Users className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="社交圈子"
value={`${data.social_network.total_groups} 个群组`}
description={`${data.bot_name}加入的群组总数`}
icon={<Users className="h-4 w-4" />}
/>
<StatCard
title="被呼叫次数"
value={`${data.social_network.at_count + data.social_network.mentioned_count}`}
description="我的名字被大家频繁提起"
icon={<AtSign className="h-4 w-4" />}
/>
<StatCard
title="最长情陪伴"
value={data.social_network.longest_companion_user || 'N/A'}
description={`始终都在,已陪伴 ${data.social_network.longest_companion_days}`}
icon={<Heart className="h-4 w-4 text-red-500" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.social_network.top_groups.length > 0 ? (
data.social_network.top_groups.map((group: { group_id: string; group_name: string; message_count: number; is_webui?: boolean }, index: number) => (
<div key={group.group_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
{index + 1}
</Badge>
<span className="font-medium truncate max-w-[120px]">{group.group_name}</span>
{group.is_webui && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
WebUI
</Badge>
)}
</div>
<span className="text-muted-foreground text-sm shrink-0">{group.message_count} </span>
</div>
))
) : (
<div className="text-center text-muted-foreground py-4"></div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.social_network.top_users.length > 0 ? (
data.social_network.top_users.map((user: { user_id: string; user_nickname: string; message_count: number; is_webui?: boolean }, index: number) => (
<div key={user.user_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
{index + 1}
</Badge>
<span className="font-medium truncate max-w-[120px]">{user.user_nickname}</span>
{user.is_webui && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
WebUI
</Badge>
)}
</div>
<span className="text-muted-foreground text-sm shrink-0">{user.message_count} </span>
</div>
))
) : (
<div className="text-center text-muted-foreground py-4"></div>
)}
</div>
</CardContent>
</Card>
</div>
</section>
{/* 维度三:最强大脑 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Brain className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="年度 Token 消耗"
value={(data.brain_power.total_tokens / 1000000).toFixed(2) + ' M'}
description={getTokenMetaphor(data.brain_power.total_tokens)}
icon={<Zap className="h-4 w-4" />}
/>
<StatCard
title="年度总花费"
value={`$${data.brain_power.total_cost.toFixed(2)}`}
description={getCostMetaphor(data.brain_power.total_cost)}
icon={<span className="font-bold">$</span>}
/>
<StatCard
title="高冷指数"
value={`${data.brain_power.silence_rate}%`}
description={getSilenceMetaphor(data.brain_power.silence_rate)}
icon={<Moon className="h-4 w-4" />}
/>
<StatCard
title="最高兴趣值"
value={data.brain_power.max_interest_value ?? 'N/A'}
description={data.brain_power.max_interest_time ? `出现在 ${data.brain_power.max_interest_time}` : '暂无数据'}
icon={<Heart className="h-4 w-4" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.brain_power.model_distribution.slice(0, 5).map((item: { model: string; count: number }, index: number) => {
const maxCount = data.brain_power.model_distribution[0]?.count || 1
const percentage = Math.round((item.count / maxCount) * 100)
return (
<div key={item.model} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
<span className="text-muted-foreground">{item.count.toLocaleString()} </span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* 最喜欢的回复模型 TOP5 */}
{data.brain_power.top_reply_models && data.brain_power.top_reply_models.length > 0 && (
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
<CardDescription>{data.bot_name}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.brain_power.top_reply_models.map((item: { model: string; count: number }, index: number) => {
const maxCount = data.brain_power.top_reply_models[0]?.count || 1
const percentage = Math.round((item.count / maxCount) * 100)
return (
<div key={item.model} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
<span className="text-muted-foreground">{item.count.toLocaleString()} </span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* 烧钱大户 - 只有有有效用户数据时才显示 */}
{data.brain_power.top_token_consumers && data.brain_power.top_token_consumers.length > 0 && (
<Card>
<CardHeader>
<CardTitle> TOP3</CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{data.brain_power.top_token_consumers.map((consumer: { user_id: string; cost: number; tokens: number }) => (
<div key={consumer.user_id} className="space-y-2">
<div className="flex justify-between text-sm font-medium">
<span> {consumer.user_id}</span>
<span>${consumer.cost.toFixed(2)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${(consumer.cost / (data.brain_power.top_token_consumers[0]?.cost || 1)) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* 最昂贵的思考 & 思考深度 */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/20 dark:to-orange-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💰</span>
</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="text-4xl font-bold text-amber-600 dark:text-amber-400">
${data.brain_power.most_expensive_cost.toFixed(4)}
</div>
{data.brain_power.most_expensive_time && (
<p className="mt-2 text-sm text-muted-foreground">
{data.brain_power.most_expensive_time}
</p>
)}
<p className="mt-4 text-sm text-muted-foreground">
{getExpensiveThinkingMetaphor(data.brain_power.most_expensive_cost)}
</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/20 dark:to-blue-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🧠</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{data.brain_power.avg_reasoning_length?.toFixed(0) || 0}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{data.brain_power.max_reasoning_length?.toLocaleString() || 0}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{data.brain_power.max_reasoning_time && (
<p className="mt-4 text-center text-xs text-muted-foreground">
{data.brain_power.max_reasoning_time}
</p>
)}
</CardContent>
</Card>
</div>
</section>
{/* 维度四:个性与表达 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Smile className="h-8 w-8" />
<h2></h2>
</div>
{/* 深夜回复 & 最喜欢的回复 */}
{(data.expression_vibe.late_night_reply || data.expression_vibe.favorite_reply) && (
<div className="grid gap-4 md:grid-cols-2">
{data.expression_vibe.late_night_reply && (
<Card className="bg-gradient-to-br from-indigo-50 to-violet-50 dark:from-indigo-950/20 dark:to-violet-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🌙</span>
</CardTitle>
<CardDescription> {data.expression_vibe.late_night_reply.time}{data.bot_name}...</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-lg italic text-muted-foreground">
"{data.expression_vibe.late_night_reply.content}"
</p>
<p className="mt-4 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
)}
{data.expression_vibe.favorite_reply && (
<Card className="bg-gradient-to-br from-rose-50 to-pink-50 dark:from-rose-950/20 dark:to-pink-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💬</span>
</CardTitle>
<CardDescription>使 {data.expression_vibe.favorite_reply.count} </CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-lg font-medium text-primary">
"{data.expression_vibe.favorite_reply.content}"
</p>
<p className="mt-4 text-sm text-muted-foreground">
{getFavoriteReplyMetaphor(data.expression_vibe.favorite_reply.count, data.bot_name)}
</p>
</CardContent>
</Card>
)}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* 使用最多的表情包 TOP3 */}
<Card className="bg-gradient-to-br from-pink-50 to-purple-50 dark:from-pink-950/20 dark:to-purple-950/20">
<CardHeader>
<CardTitle>使 TOP3</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{data.expression_vibe.top_emojis && data.expression_vibe.top_emojis.length > 0 ? (
<div className="flex justify-center gap-4">
{data.expression_vibe.top_emojis.slice(0, 3).map((emoji: { id: number; usage_count: number }, index: number) => (
<div key={emoji.id} className="flex flex-col items-center">
<div className="relative">
<img
src={`/api/webui/emoji/${emoji.id}/thumbnail?original=true`}
alt={`TOP ${index + 1}`}
className="h-24 w-24 rounded-lg object-cover shadow-md transition-transform hover:scale-105"
/>
<Badge
className={cn(
"absolute -top-2 -right-2",
index === 0 ? "bg-yellow-500" : index === 1 ? "bg-gray-400" : "bg-amber-700"
)}
>
{index + 1}
</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{emoji.usage_count} </p>
</div>
))}
</div>
) : (
<div className="flex h-32 items-center justify-center text-muted-foreground"></div>
)}
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{data.bot_name}使</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.expression_vibe.top_expressions.map((exp: { style: string; count: number }, index: number) => (
<Badge
key={exp.style}
variant="outline"
className={cn(
"px-3 py-1 text-sm",
index === 0 && "border-primary bg-primary/10 text-primary text-base px-4 py-2"
)}
>
{exp.style} ({exp.count})
</Badge>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4">
<StatCard
title="图片鉴赏"
value={`${data.expression_vibe.image_processed_count}`}
description={getImageMetaphor(data.expression_vibe.image_processed_count)}
icon={<ImageIcon className="h-4 w-4" />}
/>
<StatCard
title="成长的足迹"
value={`${data.expression_vibe.rejected_expression_count}`}
description={getRejectedMetaphor(data.expression_vibe.rejected_expression_count)}
icon={<Zap className="h-4 w-4" />}
/>
</div>
</div>
</div>
{/* 行动派 */}
{data.expression_vibe.action_types.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{data.expression_vibe.action_types.map((action: { action: string; count: number }) => (
<div
key={action.action}
className="flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2"
>
<span className="font-medium text-primary">{action.action}</span>
<Badge variant="secondary">{action.count} </Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</section>
{/* 维度五:趣味成就 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Trophy className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="col-span-1 md:col-span-2">
<CardHeader>
<CardTitle>"黑话"</CardTitle>
<CardDescription> {data.achievements.new_jargon_count} </CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{data.achievements.sample_jargons.map((jargon: { content: string; meaning: string; count: number }) => (
<div key={jargon.content} className="group relative rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
<div className="font-bold text-primary">{jargon.content}</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2 max-w-[200px]">
{jargon.meaning || '暂无解释'}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="flex flex-col justify-center items-center bg-primary text-primary-foreground">
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
<MessageSquare className="h-12 w-12 mb-4 opacity-80" />
<div className="text-4xl font-bold mb-2">{data.achievements.total_messages.toLocaleString()}</div>
<div className="text-sm opacity-80"></div>
<div className="mt-4 text-xs opacity-60">
{data.achievements.total_replies.toLocaleString()}
</div>
</CardContent>
</Card>
</div>
</section>
{/* 底部 */}
<footer className="mt-12 text-center text-muted-foreground">
<p>MaiBot 2025 Annual Report</p>
<p className="text-sm">Generated with by MaiBot Team</p>
</footer>
</div>
</div>
</ScrollArea>
)
}
function StatCard({
title,
value,
description,
icon,
}: {
title: string
value: string | number
description: string
icon: React.ReactNode
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function LoadingSkeleton() {
return (
<div className="container mx-auto space-y-8 p-8">
<Skeleton className="h-64 w-full rounded-3xl" />
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}

View File

@ -0,0 +1,342 @@
import { useState, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Key, Lock, AlertCircle, Moon, Sun, HelpCircle, FileText, Terminal, Zap } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { WavesBackground } from '@/components/waves-background'
import { useAnimation } from '@/hooks/use-animation'
import { useTheme } from '@/components/use-theme'
import { checkAuthStatus } from '@/lib/fetch-with-auth'
import { cn } from '@/lib/utils'
import { APP_FULL_NAME } from '@/lib/version'
export function AuthPage() {
const [token, setToken] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [error, setError] = useState('')
const [checkingAuth, setCheckingAuth] = useState(true)
const navigate = useNavigate()
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { theme, setTheme } = useTheme()
// 如果已经认证,直接跳转到首页
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
navigate({ to: '/' })
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
}
}
verifyAuth()
}, [navigate])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
}
const actualTheme = getActualTheme()
// 主题切换(无动画)
const toggleTheme = () => {
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!token.trim()) {
setError('请输入 Access Token')
return
}
setIsValidating(true)
console.log('开始验证 token...')
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: token.trim() }),
})
console.log('Token 验证响应状态:', response.status)
const data = await response.json()
console.log('Token 验证响应数据:', data)
if (response.ok && data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise(resolve => setTimeout(resolve, 100))
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
// 需要首次配置,跳转到配置向导
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
// 不需要配置或配置已完成,跳转到首页
navigate({ to: '/' })
}
} else {
console.error('Token 验证失败:', data.message)
setError(data.message || 'Token 验证失败,请检查后重试')
}
} catch (err) {
console.error('Token 验证错误:', err)
setError('连接服务器失败,请检查网络连接')
} finally {
setIsValidating(false)
}
}
// 正在检查认证状态时显示加载
if (checkingAuth) {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{enableWavesBackground && <WavesBackground />}
<div className="text-muted-foreground">...</div>
</div>
)
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{/* 波浪背景 - 独立控制 */}
{enableWavesBackground && <WavesBackground />}
{/* 认证卡片 - 磨砂玻璃效果 */}
<Card className="relative z-10 w-full max-w-md shadow-2xl backdrop-blur-xl bg-card/80 border-border/50">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-accent transition-colors z-10 text-foreground"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
>
{actualTheme === 'dark' ? (
<Sun className="h-5 w-5" strokeWidth={2.5} fill="none" />
) : (
<Moon className="h-5 w-5" strokeWidth={2.5} fill="none" />
)}
</button>
<CardHeader className="space-y-4 text-center">
{/* Logo/Icon */}
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Lock className="h-8 w-8 text-primary" strokeWidth={2} fill="none" />
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-bold">使 MaiBot</CardTitle>
<CardDescription className="text-base">
Access Token 访
</CardDescription>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Token 输入框 */}
<div className="space-y-2">
<Label htmlFor="token" className="text-sm font-medium">
Access Token
</Label>
<div className="relative">
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" strokeWidth={2} fill="none" />
<Input
id="token"
type="password"
placeholder="请输入您的 Access Token"
value={token}
onChange={(e) => setToken(e.target.value)}
className={cn('pl-10', error && 'border-red-500 focus-visible:ring-red-500')}
disabled={isValidating}
autoFocus
autoComplete="off"
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span>{error}</span>
</div>
)}
{/* 提交按钮 */}
<Button type="submit" className="w-full" disabled={isValidating}>
{isValidating ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
'验证并进入'
)}
</Button>
{/* 帮助文本 */}
<Dialog>
<DialogTrigger asChild>
<button className="w-full text-center text-sm text-primary hover:text-primary/80 transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<HelpCircle className="h-4 w-4" strokeWidth={2} fill="none" />
Token Token
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
Access Token
</DialogTitle>
<DialogDescription>
Access Token 访 MaiBot WebUI
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 方式一:查看控制台 */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-start gap-3">
<Terminal className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<p className="text-sm text-muted-foreground">
MaiBot WebUI Access Token
</p>
<div className="rounded bg-background p-2 font-mono text-xs">
<p className="text-muted-foreground">🔑 WebUI Access Token: abc123...</p>
<p className="text-muted-foreground">💡 使 Token WebUI</p>
</div>
</div>
</div>
</div>
{/* 方式二:查看配置文件 */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<p className="text-sm text-muted-foreground">
Token
</p>
<div className="rounded bg-background p-2 font-mono text-xs break-all">
<code className="text-primary">data/webui.json</code>
</div>
<p className="text-xs text-muted-foreground">
<code className="px-1 py-0.5 bg-background rounded">access_token</code>
</p>
</div>
</div>
</div>
{/* 安全提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token</li>
<li> Token</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 性能优化选项 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<Zap className="h-4 w-4" strokeWidth={2} fill="none" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => setEnableWavesBackground(false)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
</CardContent>
</Card>
{/* 页脚信息 */}
<div className="absolute bottom-4 left-0 right-0 text-center text-xs text-muted-foreground">
<p>{APP_FULL_NAME}</p>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

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