[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -0,0 +1,447 @@
'use client';
import { useEffect, useState } from 'react';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import {
createAuthSource,
deleteAuthSource,
toggleAuthSource,
updateAuthSource,
} from '@/features/settings/api/settings';
import type {
AuthSource,
AuthSourcePayload,
AuthSourceType,
} from '@/features/settings/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const emptyForm: AuthSourcePayload = {
name: '',
type: 'github',
display_name: '',
is_active: false,
client_id: '',
client_secret: '',
openid_discovery_url: '',
scopes: 'user:email',
icon_url: '',
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function sourceToForm(source: AuthSource): AuthSourcePayload {
return {
id: source.id,
name: source.name,
type: source.type,
display_name: source.display_name,
is_active: source.is_active,
client_id: source.client_id,
client_secret: '',
openid_discovery_url: source.openid_discovery_url,
scopes:
source.scopes ||
(source.type === 'oidc' ? 'openid profile email' : 'user:email'),
icon_url: source.icon_url,
};
}
function buildCallbackURL(origin: string, sourceName: string) {
const normalizedName = sourceName.trim() || '认证源名称';
return `${origin || '当前访问地址'}/oauth/${encodeURIComponent(normalizedName)}`;
}
export function AuthSourceModal({
isOpen,
sources,
isLoading,
error,
onClose,
onChanged,
}: {
isOpen: boolean;
sources: AuthSource[];
isLoading: boolean;
error: unknown;
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const [mode, setMode] = useState<'list' | 'edit'>('list');
const [editingSource, setEditingSource] = useState<AuthSource | null>(null);
const [form, setForm] = useState<AuthSourcePayload>(emptyForm);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const [browserOrigin, setBrowserOrigin] = useState('');
useEffect(() => {
if (!isOpen) {
setMode('list');
setEditingSource(null);
setForm(emptyForm);
setBusyKey(null);
setMessage(null);
}
}, [isOpen]);
useEffect(() => {
if (typeof window !== 'undefined') {
setBrowserOrigin(window.location.origin);
}
}, []);
const startCreate = () => {
setEditingSource(null);
setForm(emptyForm);
setMessage(null);
setMode('edit');
};
const startEdit = (source: AuthSource) => {
setEditingSource(source);
setForm(sourceToForm(source));
setMessage(null);
setMode('edit');
};
const updateType = (type: AuthSourceType) => {
setForm((previous) => ({
...previous,
type,
scopes:
previous.scopes === 'user:email' ||
previous.scopes === 'openid profile email' ||
!previous.scopes
? type === 'oidc'
? 'openid profile email'
: 'user:email'
: previous.scopes,
}));
};
const runAction = async (key: string, action: () => Promise<void>) => {
setBusyKey(key);
setMessage(null);
try {
await action();
} catch (actionError) {
setMessage({ tone: 'danger', text: getErrorMessage(actionError) });
} finally {
setBusyKey(null);
}
};
const saveForm = () => {
void runAction('save', async () => {
const payload: AuthSourcePayload = {
...form,
name: form.name.trim(),
display_name: form.display_name.trim(),
client_id: form.client_id.trim(),
client_secret: form.client_secret.trim(),
openid_discovery_url: form.openid_discovery_url.trim(),
scopes: form.scopes.trim(),
icon_url: form.icon_url.trim(),
};
if (editingSource) {
await updateAuthSource(editingSource.id, payload);
} else {
await createAuthSource(payload);
}
await onChanged();
setMessage({ tone: 'success', text: '认证源已保存。' });
setMode('list');
setEditingSource(null);
setForm(emptyForm);
});
};
const removeSource = (source: AuthSource) => {
if (
!window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
)
) {
return;
}
void runAction(`delete-${source.id}`, async () => {
await deleteAuthSource(source.id);
await onChanged();
setMessage({ tone: 'success', text: '认证源已删除。' });
});
};
const toggleSource = (source: AuthSource) => {
void runAction(`toggle-${source.id}`, async () => {
await toggleAuthSource(source.id, !source.is_active);
await onChanged();
setMessage({ tone: 'success', text: '认证源状态已更新。' });
});
};
return (
<AppModal
isOpen={isOpen}
title="认证源"
description="配置 GitHub 或标准 OIDC 登录入口。启用后会显示在登录页。"
size="xl"
onClose={onClose}
footer={
mode === 'edit' ? (
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={() => setMode('list')}>
返回列表
</SecondaryButton>
<PrimaryButton
type="button"
onClick={saveForm}
disabled={busyKey === 'save'}
>
{busyKey === 'save' ? '保存中...' : '保存认证源'}
</PrimaryButton>
</div>
) : (
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
)
}
>
<div className="space-y-5">
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
{mode === 'list' ? (
<div className="space-y-4">
<div className="flex justify-end">
<PrimaryButton type="button" onClick={startCreate}>
新增认证源
</PrimaryButton>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="认证源加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && sources.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无认证源。
</div>
) : null}
{!isLoading && !error && sources.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="px-4 py-3 font-medium">名称</th>
<th className="px-4 py-3 font-medium">类型</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">Client ID</th>
<th className="px-4 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{sources.map((source) => (
<tr key={source.id}>
<td className="px-4 py-3">
<div className="font-medium text-[var(--foreground-primary)]">
{source.display_name || source.name}
</div>
<div className="text-xs text-[var(--foreground-secondary)]">
{source.name}
</div>
</td>
<td className="px-4 py-3 uppercase">{source.type}</td>
<td className="px-4 py-3">
{source.is_active ? '已启用' : '已禁用'}
</td>
<td className="max-w-[220px] truncate px-4 py-3">
{source.client_id || '-'}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton
type="button"
onClick={() => toggleSource(source)}
disabled={busyKey === `toggle-${source.id}`}
>
{source.is_active ? '禁用' : '启用'}
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => startEdit(source)}
>
修改
</SecondaryButton>
<DangerButton
type="button"
onClick={() => removeSource(source)}
disabled={busyKey === `delete-${source.id}`}
>
删除
</DangerButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
) : (
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="认证源名称">
<ResourceInput
value={form.name}
onChange={(event) =>
setForm((previous) => ({
...previous,
name: event.target.value,
}))
}
placeholder="GitHub"
/>
</ResourceField>
<ResourceField label="展示名称">
<ResourceInput
value={form.display_name}
onChange={(event) =>
setForm((previous) => ({
...previous,
display_name: event.target.value,
}))
}
placeholder="GitHub"
/>
</ResourceField>
<ResourceField label="类型">
<ResourceSelect
value={form.type}
onChange={(event) =>
updateType(event.target.value as AuthSourceType)
}
>
<option value="github">GitHub</option>
<option value="oidc">OIDC</option>
</ResourceSelect>
</ResourceField>
<ResourceField label="状态">
<ResourceSelect
value={form.is_active ? 'true' : 'false'}
onChange={(event) =>
setForm((previous) => ({
...previous,
is_active: event.target.value === 'true',
}))
}
>
<option value="false">禁用</option>
<option value="true">启用</option>
</ResourceSelect>
</ResourceField>
<ResourceField label="Client ID">
<ResourceInput
value={form.client_id}
onChange={(event) =>
setForm((previous) => ({
...previous,
client_id: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField
label="Client Secret"
hint={editingSource ? '留空表示不更新现有密钥。' : undefined}
>
<ResourceInput
type="password"
value={form.client_secret}
onChange={(event) =>
setForm((previous) => ({
...previous,
client_secret: event.target.value,
}))
}
/>
</ResourceField>
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)] md:col-span-2">
{form.name.trim() ? (
<>
第三方平台的 Redirect URI / Callback URL 请填写:
<span className="font-medium">
{' '}
{buildCallbackURL(browserOrigin, form.name)}
</span>
。末尾路径使用上方填写的认证源名称,保存后如修改认证源名称,也需要同步更新第三方平台中的回调地址。
</>
) : (
'请先输入认证源名称获取回调地址'
)}
</div>
{form.type === 'oidc' ? (
<ResourceField
label="OIDC Discovery URL"
className="md:col-span-2"
>
<ResourceInput
value={form.openid_discovery_url}
onChange={(event) =>
setForm((previous) => ({
...previous,
openid_discovery_url: event.target.value,
}))
}
placeholder="https://auth.example.com/.well-known/openid-configuration"
/>
</ResourceField>
) : null}
<ResourceField label="Scopes">
<ResourceInput
value={form.scopes}
onChange={(event) =>
setForm((previous) => ({
...previous,
scopes: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="图标 URL">
<ResourceInput
value={form.icon_url}
onChange={(event) =>
setForm((previous) => ({
...previous,
icon_url: event.target.value,
}))
}
/>
</ResourceField>
</div>
)}
</div>
</AppModal>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '加载站点列表失败。';
}
export function UptimeKumaSiteSelectModal({
isOpen,
selectedSites,
onClose,
onSave,
}: {
isOpen: boolean;
selectedSites: string[];
onClose: () => void;
onSave: (sites: string[]) => void;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const {
data: routes = [],
isLoading,
error,
} = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
enabled: isOpen,
});
useEffect(() => {
if (isOpen) {
setTempSelected(
new Set(selectedSites.map((s) => s.trim()).filter(Boolean)),
);
setSearchTerm('');
}
}, [isOpen, selectedSites]);
const toggleSite = (siteName: string) => {
setTempSelected((prev) => {
const next = new Set(prev);
if (next.has(siteName)) {
next.delete(siteName);
} else {
next.add(siteName);
}
return next;
});
};
const handleSelectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.add(route.site_name);
});
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.delete(route.site_name);
});
return next;
});
};
const handleSave = () => {
onSave(Array.from(tempSelected));
onClose();
};
const filteredRoutes = routes.filter(
(route) =>
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<AppModal
isOpen={isOpen}
title="选择监控站点"
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" onClick={handleSave}>
保存选择
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1">
<ResourceField label="搜索站点">
<ResourceInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按名称或域名搜索..."
/>
</ResourceField>
</div>
<div className="flex gap-2 sm:mt-6">
<SecondaryButton type="button" onClick={handleSelectAll}>
全选过滤项
</SecondaryButton>
<SecondaryButton type="button" onClick={handleDeselectAll}>
清空过滤项
</SecondaryButton>
</div>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="站点加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && routes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无可用的代理站点。
</div>
) : null}
{!isLoading && !error && routes.length > 0 ? (
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="w-12 px-4 py-3">选择</th>
<th className="px-4 py-3 font-medium">站点名称</th>
<th className="px-4 py-3 font-medium">主域名</th>
<th className="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredRoutes.map((route) => {
const isChecked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
onClick={() => toggleSite(route.site_name)}
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
>
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleSite(route.site_name)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</td>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{route.site_name}
</td>
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
{route.primary_domain}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
route.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{route.enabled ? '启用' : '禁用'}
</span>
</td>
</tr>
);
})}
{filteredRoutes.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-4 py-8 text-center text-[var(--foreground-secondary)]"
>
无匹配的站点
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
<div className="text-right text-xs text-[var(--foreground-muted)]">
已选择 {tempSelected.size} 个监控站点
</div>
</div>
</AppModal>
);
}