feat: improve admin experience and ignore local data

This commit is contained in:
feie9454 2026-04-14 19:12:20 +08:00
parent 6378ab4994
commit 2e3eb4b8f8
10 changed files with 1132 additions and 119 deletions

1
.gitignore vendored
View File

@ -19,6 +19,7 @@
# production
/build
/data/
# misc
.DS_Store

View File

@ -111,4 +111,36 @@ sudo systemctl daemon-reload
sudo systemctl enable --now hy2-panel
```
如果你需要对外开放,再在 Nginx/Caddy 前面反代 `127.0.0.1:3000` 即可。
## Nginx 反代
已提供示例站点配置 [deploy/nginx-hy2-panel.conf](/root/hy2-panel/deploy/nginx-hy2-panel.conf)。
如果你的公网域名是 `hy2.xn--876a.net`,建议生产环境把 `.env` 中的 `APP_URL` 改成:
```bash
APP_URL=https://hy2.xn--876a.net
SESSION_COOKIE_SECURE=auto
```
示例配置默认反代到 `127.0.0.1:3000`,证书路径为:
```text
/etc/nginx/ssl/hy2.xn--876a.net/fullchain.pem
/etc/nginx/ssl/hy2.xn--876a.net/privkey.pem
```
典型启用流程:
```bash
sudo mkdir -p /etc/nginx/ssl/hy2.xn--876a.net
sudo cp fullchain.pem /etc/nginx/ssl/hy2.xn--876a.net/fullchain.pem
sudo cp privkey.pem /etc/nginx/ssl/hy2.xn--876a.net/privkey.pem
sudo cp deploy/nginx-hy2-panel.conf /etc/nginx/sites-available/hy2-panel.conf
sudo ln -sf /etc/nginx/sites-available/hy2-panel.conf /etc/nginx/sites-enabled/hy2-panel.conf
sudo nginx -t
sudo systemctl reload nginx
```
如果你当前的 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`

View File

@ -28,22 +28,35 @@ export async function saveProxyGroupAction(formData: FormData) {
const settings = await getSubscriptionSettings();
const id = String(formData.get("id") ?? "").trim() || createStableId();
const name = String(formData.get("name") ?? "").trim();
const memberSlugs = formData
.getAll("member_slugs")
.map((value) => String(value))
.filter(Boolean);
const seen = new Set<string>();
const memberSlugs = formData.getAll("member_slugs").flatMap((value) => {
const slug = String(value).trim();
if (!slug || seen.has(slug)) {
return [];
}
seen.add(slug);
return [slug];
});
if (!name) {
return;
}
const proxyGroups = settings.proxyGroups.filter((group) => group.id !== id);
proxyGroups.push({
const nextGroup = {
id,
name,
type: "select",
memberSlugs,
});
} as const;
const existingIndex = settings.proxyGroups.findIndex((group) => group.id === id);
const proxyGroups = [...settings.proxyGroups];
if (existingIndex === -1) {
proxyGroups.push(nextGroup);
} else {
proxyGroups[existingIndex] = nextGroup;
}
await saveProxyGroups(proxyGroups);
revalidatePath("/admin/subscriptions");

View File

@ -6,11 +6,8 @@ import {
listSubscriptionNodes,
} from "@/lib/store";
import {
deleteProxyGroupAction,
saveProxyGroupAction,
saveSubscriptionSettingsAction,
} from "./actions";
import { saveSubscriptionSettingsAction } from "./actions";
import { ProxyGroupEditor } from "./proxy-group-editor";
export default async function AdminSubscriptionsPage() {
const [settings, adminUser, nodes, origin] = await Promise.all([
@ -19,6 +16,14 @@ export default async function AdminSubscriptionsPage() {
listSubscriptionNodes(),
getRequestOrigin(),
]);
const proxyGroupNodes = nodes.map((node) => ({
slug: node.slug,
label: node.client_name || node.name,
meta:
node.client_name && node.client_name !== node.name
? `${node.name} / ${node.slug}`
: node.slug,
}));
return (
<main className="page-stack">
@ -62,65 +67,18 @@ export default async function AdminSubscriptionsPage() {
</section>
<section className="panel-card">
<div className="section-title">
<div className="section-title section-title--top">
<div className="stack-tight">
<h3>Proxy Groups</h3>
<p className="muted"> Clash </p>
</div>
</div>
<div className="stack-lg">
{settings.proxyGroups.map((group) => (
<article key={group.id} className="instruction-block">
<form action={saveProxyGroupAction} className="stack-md">
<input type="hidden" name="id" value={group.id} />
<label className="field">
<span></span>
<input name="name" defaultValue={group.name} required />
</label>
<div className="checkbox-grid">
{nodes.map((node) => (
<label key={`${group.id}-${node.slug}`} className="checkbox">
<input
type="checkbox"
name="member_slugs"
value={node.slug}
defaultChecked={group.memberSlugs.includes(node.slug)}
/>
<span>{node.client_name || node.name}</span>
</label>
))}
</div>
<div className="action-row">
<button className="ghost-button" type="submit">
</button>
</div>
</form>
<form action={deleteProxyGroupAction}>
<input type="hidden" name="id" value={group.id} />
<button className="danger-button" type="submit">
</button>
</form>
</article>
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
))}
<article className="instruction-block">
<form action={saveProxyGroupAction} className="stack-md">
<label className="field">
<span></span>
<input name="name" placeholder="ChatGPT" required />
</label>
<div className="checkbox-grid">
{nodes.map((node) => (
<label key={`new-${node.slug}`} className="checkbox">
<input type="checkbox" name="member_slugs" value={node.slug} />
<span>{node.client_name || node.name}</span>
</label>
))}
</div>
<button className="primary-button" type="submit">
</button>
</form>
</article>
<ProxyGroupEditor key={`new-${settings.proxyGroups.length}`} nodes={proxyGroupNodes} />
</div>
</section>

View File

@ -0,0 +1,292 @@
"use client";
import { useDeferredValue, useState } from "react";
import { useFormStatus } from "react-dom";
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
type ProxyGroupNodeOption = {
slug: string;
label: string;
meta: string;
};
type ProxyGroupEditorProps = {
group?: {
id: string;
name: string;
memberSlugs: string[];
};
nodes: ProxyGroupNodeOption[];
};
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
const availableSlugs = new Set(nodes.map((node) => node.slug));
const seen = new Set<string>();
return initialSlugs.filter((slug) => {
if (!availableSlugs.has(slug) || seen.has(slug)) {
return false;
}
seen.add(slug);
return true;
});
}
function SaveButton({ isEditing }: { isEditing: boolean }) {
const { pending } = useFormStatus();
return (
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
</button>
);
}
function DeleteButton() {
const { pending } = useFormStatus();
return (
<button className="danger-button" type="submit" disabled={pending}>
{pending ? "删除中..." : "删除分组"}
</button>
);
}
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
const isEditing = Boolean(group);
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [selectedSlugs, setSelectedSlugs] = useState(() =>
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes),
);
const selectedSlugSet = new Set(selectedSlugs);
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
const selectedNodes = selectedSlugs
.map((slug) => nodesBySlug.get(slug))
.filter((node): node is ProxyGroupNodeOption => Boolean(node));
const normalizedQuery = deferredQuery.trim().toLowerCase();
const remainingCount = nodes.length - selectedNodes.length;
const availableNodes = nodes.filter((node) => {
if (selectedSlugSet.has(node.slug)) {
return false;
}
if (!normalizedQuery) {
return true;
}
return [node.label, node.meta, node.slug].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
});
function addNode(slug: string) {
setSelectedSlugs((current) => {
if (current.includes(slug)) {
return current;
}
return [...current, slug];
});
}
function removeNode(slug: string) {
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() {
setSelectedSlugs((current) => {
const next = [...current];
const seen = new Set(current);
for (const node of availableNodes) {
if (seen.has(node.slug)) {
continue;
}
next.push(node.slug);
seen.add(node.slug);
}
return next;
});
}
function clearSelection() {
setSelectedSlugs([]);
}
return (
<article className="instruction-block proxy-group-card">
<form action={saveProxyGroupAction} className="stack-md">
{group ? <input type="hidden" name="id" value={group.id} /> : null}
<div className="section-title section-title--top">
<div className="stack-tight">
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
<p className="muted proxy-group-caption"> Clash </p>
</div>
<div className="badge-row badge-row--end">
<span className="status-pill status-pill--ink"> {selectedNodes.length}</span>
<span className="status-pill status-pill--warm"> {remainingCount}</span>
</div>
</div>
<label className="field">
<span>{isEditing ? "组名" : "新建分组名"}</span>
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
</label>
<div className="proxy-group-builder">
<section className="proxy-group-panel">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong></strong>
<span className="muted"></span>
</div>
<div className="proxy-group-toolbar">
<label className="proxy-group-search">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
placeholder="搜索节点名称 / slug"
/>
</label>
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={addAllVisibleNodes}
disabled={availableNodes.length === 0}
>
{normalizedQuery ? "添加筛选结果" : "全部添加"}
</button>
</div>
</div>
{availableNodes.length > 0 ? (
<div className="proxy-node-grid">
{availableNodes.map((node) => (
<button
key={node.slug}
className="proxy-node-chip"
type="button"
onClick={() => addNode(node.slug)}
>
<span className="proxy-node-chip__copy">
<strong>{node.label}</strong>
<span>{node.meta}</span>
</span>
<span className="proxy-node-chip__action"></span>
</button>
))}
</div>
) : (
<p className="muted proxy-group-empty">
{remainingCount === 0
? "所有可用节点都已加入当前分组。"
: "没有匹配当前筛选条件的节点。"}
</p>
)}
</section>
<section className="proxy-group-panel proxy-group-panel--selected">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong></strong>
<span className="muted"></span>
</div>
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={clearSelection}
disabled={selectedNodes.length === 0}
>
</button>
</div>
{selectedNodes.length > 0 ? (
<ol className="proxy-sort-list">
{selectedNodes.map((node, index) => (
<li key={node.slug} className="proxy-sort-item">
<div className="proxy-sort-item__meta">
<span className="proxy-sort-item__index">{String(index + 1).padStart(2, "0")}</span>
<div className="stack-tight">
<strong>{node.label}</strong>
<span className="muted mono proxy-sort-item__sub">{node.meta}</span>
</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>
<button
className="danger-button proxy-sort-button"
type="button"
onClick={() => removeNode(node.slug)}
>
</button>
</div>
<input type="hidden" name="member_slugs" value={node.slug} />
</li>
))}
</ol>
) : (
<p className="muted proxy-group-empty">
</p>
)}
</section>
</div>
<div className="action-row">
<span className="muted"> proxy-groups</span>
<SaveButton isEditing={isEditing} />
</div>
</form>
{group ? (
<form action={deleteProxyGroupAction}>
<input type="hidden" name="id" value={group.id} />
<DeleteButton />
</form>
) : null}
</article>
);
}

View File

@ -115,6 +115,13 @@ button:hover {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
transform: none;
box-shadow: none;
}
.login-shell {
min-height: 100vh;
display: grid;
@ -292,6 +299,279 @@ button:hover {
font-size: 0.92rem;
}
.me-shell {
position: relative;
min-height: 100vh;
overflow: hidden;
padding: 2rem;
display: grid;
place-items: center;
background:
radial-gradient(circle at 16% 18%, rgba(212, 112, 58, 0.3), transparent 20rem),
radial-gradient(circle at 82% 16%, rgba(41, 92, 114, 0.18), transparent 24rem),
radial-gradient(circle at 50% 100%, rgba(24, 34, 44, 0.16), transparent 24rem),
linear-gradient(145deg, #f7ebdc 0%, #efdfca 42%, #e4d5c5 100%);
}
.me-shell::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.16) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.16) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 90%);
}
.me-backdrop {
position: absolute;
inset: 0;
pointer-events: none;
}
.me-glow {
position: absolute;
border-radius: 999px;
filter: blur(20px);
opacity: 0.85;
animation: login-float 16s ease-in-out infinite;
}
.me-glow--warm {
top: 9%;
left: max(2rem, 7vw);
width: 17rem;
height: 17rem;
background: radial-gradient(circle, rgba(210, 104, 53, 0.28), rgba(210, 104, 53, 0.02));
}
.me-glow--cool {
right: max(2rem, 7vw);
bottom: 10%;
width: 20rem;
height: 20rem;
background: radial-gradient(circle, rgba(47, 97, 117, 0.22), rgba(47, 97, 117, 0.03));
animation-duration: 19s;
animation-delay: -5s;
}
.me-glow--light {
top: 26%;
right: 18%;
width: 8rem;
height: 8rem;
background: radial-gradient(circle, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.04));
animation-duration: 12s;
animation-delay: -2s;
}
.me-stage {
position: relative;
z-index: 1;
width: min(72rem, 100%);
display: grid;
gap: 1.25rem;
}
.me-grid,
.me-side-stack,
.me-tip-grid,
.me-metrics-grid,
.me-hero-copy,
.me-identity-card {
display: grid;
}
.me-grid {
grid-template-columns: minmax(0, 1.24fr) minmax(18rem, 0.86fr);
gap: 1.25rem;
}
.me-side-stack {
gap: 1.25rem;
}
.me-panel {
position: relative;
overflow: hidden;
border-radius: 32px;
border: 1px solid rgba(24, 34, 44, 0.1);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent 34%),
rgba(255, 251, 245, 0.7);
backdrop-filter: blur(18px);
box-shadow: 0 28px 80px rgba(51, 35, 18, 0.12);
padding: 1.4rem;
}
.me-panel::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.34), transparent 38%);
}
.me-hero {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(18rem, 0.8fr);
gap: 1rem;
align-items: stretch;
}
.me-hero-copy {
align-content: start;
gap: 0.85rem;
}
.me-hero-copy h1,
.me-subscription-card h2,
.me-support-card h2 {
margin: 0;
line-height: 1.06;
}
.me-hero-copy h1 {
font-size: clamp(2.2rem, 5vw, 3.4rem);
letter-spacing: -0.06em;
}
.me-identity-card {
align-content: space-between;
gap: 0.8rem;
min-height: 100%;
padding: 1.15rem;
border-radius: 26px;
border: 1px solid rgba(24, 34, 44, 0.08);
background:
radial-gradient(circle at top right, rgba(201, 98, 51, 0.12), transparent 12rem),
rgba(255, 255, 255, 0.44);
}
.me-identity-card strong {
font-size: 1.06rem;
}
.me-subscription-card {
display: grid;
gap: 1rem;
align-content: start;
}
.me-action-row {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.me-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3.2rem;
min-width: 12rem;
text-align: center;
}
.me-action-button--full {
width: 100%;
}
.me-link-card {
display: grid;
gap: 0.55rem;
padding: 1rem 1.05rem;
border-radius: 24px;
border: 1px solid rgba(24, 34, 44, 0.08);
background: rgba(255, 255, 255, 0.42);
}
.me-link-card p {
margin: 0;
line-height: 1.65;
}
.me-tip-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.me-tip-card {
display: grid;
gap: 0.45rem;
padding: 1rem;
border-radius: 22px;
border: 1px solid rgba(24, 34, 44, 0.08);
background: rgba(255, 255, 255, 0.38);
}
.me-tip-card strong {
font-size: 1rem;
}
.me-tip-card p {
margin: 0;
line-height: 1.6;
}
.me-metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.me-metric-card {
display: grid;
gap: 0.42rem;
min-height: 8.4rem;
padding: 1rem 1.05rem;
border-radius: 24px;
border: 1px solid rgba(24, 34, 44, 0.08);
}
.me-metric-card span {
color: var(--muted);
font-size: 0.9rem;
}
.me-metric-card strong {
font-size: 1.4rem;
line-height: 1.15;
letter-spacing: -0.04em;
}
.me-metric-card--paper {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.95), rgba(255, 250, 242, 0.72));
}
.me-metric-card--warm {
background: linear-gradient(180deg, rgba(247, 225, 203, 0.92), rgba(255, 250, 242, 0.72));
}
.me-metric-card--ink {
background: linear-gradient(180deg, rgba(223, 231, 236, 0.92), rgba(255, 250, 242, 0.72));
}
.me-metric-card--sage {
background: linear-gradient(180deg, rgba(224, 236, 228, 0.92), rgba(255, 250, 242, 0.72));
}
.me-support-card {
display: grid;
gap: 1rem;
align-content: start;
}
.me-note-list {
margin: 0;
padding-left: 1.15rem;
display: grid;
gap: 0.75rem;
color: var(--muted);
line-height: 1.6;
}
.wide-card {
width: min(56rem, 100%);
}
@ -780,6 +1060,171 @@ button:hover {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.proxy-group-card {
display: grid;
gap: 1rem;
}
.proxy-group-title,
.proxy-group-caption {
margin: 0;
}
.proxy-group-title {
font-size: 1.05rem;
}
.proxy-group-builder {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
.proxy-group-panel {
display: grid;
gap: 0.9rem;
align-content: start;
min-height: 20rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 22px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.32));
}
.proxy-group-panel--selected {
background: linear-gradient(180deg, rgba(255, 246, 238, 0.68), rgba(255, 255, 255, 0.34));
}
.proxy-group-panel__header {
display: grid;
gap: 0.75rem;
}
.proxy-group-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.proxy-group-search {
flex: 1 1 16rem;
}
.proxy-group-search input {
min-width: 0;
}
.proxy-toolbar-button,
.proxy-sort-button {
padding: 0.72rem 0.88rem;
border-radius: 14px;
}
.proxy-node-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.proxy-node-chip {
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.85rem;
width: 100%;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 18px;
text-align: left;
background: rgba(255, 255, 255, 0.5);
}
.proxy-node-chip:hover {
background: rgba(255, 255, 255, 0.78);
box-shadow: var(--shadow-soft);
}
.proxy-node-chip__copy {
display: grid;
gap: 0.28rem;
min-width: 0;
}
.proxy-node-chip__copy strong {
font-size: 0.95rem;
}
.proxy-node-chip__copy span {
color: var(--muted);
font-size: 0.82rem;
word-break: break-all;
}
.proxy-node-chip__action {
flex: none;
color: var(--accent-deep);
font-size: 0.82rem;
font-weight: 700;
}
.proxy-sort-list {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.proxy-sort-item {
display: grid;
gap: 0.75rem;
padding: 0.95rem 1rem;
border: 1px solid var(--border);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
}
.proxy-sort-item__meta {
display: flex;
align-items: start;
gap: 0.85rem;
min-width: 0;
}
.proxy-sort-item__index {
display: inline-grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
flex: none;
border-radius: 999px;
background: rgba(201, 98, 51, 0.12);
color: var(--accent-deep);
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 700;
}
.proxy-sort-item__sub {
font-size: 0.82rem;
word-break: break-all;
}
.proxy-sort-item__actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.proxy-group-empty {
margin: 0;
padding: 1rem;
border: 1px dashed var(--border-strong);
border-radius: 18px;
background: rgba(255, 255, 255, 0.36);
}
.list-row--card {
padding: 0.9rem 1rem;
border: 1px solid var(--border);
@ -822,12 +1267,18 @@ button:hover {
@media (max-width: 960px) {
.admin-shell,
.login-layout,
.me-grid,
.me-hero,
.me-tip-grid,
.me-metrics-grid,
.hero-panel,
.hero-panel--compact,
.hero-aside,
.metrics-grid,
.panel-grid,
.form-grid,
.proxy-group-builder,
.proxy-node-grid,
.checkbox-grid,
.summary-grid,
.feature-grid {
@ -863,6 +1314,10 @@ button:hover {
.login-points {
grid-template-columns: 1fr;
}
.proxy-group-toolbar {
align-items: stretch;
}
}
@media (max-width: 640px) {
@ -870,6 +1325,33 @@ button:hover {
padding: 1.15rem;
}
.me-shell {
padding: 1rem;
}
.me-panel {
padding: 1.05rem;
border-radius: 24px;
}
.me-action-row {
flex-direction: column;
}
.me-action-button {
width: 100%;
}
.me-glow--warm {
width: 13rem;
height: 13rem;
}
.me-glow--cool {
width: 15rem;
height: 15rem;
}
.login-card--brand {
padding: 1.4rem;
border-radius: 24px;
@ -884,4 +1366,14 @@ button:hover {
width: 15rem;
height: 15rem;
}
.proxy-sort-item__actions {
display: grid;
grid-template-columns: 1fr;
}
.proxy-toolbar-button,
.proxy-sort-button {
width: 100%;
}
}

View File

@ -2,7 +2,10 @@ import { NextResponse } from "next/server";
import { destroyAdminSession, destroyUserSession } from "@/lib/session";
export async function POST(request: Request) {
export async function POST() {
await Promise.all([destroyAdminSession(), destroyUserSession()]);
return NextResponse.redirect(new URL("/login", request.url));
const response = new NextResponse(null, { status: 303 });
response.headers.set("Location", "/login");
return response;
}

View File

@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getRequestOrigin } from "@/lib/request-origin";
@ -5,6 +6,10 @@ import { buildSubscriptionUrl, describeRemainingTraffic } from "@/lib/subscripti
import { requireUserSession } from "@/lib/session";
import { formatBytes, getUserById } from "@/lib/store";
export const metadata: Metadata = {
title: "我的订阅",
};
export default async function MePage() {
const session = await requireUserSession();
const [user, origin] = await Promise.all([
@ -16,59 +21,145 @@ export default async function MePage() {
redirect("/login");
}
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
const subscriptionUrl = user.subscription_token
? buildSubscriptionUrl(user.subscription_token, origin)
: null;
const clashImportUrl = subscriptionUrl
? `clash://install-config?url=${encodeURIComponent(subscriptionUrl)}`
: null;
return (
<main className="login-shell">
<section className="login-card wide-card">
<div className="hero-panel hero-panel--compact">
<div className="hero-copy">
<span className="eyebrow">SELF SERVICE</span>
<main className="me-shell">
<div aria-hidden="true" className="me-backdrop">
<span className="me-glow me-glow--warm" />
<span className="me-glow me-glow--cool" />
<span className="me-glow me-glow--light" />
</div>
<section className="me-stage">
<article className="me-panel me-hero">
<div className="me-hero-copy">
<span className="eyebrow">FEIEPROXY SELF SERVICE</span>
<h1>{user.username}</h1>
<p className="page-lede">
{formatBytes(user.used_tx_bytes + user.used_rx_bytes)}{describeRemainingTraffic(user)}
FeieProxy
Clash URL Scheme
</p>
<div className="badge-row">
<span className={`status-pill ${user.enabled ? "status-pill--ok" : "status-pill--danger"}`}>
{user.enabled ? "已启用" : "已禁用"}
<span
className={`status-pill ${
user.enabled ? "status-pill--ok" : "status-pill--danger"
}`}
>
{user.enabled ? "账号已启用" : "账号已禁用"}
</span>
<span className="status-pill status-pill--neutral">
{user.expires_at ?? "长期有效"}
</span>
<span className="status-pill status-pill--warm">
{describeRemainingTraffic(user)}
</span>
</div>
</div>
<div className="hero-aside">
<div className="stat-tile">
<span>auth_id</span>
<div className="me-identity-card">
<span className="feature-kicker">ACCOUNT SNAPSHOT</span>
<strong className="mono break-all">{user.auth_id}</strong>
<p className="muted">
{user.subscription_label || user.username}
</p>
</div>
<div className="stat-tile">
<span></span>
<strong className="mono break-all">
{user.subscription_token
? buildSubscriptionUrl(user.subscription_token, origin)
: "暂不可用"}
</strong>
</article>
<section className="me-grid">
<article className="me-panel me-subscription-card">
<div className="section-title section-title--top">
<div>
<span className="feature-kicker">SUBSCRIPTION</span>
<h2></h2>
</div>
<span className="status-pill status-pill--ink">Clash / Mihomo</span>
</div>
<p className="muted">
`clash://`
</p>
<div className="me-action-row">
{clashImportUrl ? (
<a className="primary-button me-action-button" href={clashImportUrl}>
Clash
</a>
) : (
<span className="status-pill status-pill--off"></span>
)}
{subscriptionUrl ? (
<a
className="ghost-button me-action-button"
href={subscriptionUrl}
target="_blank"
rel="noreferrer"
>
</a>
) : null}
</div>
<div className="me-link-card">
<span className="feature-kicker">SUBSCRIPTION URL</span>
<p className="mono break-all">{subscriptionUrl ?? "暂不可用"}</p>
</div>
<div className="me-tip-grid">
<div className="me-tip-card">
<span className="feature-kicker">TIP 01</span>
<strong></strong>
<p className="muted"> `clash://` </p>
</div>
<div className="me-tip-card">
<span className="feature-kicker">TIP 02</span>
<strong></strong>
<p className="muted"> Clash / Mihomo </p>
</div>
</div>
</article>
<div className="me-side-stack">
<article className="me-panel">
<div className="section-title">
<h2></h2>
</div>
<div className="summary-grid">
<div className="metric-inline">
<span></span>
<strong>{formatBytes(user.used_tx_bytes)}</strong>
<div className="me-metrics-grid">
<div className="me-metric-card me-metric-card--paper">
<span></span>
<strong>{formatBytes(usedBytes)}</strong>
</div>
<div className="metric-inline">
<span></span>
<strong>{formatBytes(user.used_rx_bytes)}</strong>
</div>
<div className="metric-inline">
<div className="me-metric-card me-metric-card--warm">
<span></span>
<strong>{describeRemainingTraffic(user)}</strong>
</div>
<div className="me-metric-card me-metric-card--ink">
<span></span>
<strong>{formatBytes(user.used_tx_bytes)}</strong>
</div>
<div className="me-metric-card me-metric-card--sage">
<span></span>
<strong>{formatBytes(user.used_rx_bytes)}</strong>
</div>
</div>
</article>
<article className="me-panel me-support-card">
<span className="feature-kicker">ACCESS</span>
<h2>使</h2>
<ul className="me-note-list">
<li></li>
<li>使 `IP:3000` </li>
<li></li>
</ul>
<form action="/logout" method="post">
<button className="ghost-button" type="submit">
<button className="ghost-button me-action-button me-action-button--full" type="submit">
退
</button>
</form>
</article>
</div>
</section>
</section>
</main>
);

View File

@ -0,0 +1,53 @@
# If your certificate files are stored elsewhere, update these two paths first.
upstream hy2_panel_upstream {
server 127.0.0.1:3000;
keepalive 32;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name hy2.xn--876a.net;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name hy2.xn--876a.net;
ssl_certificate /etc/nginx/ssl/hy2.xn--876a.net/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/hy2.xn--876a.net/privkey.pem;
ssl_session_cache shared:hy2_panel_ssl:10m;
ssl_session_timeout 1d;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 10m;
location / {
proxy_http_version 1.1;
proxy_pass http://hy2_panel_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
}
}

View File

@ -2,23 +2,101 @@ import { headers } from "next/headers";
import { getEnv } from "@/lib/env";
export async function getRequestOrigin() {
const headerList = await headers();
const forwardedProto = headerList.get("x-forwarded-proto");
const forwardedHost = headerList.get("x-forwarded-host");
const host = forwardedHost || headerList.get("host");
if (!host) {
return getEnv().APP_URL;
function getFirstHeaderValue(value: string | null) {
return value?.split(",")[0]?.trim() || null;
}
const proto =
forwardedProto || (host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "http");
function sanitizeProto(value: string | null) {
const candidate = getFirstHeaderValue(value)?.toLowerCase();
if (candidate === "http" || candidate === "https") {
return candidate;
}
return `${proto}://${host}`;
return null;
}
function sanitizeHost(value: string | null) {
const candidate = getFirstHeaderValue(value)?.replace(/^"|"$/g, "");
if (!candidate) {
return null;
}
if (candidate.includes("/") || /\s/.test(candidate)) {
return null;
}
return candidate;
}
function parseForwardedHeader(value: string | null) {
const firstEntry = getFirstHeaderValue(value);
if (!firstEntry) {
return {
host: null,
proto: null,
};
}
let host: string | null = null;
let proto: "http" | "https" | null = null;
for (const segment of firstEntry.split(";")) {
const [rawKey, ...rest] = segment.split("=");
const key = rawKey?.trim().toLowerCase();
const rawValue = rest.join("=").trim();
if (!key || !rawValue) {
continue;
}
if (key === "host") {
host = sanitizeHost(rawValue) || host;
} else if (key === "proto") {
proto = sanitizeProto(rawValue) || proto;
}
}
return { host, proto };
}
function buildOriginFromParts(
host: string | null,
proto: "http" | "https" | null,
fallback: string,
) {
if (!host) {
return fallback;
}
return `${proto || "http"}://${host}`;
}
export async function getRequestOrigin() {
const headerList = await headers();
const envOrigin = getEnv().APP_URL;
const forwarded = parseForwardedHeader(headerList.get("forwarded"));
const host =
sanitizeHost(headerList.get("x-forwarded-host")) ||
forwarded.host ||
sanitizeHost(headerList.get("host"));
const proto =
sanitizeProto(headerList.get("x-forwarded-proto")) || forwarded.proto;
return buildOriginFromParts(host, proto, envOrigin);
}
export function getOriginFromRequest(request: Request) {
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
const forwarded = parseForwardedHeader(request.headers.get("forwarded"));
const host =
sanitizeHost(request.headers.get("x-forwarded-host")) ||
forwarded.host ||
sanitizeHost(request.headers.get("host")) ||
url.host;
const proto =
sanitizeProto(request.headers.get("x-forwarded-proto")) ||
forwarded.proto ||
sanitizeProto(url.protocol.replace(/:$/, ""));
return buildOriginFromParts(host, proto, getEnv().APP_URL);
}