深度优化 ui
This commit is contained in:
@@ -40,3 +40,7 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Browser checks
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
|||||||
@@ -144,3 +144,18 @@ sudo systemctl reload nginx
|
|||||||
如果你当前的 hy2 节点仍然直接访问 `http://38.246.237.36:3000/api/hy2/...` 作为鉴权地址,可以继续保留这一入口。上面的 Nginx 只接管 `80/443` 上的域名访问,不会替换或中断现有的 `:3000` 直连。
|
如果你当前的 hy2 节点仍然直接访问 `http://38.246.237.36:3000/api/hy2/...` 作为鉴权地址,可以继续保留这一入口。上面的 Nginx 只接管 `80/443` 上的域名访问,不会替换或中断现有的 `:3000` 直连。
|
||||||
|
|
||||||
面板内部生成链接时会优先使用反代传入的 `X-Forwarded-Host` / `X-Forwarded-Proto`,如果是直接访问 `http://38.246.237.36:3000`,则会自动退回到当前请求的 `Host`。
|
面板内部生成链接时会优先使用反代传入的 `X-Forwarded-Host` / `X-Forwarded-Proto`,如果是直接访问 `http://38.246.237.36:3000`,则会自动退回到当前请求的 `Host`。
|
||||||
|
|
||||||
|
## UI 与交互
|
||||||
|
|
||||||
|
界面使用 shadcn/ui(本地组件位于 `components/ui`),以列表、行内展开和中性色分隔线为主。代理组节点排序使用 dnd-kit,支持鼠标、触屏,以及空格开始、方向键移动、空格放下、Escape 取消的键盘操作。修改排序后点击保存,也可撤销。
|
||||||
|
|
||||||
|
用户、节点和最近 100 条审计记录支持本页即时搜索和状态筛选。用户、节点与模板表单支持 Ctrl/Cmd + Enter 提交,保存失败会保留输入;删除操作通过确认对话框执行。
|
||||||
|
|
||||||
|
运行浏览器回归:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
测试自动启动 3106 端口的开发服务,使用 `/tmp` 中独立的临时数据库并关闭节点轮询,不访问现有数据库。覆盖排序持久化、鼠标/键盘/模拟触屏、列表筛选、用户编辑、复制链接、删除确认、失败保留输入和移动端溢出检查。截图输出在 `test-results/`。运行前请停止同一工作目录的其他 Next 开发服务。
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
Radio,
|
||||||
|
ScrollText,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
@@ -10,18 +17,23 @@ type NavItem = {
|
|||||||
|
|
||||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const icons = [LayoutDashboard, Users, Server, Radio, ScrollText];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="nav-list">
|
<nav className="nav-list">
|
||||||
{items.map((item) => {
|
{items.map((item, index) => {
|
||||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const Icon = icons[index];
|
||||||
|
const active =
|
||||||
|
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
className={`nav-link ${active ? "is-active" : ""}`}
|
className={`nav-link ${active ? "is-active" : ""}`}
|
||||||
>
|
>
|
||||||
|
<Icon size={16} aria-hidden="true" />
|
||||||
<span className="nav-link__label">{item.label}</span>
|
<span className="nav-link__label">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
+40
-16
@@ -1,3 +1,4 @@
|
|||||||
|
import { FilterList } from "@/components/filter-list";
|
||||||
import { formatLocalDateTime } from "@/lib/datetime";
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { formatBytes, listAudits } from "@/lib/store";
|
import { formatBytes, listAudits } from "@/lib/store";
|
||||||
|
|
||||||
@@ -10,17 +11,19 @@ export default async function AuditsPage() {
|
|||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">审计</span>
|
<span className="eyebrow">审计</span>
|
||||||
<h2>鉴权审计</h2>
|
<h2>鉴权审计</h2>
|
||||||
</div>
|
<span className="status-pill">最近 {audits.length} 条</span>
|
||||||
<div className="hero-aside">
|
|
||||||
<div className="stat-tile">
|
|
||||||
<span>最近记录</span>
|
|
||||||
<strong>{audits.length}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card dense-panel">
|
<section className="panel-card dense-panel">
|
||||||
<div className="entity-table entity-table--audits">
|
<FilterList
|
||||||
|
className="entity-table entity-table--audits"
|
||||||
|
placeholder="搜索节点、用户、来源、原因…"
|
||||||
|
statuses={[
|
||||||
|
{ value: "ok", label: "允许" },
|
||||||
|
{ value: "denied", label: "拒绝" },
|
||||||
|
]}
|
||||||
|
header={
|
||||||
<div className="entity-table__head audit-row-grid">
|
<div className="entity-table__head audit-row-grid">
|
||||||
<span>结果</span>
|
<span>结果</span>
|
||||||
<span>节点 / 用户</span>
|
<span>节点 / 用户</span>
|
||||||
@@ -29,27 +32,48 @@ export default async function AuditsPage() {
|
|||||||
<span>原因</span>
|
<span>原因</span>
|
||||||
<span>时间</span>
|
<span>时间</span>
|
||||||
</div>
|
</div>
|
||||||
{audits.map((audit) => (
|
}
|
||||||
<div key={audit.id} className="entity-table__summary audit-row-grid">
|
items={audits.map((audit) => ({
|
||||||
|
id: audit.id,
|
||||||
|
text: `${audit.node_name ?? ""} ${audit.username ?? ""} ${audit.auth_id ?? ""} ${audit.addr ?? ""} ${audit.reason}`,
|
||||||
|
status: audit.ok ? "ok" : "denied",
|
||||||
|
content: (
|
||||||
|
<div
|
||||||
|
key={audit.id}
|
||||||
|
className="entity-table__summary audit-row-grid"
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
<span className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}>
|
<span
|
||||||
|
className={`status-pill ${audit.ok ? "status-pill--ok" : "status-pill--danger"}`}
|
||||||
|
>
|
||||||
{audit.ok ? "允许" : "拒绝"}
|
{audit.ok ? "允许" : "拒绝"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-main-cell">
|
<span className="entity-main-cell">
|
||||||
<strong>{audit.node_name ?? "未知节点"}</strong>
|
<strong>{audit.node_name ?? "未知节点"}</strong>
|
||||||
<span className="muted mono break-all">{audit.username ?? audit.auth_id ?? "-"}</span>
|
<span className="muted mono break-all">
|
||||||
|
{audit.username ?? audit.auth_id ?? "-"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="entity-code-cell break-all">
|
||||||
|
{audit.addr ?? "-"}
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-code-cell break-all">{audit.addr ?? "-"}</span>
|
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>{audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"}</strong>
|
<strong>
|
||||||
|
{audit.requested_tx != null
|
||||||
|
? formatBytes(audit.requested_tx)
|
||||||
|
: "-"}
|
||||||
|
</strong>
|
||||||
<span>/s</span>
|
<span>/s</span>
|
||||||
</span>
|
</span>
|
||||||
<span>{audit.reason}</span>
|
<span>{audit.reason}</span>
|
||||||
<span className="entity-muted-cell">{formatLocalDateTime(audit.created_at)}</span>
|
<span className="entity-muted-cell">
|
||||||
</div>
|
{formatLocalDateTime(audit.created_at)}
|
||||||
))}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { formatLocalDateTime } from "@/lib/datetime";
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
import { formatBytes, getDashboardMetrics } from "@/lib/store";
|
||||||
|
|
||||||
@@ -28,18 +30,12 @@ export default async function DashboardPage() {
|
|||||||
<span className="eyebrow">概览</span>
|
<span className="eyebrow">概览</span>
|
||||||
<h2>仪表盘</h2>
|
<h2>仪表盘</h2>
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">在线 {data.totals.total_online_connections}</span>
|
<span className="status-pill status-pill--ok">
|
||||||
<span className="status-pill status-pill--warm">节点 {data.totals.enabled_nodes}</span>
|
在线 {data.totals.total_online_connections}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="status-pill status-pill--warm">
|
||||||
<div className="hero-aside">
|
节点 {data.totals.enabled_nodes}
|
||||||
<div className="stat-tile">
|
</span>
|
||||||
<span>活动连接</span>
|
|
||||||
<strong>{data.totals.total_online_connections}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stat-tile">
|
|
||||||
<span>累计流量</span>
|
|
||||||
<strong>{formatBytes(data.totals.total_traffic)}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -48,7 +44,9 @@ export default async function DashboardPage() {
|
|||||||
<article className="metric-card metric-card--warm">
|
<article className="metric-card metric-card--warm">
|
||||||
<span>总用户</span>
|
<span>总用户</span>
|
||||||
<strong>{data.totals.total_users}</strong>
|
<strong>{data.totals.total_users}</strong>
|
||||||
<small>启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users}</small>
|
<small>
|
||||||
|
启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users}
|
||||||
|
</small>
|
||||||
</article>
|
</article>
|
||||||
<article className="metric-card metric-card--ink">
|
<article className="metric-card metric-card--ink">
|
||||||
<span>活跃用户</span>
|
<span>活跃用户</span>
|
||||||
@@ -81,8 +79,17 @@ export default async function DashboardPage() {
|
|||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>节点健康</h3>
|
<h3>节点健康</h3>
|
||||||
|
<Link
|
||||||
|
href="/admin/nodes"
|
||||||
|
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
||||||
|
>
|
||||||
|
管理节点 →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack-md">
|
<div className="stack-md">
|
||||||
|
{data.nodeHealth.length === 0 && (
|
||||||
|
<p className="muted">暂无节点,请先添加节点。</p>
|
||||||
|
)}
|
||||||
{data.nodeHealth.map((node) => (
|
{data.nodeHealth.map((node) => (
|
||||||
<div key={node.id} className="list-row list-row--card">
|
<div key={node.id} className="list-row list-row--card">
|
||||||
<div>
|
<div>
|
||||||
@@ -113,8 +120,17 @@ export default async function DashboardPage() {
|
|||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>用量排行</h3>
|
<h3>用量排行</h3>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
||||||
|
>
|
||||||
|
管理用户 →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack-md">
|
<div className="stack-md">
|
||||||
|
{data.topUsers.length === 0 && (
|
||||||
|
<p className="muted">暂无用户用量。</p>
|
||||||
|
)}
|
||||||
{data.topUsers.map((user) => (
|
{data.topUsers.map((user) => (
|
||||||
<div key={user.auth_id} className="list-row list-row--card">
|
<div key={user.auth_id} className="list-row list-row--card">
|
||||||
<div>
|
<div>
|
||||||
@@ -124,7 +140,8 @@ export default async function DashboardPage() {
|
|||||||
<div className="align-right">
|
<div className="align-right">
|
||||||
<p>{formatBytes(user.total_bytes)}</p>
|
<p>{formatBytes(user.total_bytes)}</p>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}
|
TX {formatBytes(user.used_tx_bytes)} / RX{" "}
|
||||||
|
{formatBytes(user.used_rx_bytes)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,19 +152,34 @@ export default async function DashboardPage() {
|
|||||||
<article className="panel-card">
|
<article className="panel-card">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>最近鉴权失败</h3>
|
<h3>最近鉴权失败</h3>
|
||||||
|
<Link
|
||||||
|
href="/admin/audits"
|
||||||
|
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
||||||
|
>
|
||||||
|
查看日志 →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack-md">
|
<div className="stack-md">
|
||||||
|
{data.recentFailures.length === 0 && (
|
||||||
|
<p className="muted">暂无鉴权失败。</p>
|
||||||
|
)}
|
||||||
{data.recentFailures.map((item, index) => (
|
{data.recentFailures.map((item, index) => (
|
||||||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
<div
|
||||||
|
key={`${item.created_at}-${index}`}
|
||||||
|
className="list-row list-row--card"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.reason}</strong>
|
<strong>{item.reason}</strong>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{item.node_name ?? "未知节点"} / {item.username ?? "未知用户"}
|
{item.node_name ?? "未知节点"} /{" "}
|
||||||
|
{item.username ?? "未知用户"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="align-right">
|
<div className="align-right">
|
||||||
<p className="muted mono">{item.addr ?? "-"}</p>
|
<p className="muted mono">{item.addr ?? "-"}</p>
|
||||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
<p className="muted">
|
||||||
|
{formatLocalDateTime(item.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -159,17 +191,23 @@ export default async function DashboardPage() {
|
|||||||
<h3>最近流量入账</h3>
|
<h3>最近流量入账</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack-md">
|
<div className="stack-md">
|
||||||
|
{data.recentUsage.length === 0 && (
|
||||||
|
<p className="muted">暂无流量入账记录。</p>
|
||||||
|
)}
|
||||||
{data.recentUsage.map((item, index) => (
|
{data.recentUsage.map((item, index) => (
|
||||||
<div key={`${item.created_at}-${item.auth_id}-${index}`} className="list-row list-row--card">
|
<div
|
||||||
|
key={`${item.created_at}-${item.auth_id}-${index}`}
|
||||||
|
className="list-row list-row--card"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.username ?? item.auth_id}</strong>
|
<strong>{item.username ?? item.auth_id}</strong>
|
||||||
<p className="muted">{item.node_name ?? "未知节点"}</p>
|
<p className="muted">{item.node_name ?? "未知节点"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="align-right">
|
<div className="align-right">
|
||||||
<p>
|
<p>+{formatBytes(item.tx_bytes + item.rx_bytes)}</p>
|
||||||
+{formatBytes(item.tx_bytes + item.rx_bytes)}
|
<p className="muted">
|
||||||
|
{formatLocalDateTime(item.created_at)}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -185,12 +223,19 @@ export default async function DashboardPage() {
|
|||||||
<p className="muted">暂无同步错误。</p>
|
<p className="muted">暂无同步错误。</p>
|
||||||
) : (
|
) : (
|
||||||
data.syncErrors.map((item, index) => (
|
data.syncErrors.map((item, index) => (
|
||||||
<div key={`${item.created_at}-${index}`} className="list-row list-row--card">
|
<div
|
||||||
|
key={`${item.created_at}-${index}`}
|
||||||
|
className="list-row list-row--card"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.node_name}</strong>
|
<strong>{item.node_name}</strong>
|
||||||
<p className="error-text">{item.error_message ?? "unknown error"}</p>
|
<p className="error-text">
|
||||||
|
{item.error_message ?? "unknown error"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">{formatLocalDateTime(item.created_at)}</p>
|
<p className="muted">
|
||||||
|
{formatLocalDateTime(item.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { requireAdminAccess } from "@/lib/session";
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
|
||||||
import { AdminNav } from "./admin-nav";
|
import { AdminNav } from "./admin-nav";
|
||||||
@@ -24,14 +25,14 @@ export default async function AdminLayout({
|
|||||||
<span className="eyebrow">HY2 PANEL</span>
|
<span className="eyebrow">HY2 PANEL</span>
|
||||||
<h1>鹅梯控制台</h1>
|
<h1>鹅梯控制台</h1>
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ink">{session.username}</span>
|
<span className="status-pill status-pill--ink">
|
||||||
|
{session.username}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AdminNav items={navItems} />
|
<AdminNav items={navItems} />
|
||||||
<form action="/logout" method="post">
|
<form action="/logout" method="post">
|
||||||
<button className="ghost-button full-width" type="submit">
|
<SubmitButton variant="outline">退出登录</SubmitButton>
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="content-shell">
|
<div className="content-shell">
|
||||||
|
|||||||
+213
-71
@@ -1,9 +1,19 @@
|
|||||||
|
import { ActionForm } from "@/components/action-form";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { FilterList } from "@/components/filter-list";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
import { formatLocalDateTime } from "@/lib/datetime";
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { buildNodeHy2Config } from "@/lib/subscription";
|
import { buildNodeHy2Config } from "@/lib/subscription";
|
||||||
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store";
|
||||||
|
|
||||||
import { createNodeAction, deleteNodeAction, updateNodeAction } from "./actions";
|
import {
|
||||||
|
createNodeAction,
|
||||||
|
deleteNodeAction,
|
||||||
|
updateNodeAction,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
function getNodePills(node: {
|
function getNodePills(node: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
@@ -30,18 +40,12 @@ export default async function NodesPage() {
|
|||||||
<span className="eyebrow">节点</span>
|
<span className="eyebrow">节点</span>
|
||||||
<h2>节点管理</h2>
|
<h2>节点管理</h2>
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">启用 {nodes.filter((node) => node.enabled).length}</span>
|
<span className="status-pill status-pill--ok">
|
||||||
<span className="status-pill status-pill--neutral">总数 {nodes.length}</span>
|
启用 {nodes.filter((node) => node.enabled).length}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="status-pill status-pill--neutral">
|
||||||
<div className="hero-aside">
|
总数 {nodes.length}
|
||||||
<div className="stat-tile">
|
</span>
|
||||||
<span>节点总数</span>
|
|
||||||
<strong>{nodes.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stat-tile">
|
|
||||||
<span>启用中</span>
|
|
||||||
<strong>{nodes.filter((node) => node.enabled).length}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -54,46 +58,76 @@ export default async function NodesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="status-pill status-pill--warm">展开</span>
|
<span className="status-pill status-pill--warm">展开</span>
|
||||||
</summary>
|
</summary>
|
||||||
<form action={createNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
<ActionForm
|
||||||
|
action={createNodeAction}
|
||||||
|
className="dense-form-grid dense-form-grid--nodes"
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Slug</span>
|
<span>Slug</span>
|
||||||
<input name="slug" placeholder="tokyo-01" required />
|
<Input name="slug" placeholder="tokyo-01" required />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>显示名</span>
|
<span>显示名</span>
|
||||||
<input name="name" placeholder="Tokyo 01" required />
|
<Input name="name" placeholder="Tokyo 01" required />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>客户端地址</span>
|
<span>客户端地址</span>
|
||||||
<input name="server_host" placeholder="jp1.example.com" required />
|
<Input
|
||||||
|
name="server_host"
|
||||||
|
placeholder="jp1.example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>hy2 监听地址</span>
|
<span>hy2 监听地址</span>
|
||||||
<input name="hy2_listen" defaultValue=":443" placeholder=":443 或 0.0.0.0:443" />
|
<Input
|
||||||
|
name="hy2_listen"
|
||||||
|
defaultValue=":443"
|
||||||
|
placeholder=":443 或 0.0.0.0:443"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>对外端口</span>
|
<span>对外端口</span>
|
||||||
<input name="server_port" type="number" min="1" max="65535" defaultValue={443} required />
|
<Input
|
||||||
|
name="server_port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
defaultValue={443}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Clash 节点名</span>
|
<span>Clash 节点名</span>
|
||||||
<input name="client_name" placeholder="JP1 - 30M - hy2" />
|
<Input name="client_name" placeholder="JP1 - 30M - hy2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>鉴权 Token</span>
|
<span>鉴权 Token</span>
|
||||||
<input name="auth_token" placeholder="留空自动生成" />
|
<Input name="auth_token" placeholder="留空自动生成" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>上行带宽 Mbps</span>
|
<span>上行带宽 Mbps</span>
|
||||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={30} required />
|
<Input
|
||||||
|
name="bandwidth_up_mbps"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={30}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>下行带宽 Mbps</span>
|
<span>下行带宽 Mbps</span>
|
||||||
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={30} required />
|
<Input
|
||||||
|
name="bandwidth_down_mbps"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={30}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>SNI</span>
|
<span>SNI</span>
|
||||||
<input name="sni" placeholder="留空使用默认" />
|
<Input name="sni" placeholder="留空使用默认" />
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="skip_cert_verify" type="checkbox" />
|
<input name="skip_cert_verify" type="checkbox" />
|
||||||
@@ -101,37 +135,55 @@ export default async function NodesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats 监听</span>
|
<span>trafficStats 监听</span>
|
||||||
<input name="traffic_stats_listen" defaultValue=":9999" required />
|
<Input
|
||||||
|
name="traffic_stats_listen"
|
||||||
|
defaultValue=":9999"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats URL</span>
|
<span>trafficStats URL</span>
|
||||||
<input name="traffic_stats_url" placeholder="留空自动推导" />
|
<Input name="traffic_stats_url" placeholder="留空自动推导" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats Secret</span>
|
<span>trafficStats Secret</span>
|
||||||
<input name="traffic_stats_secret" placeholder="留空自动生成" />
|
<Input name="traffic_stats_secret" placeholder="留空自动生成" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>轮询间隔(秒)</span>
|
<span>轮询间隔(秒)</span>
|
||||||
<input name="poll_interval_seconds" type="number" min="10" max="30" defaultValue={15} required />
|
<Input
|
||||||
|
name="poll_interval_seconds"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="30"
|
||||||
|
defaultValue={15}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="enabled" type="checkbox" defaultChecked />
|
<input name="enabled" type="checkbox" defaultChecked />
|
||||||
<span>启用节点</span>
|
<span>启用节点</span>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<SubmitButton variant="default">创建节点</SubmitButton>
|
||||||
创建节点
|
</ActionForm>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card dense-panel">
|
<section className="panel-card dense-panel">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>节点列表</h3>
|
<h3>节点列表</h3>
|
||||||
<span className="status-pill status-pill--neutral">{nodes.length} 个节点</span>
|
<span className="status-pill status-pill--neutral">
|
||||||
|
{nodes.length} 个节点
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="entity-table entity-table--nodes">
|
<FilterList
|
||||||
|
className="entity-table entity-table--nodes"
|
||||||
|
placeholder="搜索节点、地址、Slug…"
|
||||||
|
statuses={[
|
||||||
|
{ value: "enabled", label: "已启用" },
|
||||||
|
{ value: "disabled", label: "已禁用" },
|
||||||
|
]}
|
||||||
|
header={
|
||||||
<div className="entity-table__head node-row-grid">
|
<div className="entity-table__head node-row-grid">
|
||||||
<span>节点</span>
|
<span>节点</span>
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
@@ -141,14 +193,23 @@ export default async function NodesPage() {
|
|||||||
<span>最近同步</span>
|
<span>最近同步</span>
|
||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
{nodes.map((node) => {
|
}
|
||||||
const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin);
|
items={nodes.map((node) => {
|
||||||
|
const authUrl = buildNodeAuthUrl(
|
||||||
|
node.slug,
|
||||||
|
node.auth_token,
|
||||||
|
origin,
|
||||||
|
);
|
||||||
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
const suggestedTrafficStatsUrl = buildTrafficStatsUrl(
|
||||||
node.server_host,
|
node.server_host,
|
||||||
node.traffic_stats_listen,
|
node.traffic_stats_listen,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return {
|
||||||
|
id: node.id,
|
||||||
|
text: `${node.name} ${node.slug} ${node.server_host} ${node.client_name}`,
|
||||||
|
status: node.enabled ? "enabled" : "disabled",
|
||||||
|
content: (
|
||||||
<details key={node.id} className="entity-table__item">
|
<details key={node.id} className="entity-table__item">
|
||||||
<summary className="entity-table__summary node-row-grid">
|
<summary className="entity-table__summary node-row-grid">
|
||||||
<span className="entity-main-cell">
|
<span className="entity-main-cell">
|
||||||
@@ -171,20 +232,32 @@ export default async function NodesPage() {
|
|||||||
<small>{node.traffic_stats_listen}</small>
|
<small>{node.traffic_stats_listen}</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>{node.last_online_users} / {node.last_stream_count}</strong>
|
<strong>
|
||||||
|
{node.last_online_users} / {node.last_stream_count}
|
||||||
|
</strong>
|
||||||
<span>用户 / 流</span>
|
<span>用户 / 流</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-muted-cell">
|
<span className="entity-muted-cell">
|
||||||
{formatLocalDateTime(node.last_sync_ok_at)}
|
{formatLocalDateTime(node.last_sync_ok_at)}
|
||||||
{node.last_error_at ? <small>错误 {formatLocalDateTime(node.last_error_at)}</small> : null}
|
{node.last_error_at ? (
|
||||||
|
<small>
|
||||||
|
错误 {formatLocalDateTime(node.last_error_at)}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="entity-expand-label">
|
||||||
|
<span className="when-closed">编辑</span>
|
||||||
|
<span className="when-open">收起</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-expand-label">编辑</span>
|
|
||||||
</summary>
|
</summary>
|
||||||
<div className="entity-table__details">
|
<div className="entity-table__details">
|
||||||
<div className="dense-code-grid">
|
<div className="dense-code-grid">
|
||||||
<div className="dense-code-line">
|
<div className="dense-code-line">
|
||||||
<span>鉴权 URL</span>
|
<span>鉴权 URL</span>
|
||||||
|
<div className="copy-line">
|
||||||
<code>{authUrl}</code>
|
<code>{authUrl}</code>
|
||||||
|
<CopyButton value={authUrl} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dense-code-line">
|
<div className="dense-code-line">
|
||||||
<span>自动推导 trafficStats</span>
|
<span>自动推导 trafficStats</span>
|
||||||
@@ -192,83 +265,148 @@ export default async function NodesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action={updateNodeAction} className="dense-form-grid dense-form-grid--nodes">
|
<ActionForm
|
||||||
|
action={updateNodeAction}
|
||||||
|
className="dense-form-grid dense-form-grid--nodes"
|
||||||
|
>
|
||||||
<input name="id" type="hidden" value={node.id} />
|
<input name="id" type="hidden" value={node.id} />
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Slug</span>
|
<span>Slug</span>
|
||||||
<input name="slug" defaultValue={node.slug} required />
|
<Input name="slug" defaultValue={node.slug} required />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>显示名</span>
|
<span>显示名</span>
|
||||||
<input name="name" defaultValue={node.name} required />
|
<Input name="name" defaultValue={node.name} required />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>客户端地址</span>
|
<span>客户端地址</span>
|
||||||
<input name="server_host" defaultValue={node.server_host} required />
|
<Input
|
||||||
|
name="server_host"
|
||||||
|
defaultValue={node.server_host}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>hy2 监听地址</span>
|
<span>hy2 监听地址</span>
|
||||||
<input name="hy2_listen" defaultValue={node.hy2_listen} placeholder=":443 或 0.0.0.0:443" />
|
<Input
|
||||||
|
name="hy2_listen"
|
||||||
|
defaultValue={node.hy2_listen}
|
||||||
|
placeholder=":443 或 0.0.0.0:443"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>对外端口</span>
|
<span>对外端口</span>
|
||||||
<input name="server_port" type="number" min="1" max="65535" defaultValue={node.server_port} required />
|
<Input
|
||||||
|
name="server_port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
defaultValue={node.server_port}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Clash 节点名</span>
|
<span>Clash 节点名</span>
|
||||||
<input name="client_name" defaultValue={node.client_name} />
|
<Input
|
||||||
|
name="client_name"
|
||||||
|
defaultValue={node.client_name}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>鉴权 Token</span>
|
<span>鉴权 Token</span>
|
||||||
<input name="auth_token" defaultValue={node.auth_token} required />
|
<Input
|
||||||
|
name="auth_token"
|
||||||
|
defaultValue={node.auth_token}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>上行带宽 Mbps</span>
|
<span>上行带宽 Mbps</span>
|
||||||
<input name="bandwidth_up_mbps" type="number" min="1" defaultValue={node.bandwidth_up_mbps} required />
|
<Input
|
||||||
|
name="bandwidth_up_mbps"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={node.bandwidth_up_mbps}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>下行带宽 Mbps</span>
|
<span>下行带宽 Mbps</span>
|
||||||
<input name="bandwidth_down_mbps" type="number" min="1" defaultValue={node.bandwidth_down_mbps} required />
|
<Input
|
||||||
|
name="bandwidth_down_mbps"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={node.bandwidth_down_mbps}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>SNI</span>
|
<span>SNI</span>
|
||||||
<input name="sni" defaultValue={node.sni ?? ""} />
|
<Input name="sni" defaultValue={node.sni ?? ""} />
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="skip_cert_verify" type="checkbox" defaultChecked={Boolean(node.skip_cert_verify)} />
|
<input
|
||||||
|
name="skip_cert_verify"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(node.skip_cert_verify)}
|
||||||
|
/>
|
||||||
<span>跳过证书校验</span>
|
<span>跳过证书校验</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats 监听</span>
|
<span>trafficStats 监听</span>
|
||||||
<input name="traffic_stats_listen" defaultValue={node.traffic_stats_listen} required />
|
<Input
|
||||||
|
name="traffic_stats_listen"
|
||||||
|
defaultValue={node.traffic_stats_listen}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats URL</span>
|
<span>trafficStats URL</span>
|
||||||
<input name="traffic_stats_url" defaultValue={node.traffic_stats_url} />
|
<Input
|
||||||
|
name="traffic_stats_url"
|
||||||
|
defaultValue={node.traffic_stats_url}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>trafficStats Secret</span>
|
<span>trafficStats Secret</span>
|
||||||
<input name="traffic_stats_secret" defaultValue={node.traffic_stats_secret} placeholder="留空保持当前值" />
|
<Input
|
||||||
|
name="traffic_stats_secret"
|
||||||
|
defaultValue={node.traffic_stats_secret}
|
||||||
|
placeholder="留空保持当前值"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>轮询间隔(秒)</span>
|
<span>轮询间隔(秒)</span>
|
||||||
<input name="poll_interval_seconds" type="number" min="10" max="30" defaultValue={node.poll_interval_seconds} required />
|
<Input
|
||||||
|
name="poll_interval_seconds"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="30"
|
||||||
|
defaultValue={node.poll_interval_seconds}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input name="enabled" type="checkbox" defaultChecked={Boolean(node.enabled)} />
|
<input
|
||||||
|
name="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(node.enabled)}
|
||||||
|
/>
|
||||||
<span>启用节点</span>
|
<span>启用节点</span>
|
||||||
</label>
|
</label>
|
||||||
<button className="ghost-button" type="submit">
|
<SubmitButton variant="outline">保存节点</SubmitButton>
|
||||||
保存节点
|
</ActionForm>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<details className="config-collapse">
|
<details className="config-collapse">
|
||||||
<summary>查看 hy2 config.yaml</summary>
|
<summary>查看 hy2 config.yaml</summary>
|
||||||
<pre>
|
<pre>{buildNodeHy2Config(node, origin)}</pre>
|
||||||
{buildNodeHy2Config(node, origin)}
|
<a
|
||||||
</pre>
|
className={buttonVariants({
|
||||||
<a className="ghost-button inline-button" href={`/api/admin/nodes/${node.slug}/config`}>
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
})}
|
||||||
|
href={`/api/admin/nodes/${node.slug}/config`}
|
||||||
|
>
|
||||||
下载 hy2 config.yaml
|
下载 hy2 config.yaml
|
||||||
</a>
|
</a>
|
||||||
</details>
|
</details>
|
||||||
@@ -278,18 +416,22 @@ export default async function NodesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="entity-action-strip">
|
<div className="entity-action-strip">
|
||||||
<form action={deleteNodeAction}>
|
<ActionForm action={deleteNodeAction}>
|
||||||
<input name="id" type="hidden" value={node.id} />
|
<input name="id" type="hidden" value={node.id} />
|
||||||
<button className="danger-button" type="submit">
|
<SubmitButton
|
||||||
|
variant="destructive"
|
||||||
|
confirm={`将永久删除节点「${node.name}」及其面板配置。`}
|
||||||
|
>
|
||||||
删除节点
|
删除节点
|
||||||
</button>
|
</SubmitButton>
|
||||||
</form>
|
</ActionForm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
),
|
||||||
|
};
|
||||||
})}
|
})}
|
||||||
</div>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import { ActionForm } from "@/components/action-form";
|
||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
import { buildSubscriptionUrl, getDefaultClashTemplate } from "@/lib/subscription";
|
import {
|
||||||
|
buildSubscriptionUrl,
|
||||||
|
getDefaultClashTemplate,
|
||||||
|
} from "@/lib/subscription";
|
||||||
import {
|
import {
|
||||||
getAdminMirrorUser,
|
getAdminMirrorUser,
|
||||||
getSubscriptionSettings,
|
getSubscriptionSettings,
|
||||||
@@ -32,18 +39,12 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
<span className="eyebrow">订阅</span>
|
<span className="eyebrow">订阅</span>
|
||||||
<h2>订阅与模板</h2>
|
<h2>订阅与模板</h2>
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">节点 {nodes.length}</span>
|
<span className="status-pill status-pill--ok">
|
||||||
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
节点 {nodes.length}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="status-pill status-pill--ink">
|
||||||
<div className="hero-aside">
|
分组 {settings.proxyGroups.length}
|
||||||
<div className="stat-tile">
|
</span>
|
||||||
<span>可用节点</span>
|
|
||||||
<strong>{nodes.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stat-tile">
|
|
||||||
<span>默认主组</span>
|
|
||||||
<strong>{settings.profileName}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -54,9 +55,17 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{adminUser?.subscription_token ? (
|
{adminUser?.subscription_token ? (
|
||||||
<div className="instruction-block no-margin">
|
<div className="instruction-block no-margin">
|
||||||
<p className="muted mono break-all">
|
<div className="copy-line">
|
||||||
|
<code>
|
||||||
{buildSubscriptionUrl(adminUser.subscription_token, origin)}
|
{buildSubscriptionUrl(adminUser.subscription_token, origin)}
|
||||||
</p>
|
</code>
|
||||||
|
<CopyButton
|
||||||
|
value={buildSubscriptionUrl(
|
||||||
|
adminUser.subscription_token,
|
||||||
|
origin,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">管理员订阅链接暂不可用。</p>
|
<p className="muted">管理员订阅链接暂不可用。</p>
|
||||||
@@ -81,21 +90,43 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{settings.proxyGroups.map((group) => (
|
{settings.proxyGroups.map((group) => (
|
||||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
<details key={group.id} className="group-disclosure">
|
||||||
|
<summary className="group-summary">
|
||||||
|
<strong>{group.name}</strong>
|
||||||
|
<span>{group.memberSlugs.length} 个节点 · 编辑分组</span>
|
||||||
|
</summary>
|
||||||
|
<ProxyGroupEditor group={group} nodes={proxyGroupNodes} />
|
||||||
|
</details>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<ProxyGroupEditor key={`new-${settings.proxyGroups.length}`} nodes={proxyGroupNodes} />
|
<details
|
||||||
|
key={`new-${settings.proxyGroups.length}`}
|
||||||
|
className="group-disclosure"
|
||||||
|
>
|
||||||
|
<summary className="group-summary">
|
||||||
|
<strong>新建分组</strong>
|
||||||
|
</summary>
|
||||||
|
<ProxyGroupEditor nodes={proxyGroupNodes} />
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card">
|
<details className="panel-card group-disclosure">
|
||||||
<div className="section-title">
|
<summary className="group-summary">
|
||||||
<h3>订阅模板</h3>
|
<strong>订阅模板</strong>
|
||||||
</div>
|
<span>编辑 YAML 与默认组名</span>
|
||||||
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
</summary>
|
||||||
|
<ActionForm
|
||||||
|
action={saveSubscriptionSettingsAction}
|
||||||
|
className="stack-lg"
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>订阅文件名 / 默认主组名</span>
|
<span>订阅文件名 / 默认主组名</span>
|
||||||
<input name="profile_name" defaultValue={settings.profileName} required />
|
<Input
|
||||||
|
name="profile_name"
|
||||||
|
defaultValue={settings.profileName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>基础模板</span>
|
<span>基础模板</span>
|
||||||
@@ -114,11 +145,9 @@ export default async function AdminSubscriptionsPage() {
|
|||||||
placeholder={` - name: ChatGPT\n type: select\n proxies: ["US 01 - hy2", "JP 01 - hy2"]`}
|
placeholder={` - name: ChatGPT\n type: select\n proxies: ["US 01 - hy2", "JP 01 - hy2"]`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<SubmitButton variant="default">保存订阅模板</SubmitButton>
|
||||||
保存订阅模板
|
</ActionForm>
|
||||||
</button>
|
</details>
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useDeferredValue, useState } from "react";
|
import { useDeferredValue, useState, useId, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
arrayMove,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { GripVertical, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ActionForm } from "@/components/action-form";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -55,33 +76,93 @@ function normalizeSelectedSlugs(
|
|||||||
return selectedSlugs;
|
return selectedSlugs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
function EditorFields({ children }: { children: ReactNode }) {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
|
||||||
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<fieldset disabled={pending} className="stack-md min-w-0 border-0 p-0 m-0">
|
||||||
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
{children}
|
||||||
type="submit"
|
</fieldset>
|
||||||
disabled={pending}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableNode({
|
||||||
|
node,
|
||||||
|
index,
|
||||||
|
removable,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
node: ProxyGroupNodeOption;
|
||||||
|
index: number;
|
||||||
|
removable: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: node.slug });
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="proxy-sort-item"
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
position: "relative",
|
||||||
|
zIndex: isDragging ? 2 : undefined,
|
||||||
|
background: isDragging ? "#f4f4f5" : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{pending ? pendingLabel : label}
|
<Button
|
||||||
</button>
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label={`拖拽排序 ${node.label}`}
|
||||||
|
title="拖拽排序;键盘按空格开始,方向键移动,空格放下"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="drag-handle"
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</Button>
|
||||||
|
<span className="proxy-sort-item__index">{index + 1}</span>
|
||||||
|
<div className="stack-tight min-w-0 flex-1">
|
||||||
|
<strong>{node.label}</strong>
|
||||||
|
<span className="proxy-sort-item__sub">{node.meta}</span>
|
||||||
|
</div>
|
||||||
|
{removable && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label={`移除 ${node.label}`}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteButton() {
|
export function ProxyGroupEditor({
|
||||||
const { pending } = useFormStatus();
|
mode = "custom",
|
||||||
|
group,
|
||||||
return (
|
nodes,
|
||||||
<button className="danger-button" type="submit" disabled={pending}>
|
}: ProxyGroupEditorProps) {
|
||||||
{pending ? "删除中..." : "删除分组"}
|
const dndId = useId();
|
||||||
</button>
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
|
|
||||||
const isMainGroup = mode === "main";
|
const isMainGroup = mode === "main";
|
||||||
const isEditing = Boolean(group) && !isMainGroup;
|
const isEditing = Boolean(group) && !isMainGroup;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -89,6 +170,15 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||||
);
|
);
|
||||||
|
const [name, setName] = useState(group?.name ?? "");
|
||||||
|
const [saved, setSaved] = useState(() =>
|
||||||
|
JSON.stringify([
|
||||||
|
group?.name ?? "",
|
||||||
|
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const dirty = saved !== JSON.stringify([name, selectedSlugs]);
|
||||||
const selectedSlugSet = new Set(selectedSlugs);
|
const selectedSlugSet = new Set(selectedSlugs);
|
||||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||||
const selectedNodes = selectedSlugs
|
const selectedNodes = selectedSlugs
|
||||||
@@ -111,6 +201,7 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
});
|
});
|
||||||
|
|
||||||
function addNode(slug: string) {
|
function addNode(slug: string) {
|
||||||
|
setFeedback("");
|
||||||
setSelectedSlugs((current) => {
|
setSelectedSlugs((current) => {
|
||||||
if (current.includes(slug)) {
|
if (current.includes(slug)) {
|
||||||
return current;
|
return current;
|
||||||
@@ -121,25 +212,14 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeNode(slug: string) {
|
function removeNode(slug: string) {
|
||||||
setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug));
|
setFeedback("");
|
||||||
}
|
setSelectedSlugs((current) =>
|
||||||
|
current.filter((currentSlug) => currentSlug !== slug),
|
||||||
function moveNode(slug: string, direction: -1 | 1) {
|
);
|
||||||
setSelectedSlugs((current) => {
|
|
||||||
const index = current.indexOf(slug);
|
|
||||||
const nextIndex = index + direction;
|
|
||||||
|
|
||||||
if (index === -1 || nextIndex < 0 || nextIndex >= current.length) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = [...current];
|
|
||||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAllVisibleNodes() {
|
function addAllVisibleNodes() {
|
||||||
|
setFeedback("");
|
||||||
setSelectedSlugs((current) => {
|
setSelectedSlugs((current) => {
|
||||||
const next = [...current];
|
const next = [...current];
|
||||||
const seen = new Set(current);
|
const seen = new Set(current);
|
||||||
@@ -158,23 +238,49 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
|
setFeedback("");
|
||||||
setSelectedSlugs([]);
|
setSelectedSlugs([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="instruction-block proxy-group-card">
|
<article className="instruction-block proxy-group-card">
|
||||||
<form action={isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction} className="stack-md">
|
<form
|
||||||
{group && !isMainGroup ? <input type="hidden" name="id" value={group.id} /> : null}
|
action={async (formData) => {
|
||||||
|
setFeedback("");
|
||||||
|
try {
|
||||||
|
await (
|
||||||
|
isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction
|
||||||
|
)(formData);
|
||||||
|
setSaved(JSON.stringify([name, selectedSlugs]));
|
||||||
|
setFeedback("已保存");
|
||||||
|
} catch {
|
||||||
|
setFeedback("保存失败,请重试。当前修改已保留。");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="stack-md"
|
||||||
|
>
|
||||||
|
<EditorFields>
|
||||||
|
{group && !isMainGroup ? (
|
||||||
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
) : null}
|
||||||
<div className="section-title section-title--top">
|
<div className="section-title section-title--top">
|
||||||
<div className="stack-tight">
|
<div className="stack-tight">
|
||||||
<h4 className="proxy-group-title">
|
<h4 className="proxy-group-title">
|
||||||
{isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}
|
{isMainGroup
|
||||||
|
? `默认主分组:${group?.name ?? "FeieProxy"}`
|
||||||
|
: isEditing
|
||||||
|
? group?.name
|
||||||
|
: "新建分组"}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="badge-row badge-row--end">
|
<div className="badge-row badge-row--end">
|
||||||
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
<span className="status-pill status-pill--ink">
|
||||||
|
已选 {selectedNodes.length}
|
||||||
|
</span>
|
||||||
{!isMainGroup ? (
|
{!isMainGroup ? (
|
||||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
<span className="status-pill status-pill--warm">
|
||||||
|
未加入 {remainingCount}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,11 +288,22 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
{!isMainGroup ? (
|
{!isMainGroup ? (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
setFeedback("");
|
||||||
|
}}
|
||||||
|
placeholder="ChatGPT"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}>
|
<div
|
||||||
|
className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}
|
||||||
|
>
|
||||||
{!isMainGroup ? (
|
{!isMainGroup ? (
|
||||||
<section className="proxy-group-panel">
|
<section className="proxy-group-panel">
|
||||||
<div className="proxy-group-panel__header">
|
<div className="proxy-group-panel__header">
|
||||||
@@ -196,6 +313,7 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
<div className="proxy-group-toolbar">
|
<div className="proxy-group-toolbar">
|
||||||
<label className="proxy-group-search">
|
<label className="proxy-group-search">
|
||||||
<input
|
<input
|
||||||
|
aria-label="搜索候选节点"
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
@@ -207,21 +325,23 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
placeholder="搜索节点名称 / slug"
|
placeholder="搜索节点名称 / slug"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<Button
|
||||||
className="ghost-button proxy-toolbar-button"
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addAllVisibleNodes}
|
onClick={addAllVisibleNodes}
|
||||||
disabled={availableNodes.length === 0}
|
disabled={availableNodes.length === 0}
|
||||||
>
|
>
|
||||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableNodes.length > 0 ? (
|
{availableNodes.length > 0 ? (
|
||||||
<div className="proxy-node-grid">
|
<div className="proxy-node-grid">
|
||||||
{availableNodes.map((node) => (
|
{availableNodes.map((node) => (
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
key={node.slug}
|
key={node.slug}
|
||||||
className="proxy-node-chip"
|
className="proxy-node-chip"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,7 +352,7 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
<span>{node.meta}</span>
|
<span>{node.meta}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="proxy-node-chip__action">添加</span>
|
<span className="proxy-node-chip__action">添加</span>
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -249,79 +369,124 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
|||||||
<div className="proxy-group-panel__header">
|
<div className="proxy-group-panel__header">
|
||||||
<div className="stack-tight">
|
<div className="stack-tight">
|
||||||
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||||
|
<span className="muted text-xs">拖动手柄排序 · 支持键盘</span>
|
||||||
</div>
|
</div>
|
||||||
{!isMainGroup ? (
|
{!isMainGroup ? (
|
||||||
<button
|
<Button
|
||||||
className="ghost-button proxy-toolbar-button"
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
disabled={selectedNodes.length === 0}
|
disabled={selectedNodes.length === 0}
|
||||||
>
|
>
|
||||||
清空已选
|
清空已选
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedNodes.length > 0 ? (
|
{selectedNodes.length > 0 ? (
|
||||||
|
<DndContext
|
||||||
|
id={dndId}
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
accessibility={{
|
||||||
|
screenReaderInstructions: {
|
||||||
|
draggable:
|
||||||
|
"按空格开始拖拽,使用方向键调整顺序,再按空格放下,Escape 取消。",
|
||||||
|
},
|
||||||
|
announcements: {
|
||||||
|
onDragStart: ({ active }) =>
|
||||||
|
`开始移动 ${nodesBySlug.get(String(active.id))?.label ?? active.id}`,
|
||||||
|
onDragOver: ({ over }) =>
|
||||||
|
over
|
||||||
|
? `目标位置 ${selectedSlugs.indexOf(String(over.id)) + 1}`
|
||||||
|
: "已离开列表",
|
||||||
|
onDragEnd: ({ over }) =>
|
||||||
|
over
|
||||||
|
? `已放到第 ${selectedSlugs.indexOf(String(over.id)) + 1} 位,请保存顺序`
|
||||||
|
: "顺序未变",
|
||||||
|
onDragCancel: () => "已取消排序",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDragEnd={({ active, over }) => {
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setSelectedSlugs((current) =>
|
||||||
|
arrayMove(
|
||||||
|
current,
|
||||||
|
current.indexOf(String(active.id)),
|
||||||
|
current.indexOf(String(over.id)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setFeedback("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={selectedSlugs}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<ol className="proxy-sort-list">
|
<ol className="proxy-sort-list">
|
||||||
{selectedNodes.map((node, index) => (
|
{selectedNodes.map((node, index) => (
|
||||||
<li key={node.slug} className="proxy-sort-item">
|
<SortableNode
|
||||||
<div className="proxy-sort-item__meta">
|
key={node.slug}
|
||||||
<span className="proxy-sort-item__index">{String(index + 1).padStart(2, "0")}</span>
|
node={node}
|
||||||
<div className="stack-tight">
|
index={index}
|
||||||
<strong>{node.label}</strong>
|
removable={!isMainGroup}
|
||||||
<span className="muted mono proxy-sort-item__sub">{node.meta}</span>
|
onRemove={() => removeNode(node.slug)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="proxy-sort-item__actions">
|
|
||||||
<button
|
|
||||||
className="ghost-button proxy-sort-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveNode(node.slug, -1)}
|
|
||||||
disabled={index === 0}
|
|
||||||
>
|
|
||||||
上移
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button proxy-sort-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveNode(node.slug, 1)}
|
|
||||||
disabled={index === selectedNodes.length - 1}
|
|
||||||
>
|
|
||||||
下移
|
|
||||||
</button>
|
|
||||||
{!isMainGroup ? (
|
|
||||||
<button
|
|
||||||
className="danger-button proxy-sort-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeNode(node.slug)}
|
|
||||||
>
|
|
||||||
移除
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted proxy-group-empty">
|
<p className="muted proxy-group-empty">
|
||||||
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
{isMainGroup
|
||||||
|
? "暂无可用节点。"
|
||||||
|
: "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="action-row action-row--end">
|
<div className="action-row action-row--end">
|
||||||
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
<span role="status" className="muted">
|
||||||
|
{feedback || (dirty ? "有未保存的修改" : "")}
|
||||||
|
</span>
|
||||||
|
{dirty && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const [oldName, oldSlugs] = JSON.parse(saved) as [
|
||||||
|
string,
|
||||||
|
string[],
|
||||||
|
];
|
||||||
|
setName(oldName);
|
||||||
|
setSelectedSlugs(oldSlugs);
|
||||||
|
setFeedback("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
撤销修改
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<SubmitButton disabled={!dirty && (isEditing || isMainGroup)}>
|
||||||
|
{isMainGroup ? "保存顺序" : isEditing ? "保存分组" : "新建分组"}
|
||||||
|
</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
|
</EditorFields>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{group && !isMainGroup ? (
|
{group && !isMainGroup ? (
|
||||||
<form action={deleteProxyGroupAction}>
|
<ActionForm action={deleteProxyGroupAction}>
|
||||||
<input type="hidden" name="id" value={group.id} />
|
<input type="hidden" name="id" value={group.id} />
|
||||||
<DeleteButton />
|
<SubmitButton
|
||||||
</form>
|
variant="destructive"
|
||||||
|
confirm={`将删除分组「${group.name}」,节点本身不受影响。`}
|
||||||
|
>
|
||||||
|
删除分组
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
+123
-60
@@ -1,14 +1,15 @@
|
|||||||
|
import { ActionForm } from "@/components/action-form";
|
||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { FilterList } from "@/components/filter-list";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
import { formatLocalDateTime } from "@/lib/datetime";
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import {
|
import {
|
||||||
buildSubscriptionUrl,
|
buildSubscriptionUrl,
|
||||||
describeRemainingTraffic,
|
describeRemainingTraffic,
|
||||||
} from "@/lib/subscription";
|
} from "@/lib/subscription";
|
||||||
import {
|
import { formatBytes, listUsers, toDatetimeLocalValue } from "@/lib/store";
|
||||||
formatBytes,
|
|
||||||
listUsers,
|
|
||||||
toDatetimeLocalValue,
|
|
||||||
} from "@/lib/store";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createUserAction,
|
createUserAction,
|
||||||
@@ -31,9 +32,14 @@ function getUserPills(user: {
|
|||||||
? { label: "管理员", className: "status-pill status-pill--ink" }
|
? { label: "管理员", className: "status-pill status-pill--ink" }
|
||||||
: null,
|
: null,
|
||||||
user.expires_at
|
user.expires_at
|
||||||
? { label: formatLocalDateTime(user.expires_at), className: "status-pill status-pill--neutral" }
|
? {
|
||||||
|
label: formatLocalDateTime(user.expires_at),
|
||||||
|
className: "status-pill status-pill--neutral",
|
||||||
|
}
|
||||||
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
||||||
].filter((pill): pill is { label: string; className: string } => Boolean(pill));
|
].filter((pill): pill is { label: string; className: string } =>
|
||||||
|
Boolean(pill),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function UsersPage() {
|
export default async function UsersPage() {
|
||||||
@@ -46,18 +52,12 @@ export default async function UsersPage() {
|
|||||||
<span className="eyebrow">用户</span>
|
<span className="eyebrow">用户</span>
|
||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">启用 {users.filter((user) => user.enabled).length}</span>
|
<span className="status-pill status-pill--ok">
|
||||||
<span className="status-pill status-pill--neutral">总数 {users.length}</span>
|
启用 {users.filter((user) => user.enabled).length}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="status-pill status-pill--neutral">
|
||||||
<div className="hero-aside">
|
总数 {users.length}
|
||||||
<div className="stat-tile">
|
</span>
|
||||||
<span>用户数</span>
|
|
||||||
<strong>{users.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="stat-tile">
|
|
||||||
<span>启用中</span>
|
|
||||||
<strong>{users.filter((user) => user.enabled).length}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -70,22 +70,30 @@ export default async function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="status-pill status-pill--warm">展开</span>
|
<span className="status-pill status-pill--warm">展开</span>
|
||||||
</summary>
|
</summary>
|
||||||
<form action={createUserAction} className="dense-form-grid dense-form-grid--users">
|
<ActionForm
|
||||||
|
action={createUserAction}
|
||||||
|
className="dense-form-grid dense-form-grid--users"
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
<input name="username" required />
|
<Input name="username" required />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>密码</span>
|
<span>密码</span>
|
||||||
<input name="password" type="password" required />
|
<Input name="password" type="password" required />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>到期时间</span>
|
<span>到期时间</span>
|
||||||
<input name="expires_at" type="datetime-local" />
|
<Input name="expires_at" type="datetime-local" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>总流量上限 GiB</span>
|
<span>总流量上限 GiB</span>
|
||||||
<input name="traffic_limit_gib" type="number" min="0" step="0.1" />
|
<Input
|
||||||
|
name="traffic_limit_gib"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field dense-field-wide">
|
<label className="field dense-field-wide">
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
@@ -95,19 +103,26 @@ export default async function UsersPage() {
|
|||||||
<input name="enabled" type="checkbox" defaultChecked />
|
<input name="enabled" type="checkbox" defaultChecked />
|
||||||
<span>立即启用</span>
|
<span>立即启用</span>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit">
|
<SubmitButton variant="default">创建用户</SubmitButton>
|
||||||
创建用户
|
</ActionForm>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel-card dense-panel">
|
<section className="panel-card dense-panel">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<h3>用户列表</h3>
|
<h3>用户列表</h3>
|
||||||
<span className="status-pill status-pill--neutral">{users.length} 个账户</span>
|
<span className="status-pill status-pill--neutral">
|
||||||
|
{users.length} 个账户
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="entity-table entity-table--users">
|
<FilterList
|
||||||
|
className="entity-table entity-table--users"
|
||||||
|
placeholder="搜索用户名、ID、备注…"
|
||||||
|
statuses={[
|
||||||
|
{ value: "enabled", label: "已启用" },
|
||||||
|
{ value: "disabled", label: "已禁用" },
|
||||||
|
]}
|
||||||
|
header={
|
||||||
<div className="entity-table__head user-row-grid">
|
<div className="entity-table__head user-row-grid">
|
||||||
<span>用户</span>
|
<span>用户</span>
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
@@ -116,18 +131,25 @@ export default async function UsersPage() {
|
|||||||
<span>在线</span>
|
<span>在线</span>
|
||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
{users.map((user) => {
|
}
|
||||||
|
items={users.map((user) => {
|
||||||
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
|
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
|
||||||
const subscriptionUrl = user.subscription_token
|
const subscriptionUrl = user.subscription_token
|
||||||
? buildSubscriptionUrl(user.subscription_token, origin)
|
? buildSubscriptionUrl(user.subscription_token, origin)
|
||||||
: "暂不可用";
|
: "暂不可用";
|
||||||
|
|
||||||
return (
|
return {
|
||||||
|
id: user.id,
|
||||||
|
text: `${user.username} ${user.auth_id} ${user.notes}`,
|
||||||
|
status: user.enabled ? "enabled" : "disabled",
|
||||||
|
content: (
|
||||||
<details key={user.id} className="entity-table__item">
|
<details key={user.id} className="entity-table__item">
|
||||||
<summary className="entity-table__summary user-row-grid">
|
<summary className="entity-table__summary user-row-grid">
|
||||||
<span className="entity-main-cell">
|
<span className="entity-main-cell">
|
||||||
<strong>{user.username}</strong>
|
<strong>{user.username}</strong>
|
||||||
<span className="muted mono break-all">{user.auth_id}</span>
|
<span className="muted mono break-all">
|
||||||
|
{user.auth_id}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="badge-row entity-status-cell">
|
<span className="badge-row entity-status-cell">
|
||||||
{getUserPills(user).map((pill) => (
|
{getUserPills(user).map((pill) => (
|
||||||
@@ -138,32 +160,52 @@ export default async function UsersPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>{formatBytes(usedBytes)}</strong>
|
<strong>{formatBytes(usedBytes)}</strong>
|
||||||
<span>TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)}</span>
|
<span>
|
||||||
|
TX {formatBytes(user.used_tx_bytes)} / RX{" "}
|
||||||
|
{formatBytes(user.used_rx_bytes)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>{describeRemainingTraffic(user)}</strong>
|
<strong>{describeRemainingTraffic(user)}</strong>
|
||||||
<span>{user.traffic_limit_bytes ? "有额度" : "不限量"}</span>
|
<span>
|
||||||
|
{user.traffic_limit_bytes ? "有额度" : "不限量"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>{user.online_connections}</strong>
|
<strong>{user.online_connections}</strong>
|
||||||
<span>连接</span>
|
<span>连接</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-expand-label">编辑</span>
|
<span className="entity-expand-label">
|
||||||
|
<span className="when-closed">编辑</span>
|
||||||
|
<span className="when-open">收起</span>
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="entity-table__details">
|
<div className="entity-table__details">
|
||||||
<div className="dense-code-line">
|
<div className="dense-code-line">
|
||||||
<span>订阅链接</span>
|
<span>订阅链接</span>
|
||||||
|
<div className="copy-line">
|
||||||
<code>{subscriptionUrl}</code>
|
<code>{subscriptionUrl}</code>
|
||||||
|
{user.subscription_token && (
|
||||||
|
<CopyButton value={subscriptionUrl} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<form action={updateUserAction} className="dense-form-grid dense-form-grid--users">
|
</div>
|
||||||
|
<ActionForm
|
||||||
|
action={updateUserAction}
|
||||||
|
className="dense-form-grid dense-form-grid--users"
|
||||||
|
>
|
||||||
<input name="id" type="hidden" value={user.id} />
|
<input name="id" type="hidden" value={user.id} />
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
<input name="username" defaultValue={user.username} required />
|
<Input
|
||||||
|
name="username"
|
||||||
|
defaultValue={user.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>到期时间</span>
|
<span>到期时间</span>
|
||||||
<input
|
<Input
|
||||||
name="expires_at"
|
name="expires_at"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
||||||
@@ -171,14 +213,19 @@ export default async function UsersPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>总流量上限 GiB</span>
|
<span>总流量上限 GiB</span>
|
||||||
<input
|
<Input
|
||||||
name="traffic_limit_gib"
|
name="traffic_limit_gib"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
defaultValue={
|
defaultValue={
|
||||||
user.traffic_limit_bytes && !user.is_admin
|
user.traffic_limit_bytes && !user.is_admin
|
||||||
? (user.traffic_limit_bytes / 1024 / 1024 / 1024).toFixed(2)
|
? (
|
||||||
|
user.traffic_limit_bytes /
|
||||||
|
1024 /
|
||||||
|
1024 /
|
||||||
|
1024
|
||||||
|
).toFixed(2)
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
disabled={Boolean(user.is_admin)}
|
disabled={Boolean(user.is_admin)}
|
||||||
@@ -186,14 +233,20 @@ export default async function UsersPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>订阅名称</span>
|
<span>订阅名称</span>
|
||||||
<input
|
<Input
|
||||||
name="subscription_label"
|
name="subscription_label"
|
||||||
defaultValue={user.subscription_label || user.username}
|
defaultValue={
|
||||||
|
user.subscription_label || user.username
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field dense-field-wide">
|
<label className="field dense-field-wide">
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<textarea name="notes" rows={2} defaultValue={user.notes} />
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={user.notes}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox">
|
<label className="checkbox">
|
||||||
<input
|
<input
|
||||||
@@ -203,32 +256,42 @@ export default async function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
<span>启用用户</span>
|
<span>启用用户</span>
|
||||||
</label>
|
</label>
|
||||||
<button className="ghost-button" type="submit">
|
<SubmitButton variant="outline">保存修改</SubmitButton>
|
||||||
保存修改
|
</ActionForm>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div className="entity-action-strip">
|
<div className="entity-action-strip">
|
||||||
<form action={resetUserPasswordAction} className="inline-form inline-form--compact">
|
<ActionForm
|
||||||
|
action={resetUserPasswordAction}
|
||||||
|
className="inline-form inline-form--compact"
|
||||||
|
>
|
||||||
<input name="id" type="hidden" value={user.id} />
|
<input name="id" type="hidden" value={user.id} />
|
||||||
<input name="password" type="password" placeholder="新密码" required />
|
<Input
|
||||||
<button className="ghost-button" type="submit">
|
name="password"
|
||||||
重置密码
|
type="password"
|
||||||
</button>
|
aria-label="新密码"
|
||||||
</form>
|
autoComplete="new-password"
|
||||||
|
placeholder="新密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<SubmitButton variant="outline">重置密码</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
{user.is_admin ? null : (
|
{user.is_admin ? null : (
|
||||||
<form action={deleteUserAction}>
|
<ActionForm action={deleteUserAction}>
|
||||||
<input name="id" type="hidden" value={user.id} />
|
<input name="id" type="hidden" value={user.id} />
|
||||||
<button className="danger-button" type="submit">
|
<SubmitButton
|
||||||
|
variant="destructive"
|
||||||
|
confirm={`将永久删除用户「${user.username}」,此用户将无法继续连接。`}
|
||||||
|
>
|
||||||
删除用户
|
删除用户
|
||||||
</button>
|
</SubmitButton>
|
||||||
</form>
|
</ActionForm>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
),
|
||||||
|
};
|
||||||
})}
|
})}
|
||||||
</div>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
+880
-1517
File diff suppressed because it is too large
Load Diff
+17
-5
@@ -1,3 +1,5 @@
|
|||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -41,18 +43,28 @@ export default async function LoginPage({
|
|||||||
<form action={loginAction} className="stack-lg login-form">
|
<form action={loginAction} className="stack-lg login-form">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>用户名</span>
|
<span>用户名</span>
|
||||||
<input name="username" type="text" placeholder="admin" required />
|
<Input
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>密码</span>
|
<span>密码</span>
|
||||||
<input name="password" type="password" required />
|
<Input
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{params.error ? (
|
{params.error ? (
|
||||||
<p className="error-banner">用户名或密码错误。</p>
|
<p className="error-banner">用户名或密码错误。</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button className="primary-button" type="submit">
|
<SubmitButton variant="default">登录</SubmitButton>
|
||||||
登录
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+23
-11
@@ -1,9 +1,15 @@
|
|||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { formatLocalDateTime } from "@/lib/datetime";
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
import { getRequestOrigin } from "@/lib/request-origin";
|
import { getRequestOrigin } from "@/lib/request-origin";
|
||||||
import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscription";
|
import {
|
||||||
|
buildSubscriptionUrl,
|
||||||
|
describeRemainingTraffic,
|
||||||
|
} from "@/lib/subscription";
|
||||||
import { requireUserSession } from "@/lib/session";
|
import { requireUserSession } from "@/lib/session";
|
||||||
import { formatBytes, getUserById } from "@/lib/store";
|
import { formatBytes, getUserById } from "@/lib/store";
|
||||||
|
|
||||||
@@ -66,22 +72,28 @@ export default async function MePage() {
|
|||||||
<article className="me-panel me-subscription-card">
|
<article className="me-panel me-subscription-card">
|
||||||
<div className="section-title section-title--top">
|
<div className="section-title section-title--top">
|
||||||
<div>
|
<div>
|
||||||
<span className="feature-kicker">订阅</span>
|
|
||||||
<h2>订阅</h2>
|
<h2>订阅</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="status-pill status-pill--ink">Clash / Mihomo</span>
|
<span className="status-pill status-pill--ink">
|
||||||
|
Clash / Mihomo
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="me-action-row">
|
<div className="me-action-row">
|
||||||
{clashImportUrl ? (
|
{clashImportUrl ? (
|
||||||
<a className="primary-button me-action-button" href={clashImportUrl}>
|
<a
|
||||||
|
className={buttonVariants({ size: "sm" })}
|
||||||
|
href={clashImportUrl}
|
||||||
|
>
|
||||||
一键导入
|
一键导入
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="status-pill status-pill--off">订阅暂不可用</span>
|
<span className="status-pill status-pill--off">
|
||||||
|
订阅暂不可用
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{subscriptionUrl ? (
|
{subscriptionUrl ? (
|
||||||
<a
|
<a
|
||||||
className="ghost-button me-action-button"
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
href={subscriptionUrl}
|
href={subscriptionUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
@@ -92,7 +104,10 @@ export default async function MePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="me-link-card">
|
<div className="me-link-card">
|
||||||
<span className="feature-kicker">订阅链接</span>
|
<span className="feature-kicker">订阅链接</span>
|
||||||
<p className="mono break-all">{subscriptionUrl ?? "暂不可用"}</p>
|
<div className="copy-line">
|
||||||
|
<code>{subscriptionUrl ?? "暂不可用"}</code>
|
||||||
|
{subscriptionUrl && <CopyButton value={subscriptionUrl} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -122,12 +137,9 @@ export default async function MePage() {
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="me-panel me-support-card">
|
<article className="me-panel me-support-card">
|
||||||
<span className="feature-kicker">账户</span>
|
|
||||||
<h2>账户</h2>
|
<h2>账户</h2>
|
||||||
<form action="/logout" method="post">
|
<form action="/logout" method="post">
|
||||||
<button className="ghost-button me-action-button me-action-button--full" type="submit">
|
<SubmitButton variant="outline">退出登录</SubmitButton>
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,15 +5,24 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "hy2-panel",
|
"name": "hy2-panel",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.48.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.7.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -66,6 +75,14 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
@@ -90,6 +107,14 @@
|
|||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||||
|
|
||||||
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
@@ -188,6 +213,128 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.15", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.16", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.18", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.5", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
@@ -332,6 +479,8 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||||
@@ -396,6 +545,8 @@
|
|||||||
|
|
||||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
@@ -458,6 +609,8 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
@@ -568,6 +721,8 @@
|
|||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||||
@@ -738,6 +893,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@1.48.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
@@ -810,6 +967,10 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||||
|
|
||||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||||
@@ -826,6 +987,8 @@
|
|||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
|
"radix-ui": ["radix-ui@1.6.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.15", "@radix-ui/react-accordion": "1.2.20", "@radix-ui/react-alert-dialog": "1.1.23", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.15", "@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-checkbox": "1.3.11", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.7", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-dropdown-menu": "2.1.24", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-form": "0.1.16", "@radix-ui/react-hover-card": "1.1.23", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-menubar": "1.1.24", "@radix-ui/react-navigation-menu": "1.2.22", "@radix-ui/react-one-time-password-field": "0.1.16", "@radix-ui/react-password-toggle-field": "0.1.11", "@radix-ui/react-popover": "1.1.23", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-progress": "1.1.16", "@radix-ui/react-radio-group": "1.4.7", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-select": "2.3.7", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-slider": "1.4.7", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-switch": "1.3.7", "@radix-ui/react-tabs": "1.1.21", "@radix-ui/react-toast": "1.2.23", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-toggle-group": "1.1.19", "@radix-ui/react-toolbar": "1.1.19", "@radix-ui/react-tooltip": "1.2.16", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA=="],
|
||||||
|
|
||||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
@@ -836,6 +999,12 @@
|
|||||||
|
|
||||||
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||||
@@ -926,6 +1095,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||||
@@ -972,6 +1143,10 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
import { useRef, useState, type ComponentProps, type ReactNode } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
|
function FormFields({ children }: { children: ReactNode }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<fieldset disabled={pending} style={{ display: "contents" }}>
|
||||||
|
{children}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps action errors beside the submitted form, without losing entered values. */
|
||||||
|
export function ActionForm({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<ComponentProps<"form">, "action"> & {
|
||||||
|
action: (data: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const preserveValues = useRef(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
{...props}
|
||||||
|
onReset={(event) => {
|
||||||
|
if (preserveValues.current) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onChange={() => setMessage("")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const button = event.currentTarget.querySelector<HTMLButtonElement>(
|
||||||
|
'button[type="submit"]',
|
||||||
|
);
|
||||||
|
if (button && !button.disabled) button.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
action={async (data) => {
|
||||||
|
setMessage("");
|
||||||
|
preserveValues.current = false;
|
||||||
|
try {
|
||||||
|
await action(data);
|
||||||
|
setFailed(false);
|
||||||
|
setMessage("已保存");
|
||||||
|
} catch {
|
||||||
|
preserveValues.current = true;
|
||||||
|
setFailed(true);
|
||||||
|
setMessage("操作失败,请检查输入后重试。填写内容已保留。");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormFields>{children}</FormFields>
|
||||||
|
{message && (
|
||||||
|
<p
|
||||||
|
role={failed ? "alert" : "status"}
|
||||||
|
className={`full-span ${failed ? "error-text" : "muted"}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
export function CopyButton({ value }: { value: string }) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setStatus("copied");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "copied" ? <Check /> : <Copy />}
|
||||||
|
{status === "copied" ? "已复制" : "复制"}
|
||||||
|
</Button>
|
||||||
|
<span role="status" className="muted">
|
||||||
|
{status === "error" ? "复制失败,请选中链接手动复制" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
import { useId, useState, type ReactNode } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: string | number;
|
||||||
|
text: string;
|
||||||
|
status: string;
|
||||||
|
content: ReactNode;
|
||||||
|
};
|
||||||
|
export function FilterList({
|
||||||
|
items,
|
||||||
|
header,
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
statuses,
|
||||||
|
}: {
|
||||||
|
items: Item[];
|
||||||
|
header: ReactNode;
|
||||||
|
className: string;
|
||||||
|
placeholder: string;
|
||||||
|
statuses: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const visible = items.filter(
|
||||||
|
(item) =>
|
||||||
|
(!status || item.status === status) &&
|
||||||
|
item.text.toLowerCase().includes(query.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="list-toolbar">
|
||||||
|
<div className="list-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
aria-label={placeholder}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
aria-controls={id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label="筛选状态"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="muted" role="status">
|
||||||
|
{visible.length} / {items.length} 条
|
||||||
|
</span>
|
||||||
|
{(query || status) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setStatus("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mobile-table-hint">
|
||||||
|
左右滑动查看完整信息
|
||||||
|
{className.includes("audits") ? "" : " · 点击行展开编辑"}
|
||||||
|
</p>
|
||||||
|
<div id={id} className={className}>
|
||||||
|
{header}
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} hidden={!visible.includes(item)}>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!visible.length && (
|
||||||
|
<p className="empty-state">
|
||||||
|
{items.length
|
||||||
|
? "没有匹配的结果,试试其他关键词或清除筛选。"
|
||||||
|
: "暂无记录。"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, type ComponentProps } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogAction,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export function SubmitButton({
|
||||||
|
children,
|
||||||
|
confirm,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof Button> & { confirm?: string }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending || props.disabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (confirm) {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
|
||||||
|
{pending ? "处理中…" : children}
|
||||||
|
</Button>
|
||||||
|
{confirm && (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认{children}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{confirm}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => ref.current?.form?.requestSubmit(ref.current)}
|
||||||
|
>
|
||||||
|
确认{children}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Generated
+3489
-94
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -6,18 +6,28 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.48.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.7.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
workers: 1,
|
||||||
|
timeout: 90000,
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3106",
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev -- --port 3106",
|
||||||
|
url: "http://localhost:3106/login",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
env: {
|
||||||
|
DATABASE_PATH: `/tmp/hy2-panel-ui-test-${Date.now()}.db`,
|
||||||
|
POLLER_ENABLED: "false",
|
||||||
|
ADMIN_USERNAME: "ui-review",
|
||||||
|
ADMIN_PASSWORD: "ui-review-local-2026",
|
||||||
|
SESSION_COOKIE_SECURE: "false",
|
||||||
|
APP_URL: "http://localhost:3106",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("compact management flows, drag persistence, filters and mobile layout", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/dashboard/);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/dashboard-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "节点管理" }).click();
|
||||||
|
for (const [slug, name] of [
|
||||||
|
["tokyo", "Tokyo"],
|
||||||
|
["singapore", "Singapore"],
|
||||||
|
["frankfurt", "Frankfurt"],
|
||||||
|
]) {
|
||||||
|
const create = page.locator(".compact-create-panel details");
|
||||||
|
if ((await create.getAttribute("open")) === null)
|
||||||
|
await create.locator("summary").click();
|
||||||
|
await create.getByLabel("Slug", { exact: true }).fill(slug);
|
||||||
|
await create.getByLabel("显示名", { exact: true }).fill(name);
|
||||||
|
await create
|
||||||
|
.getByLabel("客户端地址", { exact: true })
|
||||||
|
.fill(`${slug}.example.com`);
|
||||||
|
await create.getByRole("button", { name: "创建节点" }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator(".entity-main-cell strong", { hasText: name }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
await page.getByRole("searchbox").fill("Tokyo");
|
||||||
|
await expect(page.locator(".entity-table__item:visible")).toHaveCount(1);
|
||||||
|
await page.getByRole("button", { name: "清除筛选" }).click();
|
||||||
|
await expect(page.locator(".entity-table__item:visible")).toHaveCount(3);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/nodes-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "订阅配置" }).click();
|
||||||
|
const main = page.locator(".proxy-group-card").first();
|
||||||
|
const labels = () =>
|
||||||
|
main.locator(".proxy-sort-item strong").allTextContents();
|
||||||
|
await expect(main.locator(".proxy-sort-item")).toHaveCount(3);
|
||||||
|
const initial = await labels();
|
||||||
|
const handles = main.getByRole("button", { name: /拖拽排序/ });
|
||||||
|
await handles.first().focus();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await expect(handles.first()).toHaveAttribute("aria-pressed", "true");
|
||||||
|
// dnd-kit measures droppable rows and attaches its keyboard listener after activation.
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expect(page.locator("[id^=DndLiveRegion]")).toContainText("目标位置 2");
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await expect.poll(labels).toEqual([initial[1], initial[0], initial[2]]);
|
||||||
|
await main.getByRole("button", { name: "撤销修改" }).click();
|
||||||
|
await expect.poll(labels).toEqual(initial);
|
||||||
|
const from = await handles.first().boundingBox();
|
||||||
|
const to = await handles.nth(2).boundingBox();
|
||||||
|
await page.mouse.move(from!.x + 15, from!.y + 15);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(to!.x + 15, to!.y + 15, { steps: 12 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(labels).toEqual([initial[1], initial[2], initial[0]]);
|
||||||
|
await main.getByRole("button", { name: "保存顺序", exact: true }).click();
|
||||||
|
await expect(main.locator(".action-row [role=status]")).toHaveText("已保存");
|
||||||
|
await page.reload();
|
||||||
|
await expect.poll(labels).toEqual([initial[1], initial[2], initial[0]]);
|
||||||
|
|
||||||
|
const newGroup = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({ has: page.locator("summary", { hasText: "新建分组" }) });
|
||||||
|
await newGroup.locator("summary").click();
|
||||||
|
await newGroup.getByLabel("新建分组名").fill("Work");
|
||||||
|
await newGroup.getByRole("button", { name: "全部添加" }).click();
|
||||||
|
await newGroup.getByRole("button", { name: "新建分组", exact: true }).click();
|
||||||
|
const group = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({ has: page.locator("summary strong", { hasText: /^Work$/ }) });
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
await group.locator("summary").click();
|
||||||
|
await group.getByRole("button", { name: "删除分组" }).click();
|
||||||
|
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "取消", exact: true }).click();
|
||||||
|
await expect(group).toBeVisible();
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await page.getByRole("button", { name: "复制", exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "已复制" })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||||
|
.toContain("/api/subscription/");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/subscriptions-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await group.getByRole("button", { name: "删除分组" }).click();
|
||||||
|
await page.getByRole("button", { name: "确认删除分组" }).click();
|
||||||
|
await expect(group).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "用户管理" }).click();
|
||||||
|
await page.locator(".compact-create-summary").click();
|
||||||
|
const createUser = page.locator(".compact-create-panel");
|
||||||
|
await createUser.getByLabel("用户名", { exact: true }).fill("test-member");
|
||||||
|
await createUser.getByLabel("密码", { exact: true }).fill("test-member-pass");
|
||||||
|
await createUser.getByRole("button", { name: "创建用户" }).click();
|
||||||
|
const user = page
|
||||||
|
.locator(".entity-table__item")
|
||||||
|
.filter({ hasText: "test-member" });
|
||||||
|
await expect(user).toBeVisible();
|
||||||
|
await user.locator("summary").click();
|
||||||
|
await user
|
||||||
|
.getByRole("textbox", { name: "备注", exact: true })
|
||||||
|
.fill("updated via UI");
|
||||||
|
await user.getByRole("button", { name: "保存修改" }).click();
|
||||||
|
await expect(user.locator("p[role=status]")).toHaveText("已保存");
|
||||||
|
await page.reload();
|
||||||
|
await user.locator("summary").click();
|
||||||
|
await expect(
|
||||||
|
user.getByRole("textbox", { name: "备注", exact: true }),
|
||||||
|
).toHaveValue("updated via UI");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/users-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user
|
||||||
|
.getByRole("textbox", { name: "备注", exact: true })
|
||||||
|
.fill("keep on failure");
|
||||||
|
await page.route("**/admin/users", (route) =>
|
||||||
|
route.request().headers()["next-action"] ? route.abort() : route.continue(),
|
||||||
|
);
|
||||||
|
await user.getByRole("button", { name: "保存修改" }).click();
|
||||||
|
await expect(user.getByRole("alert")).toContainText("操作失败");
|
||||||
|
await expect(
|
||||||
|
user.getByRole("textbox", { name: "备注", exact: true }),
|
||||||
|
).toHaveValue("keep on failure");
|
||||||
|
await page.unroute("**/admin/users");
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
for (const route of [
|
||||||
|
"users",
|
||||||
|
"nodes",
|
||||||
|
"subscriptions",
|
||||||
|
"audits",
|
||||||
|
"dashboard",
|
||||||
|
]) {
|
||||||
|
await page.goto(`/admin/${route}`);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.screenshot({
|
||||||
|
path: `test-results/${route}-mobile.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Exercise native touch input against the same handles on a narrow viewport.
|
||||||
|
await page.goto("/admin/subscriptions");
|
||||||
|
await expect(main.locator(".proxy-sort-item")).toHaveCount(3);
|
||||||
|
const beforeTouch = await labels();
|
||||||
|
const touchFrom = await handles.first().boundingBox();
|
||||||
|
const touchTo = await handles.nth(1).boundingBox();
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true });
|
||||||
|
await cdp.send("Input.dispatchTouchEvent", {
|
||||||
|
type: "touchStart",
|
||||||
|
touchPoints: [{ x: touchFrom!.x + 15, y: touchFrom!.y + 15 }],
|
||||||
|
});
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
await cdp.send("Input.dispatchTouchEvent", {
|
||||||
|
type: "touchMove",
|
||||||
|
touchPoints: [
|
||||||
|
{
|
||||||
|
x: touchFrom!.x + 15,
|
||||||
|
y: touchFrom!.y + 15 + ((touchTo!.y - touchFrom!.y) * i) / 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cdp.send("Input.dispatchTouchEvent", {
|
||||||
|
type: "touchEnd",
|
||||||
|
touchPoints: [],
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(labels)
|
||||||
|
.toEqual([beforeTouch[1], beforeTouch[0], beforeTouch[2]]);
|
||||||
|
await main.getByRole("button", { name: "撤销修改" }).click();
|
||||||
|
await cdp.detach();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "退出登录" }).click();
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill("test-member");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("test-member-pass");
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/me$/);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/account-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user