mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 02:26:36 +08:00
[优化] 新增 GeoIP 测试功能,更新相关 API 路由和前端组件
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"atsflare/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type geoIPLookupRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupGeoIP godoc
|
||||||
|
// @Summary Test GeoIP lookup
|
||||||
|
// @Tags Options
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body geoIPLookupRequest true "GeoIP lookup payload"
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Failure 400 {object} map[string]interface{}
|
||||||
|
// @Router /api/option/geoip/lookup [post]
|
||||||
|
func LookupGeoIP(c *gin.Context) {
|
||||||
|
var request geoIPLookupRequest
|
||||||
|
if err := decodeJSONBody(c.Request.Body, &request); err != nil {
|
||||||
|
respondBadRequest(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := service.LookupGeoIP(request.Provider, request.IP)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, view)
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
{
|
{
|
||||||
optionRoute.GET("/", controller.GetOptions)
|
optionRoute.GET("/", controller.GetOptions)
|
||||||
optionRoute.PUT("/", controller.UpdateOption)
|
optionRoute.PUT("/", controller.UpdateOption)
|
||||||
|
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
||||||
}
|
}
|
||||||
updateRoute := apiRouter.Group("/update")
|
updateRoute := apiRouter.Group("/update")
|
||||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"atsflare/utils/geoip"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeoIPLookupView struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
ISOCode string `json:"iso_code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Latitude *float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude *float64 `json:"longitude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupGeoIP(provider string, rawIP string) (*GeoIPLookupView, error) {
|
||||||
|
trimmedProvider := strings.TrimSpace(provider)
|
||||||
|
if !geoip.IsValidProvider(trimmedProvider) {
|
||||||
|
return nil, errors.New("归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedIP := strings.TrimSpace(rawIP)
|
||||||
|
if trimmedIP == "" {
|
||||||
|
return nil, errors.New("IP 不能为空")
|
||||||
|
}
|
||||||
|
parsedIP := net.ParseIP(trimmedIP)
|
||||||
|
if parsedIP == nil {
|
||||||
|
return nil, errors.New("IP 格式无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := geoip.LookupGeoInfoWithProvider(trimmedProvider, parsedIP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
return nil, errors.New("未获取到 IP 归属结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GeoIPLookupView{
|
||||||
|
Provider: trimmedProvider,
|
||||||
|
IP: parsedIP.String(),
|
||||||
|
ISOCode: info.ISOCode,
|
||||||
|
Name: info.Name,
|
||||||
|
Latitude: info.Latitude,
|
||||||
|
Longitude: info.Longitude,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"atsflare/utils/geoip"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeLookupProvider struct{}
|
||||||
|
|
||||||
|
func (f *fakeLookupProvider) Name() string {
|
||||||
|
return "fake-lookup"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLookupProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) {
|
||||||
|
return &geoip.GeoInfo{
|
||||||
|
ISOCode: "US",
|
||||||
|
Name: "United States",
|
||||||
|
Latitude: geoipFloat(37.7749),
|
||||||
|
Longitude: geoipFloat(-122.4194),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLookupProvider) UpdateDatabase() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLookupProvider) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupGeoIP(t *testing.T) {
|
||||||
|
previousFactory := geoip.ProviderFactoryForTest()
|
||||||
|
geoip.SetProviderFactoryForTest(func(provider string) (geoip.GeoIPService, error) {
|
||||||
|
return &fakeLookupProvider{}, nil
|
||||||
|
})
|
||||||
|
defer geoip.SetProviderFactoryForTest(previousFactory)
|
||||||
|
|
||||||
|
view, err := LookupGeoIP("ipinfo", "8.8.8.8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupGeoIP failed: %v", err)
|
||||||
|
}
|
||||||
|
if view.Provider != "ipinfo" {
|
||||||
|
t.Fatalf("expected provider ipinfo, got %s", view.Provider)
|
||||||
|
}
|
||||||
|
if view.IP != "8.8.8.8" {
|
||||||
|
t.Fatalf("expected IP 8.8.8.8, got %s", view.IP)
|
||||||
|
}
|
||||||
|
if view.ISOCode != "US" || view.Name != "United States" {
|
||||||
|
t.Fatalf("unexpected lookup view: %+v", view)
|
||||||
|
}
|
||||||
|
if view.Latitude == nil || view.Longitude == nil {
|
||||||
|
t.Fatalf("expected coordinates, got %+v", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupGeoIPRejectsInvalidInput(t *testing.T) {
|
||||||
|
if _, err := LookupGeoIP("invalid", "8.8.8.8"); err == nil {
|
||||||
|
t.Fatal("expected invalid provider to fail")
|
||||||
|
}
|
||||||
|
if _, err := LookupGeoIP("ipinfo", "not-an-ip"); err == nil {
|
||||||
|
t.Fatal("expected invalid IP to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
var CurrentProvider GeoIPService
|
var CurrentProvider GeoIPService
|
||||||
var geoCache *providerCache
|
var geoCache *providerCache
|
||||||
var providerMutex sync.RWMutex
|
var providerMutex sync.RWMutex
|
||||||
|
var providerFactory = newProvider
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProviderDisabled = "disabled"
|
ProviderDisabled = "disabled"
|
||||||
@@ -111,7 +112,7 @@ func GetRegionUnicodeEmoji(isoCode string) string {
|
|||||||
|
|
||||||
func InitGeoIP() {
|
func InitGeoIP() {
|
||||||
providerName := normalizeProvider(common.GeoIPProvider)
|
providerName := normalizeProvider(common.GeoIPProvider)
|
||||||
nextProvider, err := newProvider(providerName)
|
nextProvider, err := providerFactory(providerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err)
|
slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err)
|
||||||
nextProvider = &EmptyProvider{}
|
nextProvider = &EmptyProvider{}
|
||||||
@@ -142,6 +143,24 @@ func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
|||||||
return info, err
|
return info, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LookupGeoInfoWithProvider(providerName string, ip net.IP) (*GeoInfo, error) {
|
||||||
|
if ip == nil {
|
||||||
|
return nil, fmt.Errorf("IP address cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := providerFactory(normalizeProvider(providerName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := provider.Close(); closeErr != nil {
|
||||||
|
slog.Warn("close temporary GeoIP provider failed", "provider", provider.Name(), "error", closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return provider.GetGeoInfo(ip)
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateDatabase() error {
|
func UpdateDatabase() error {
|
||||||
err := getProvider().UpdateDatabase()
|
err := getProvider().UpdateDatabase()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -210,3 +229,15 @@ func getProvider() GeoIPService {
|
|||||||
func float64Pointer(value float64) *float64 {
|
func float64Pointer(value float64) *float64 {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ProviderFactoryForTest() func(string) (GeoIPService, error) {
|
||||||
|
return providerFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetProviderFactoryForTest(factory func(string) (GeoIPService, error)) {
|
||||||
|
if factory == nil {
|
||||||
|
providerFactory = newProvider
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providerFactory = factory
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,3 +79,21 @@ func TestIsValidProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupGeoInfoWithProviderUsesTemporaryProvider(t *testing.T) {
|
||||||
|
previousFactory := providerFactory
|
||||||
|
providerFactory = func(provider string) (GeoIPService, error) {
|
||||||
|
return &fakeProvider{}, nil
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
providerFactory = previousFactory
|
||||||
|
}()
|
||||||
|
|
||||||
|
info, err := LookupGeoInfoWithProvider("ipinfo", net.ParseIP("8.8.8.8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected lookup to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if info == nil || info.ISOCode != "CN" || info.Name != "China" {
|
||||||
|
t.Fatalf("unexpected geo info: %#v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { apiRequest } from '@/lib/api/client';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
BootstrapTokenPayload,
|
BootstrapTokenPayload,
|
||||||
|
GeoIPLookupResult,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
SettingsProfile,
|
SettingsProfile,
|
||||||
UpdateSelfPayload,
|
UpdateSelfPayload,
|
||||||
@@ -18,6 +19,13 @@ export function updateOption(key: string, value: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lookupGeoIP(provider: string, ip: string) {
|
||||||
|
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider, ip }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getBootstrapToken() {
|
export function getBootstrapToken() {
|
||||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import {
|
|||||||
getBootstrapToken,
|
getBootstrapToken,
|
||||||
getOptions,
|
getOptions,
|
||||||
getSettingsProfile,
|
getSettingsProfile,
|
||||||
|
lookupGeoIP,
|
||||||
rotateBootstrapToken,
|
rotateBootstrapToken,
|
||||||
updateOption,
|
updateOption,
|
||||||
updateSelf,
|
updateSelf,
|
||||||
} from '@/features/settings/api/settings';
|
} from '@/features/settings/api/settings';
|
||||||
import type {
|
import type {
|
||||||
BootstrapTokenPayload,
|
BootstrapTokenPayload,
|
||||||
|
GeoIPLookupResult,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
UpdateSelfPayload,
|
UpdateSelfPayload,
|
||||||
} from '@/features/settings/types';
|
} from '@/features/settings/types';
|
||||||
@@ -230,6 +232,7 @@ export function SettingsPage() {
|
|||||||
const [emailAddress, setEmailAddress] = useState('');
|
const [emailAddress, setEmailAddress] = useState('');
|
||||||
const [emailCode, setEmailCode] = useState('');
|
const [emailCode, setEmailCode] = useState('');
|
||||||
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
||||||
|
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
|
||||||
|
|
||||||
const isRoot = (user?.role ?? 0) >= 100;
|
const isRoot = (user?.role ?? 0) >= 100;
|
||||||
|
|
||||||
@@ -443,6 +446,11 @@ export function SettingsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const geoIPLookupMutation = useMutation({
|
||||||
|
mutationFn: ({ provider, ip }: { provider: string; ip: string }) =>
|
||||||
|
lookupGeoIP(provider, ip),
|
||||||
|
});
|
||||||
|
|
||||||
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
||||||
const discoveryCommand =
|
const discoveryCommand =
|
||||||
isRoot && operationFields.ServerAddress && discoveryToken
|
isRoot && operationFields.ServerAddress && discoveryToken
|
||||||
@@ -505,6 +513,17 @@ export function SettingsPage() {
|
|||||||
setFeedback({ tone: 'success', message: successMessage });
|
setFeedback({ tone: 'success', message: successMessage });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGeoIPLookup = () => {
|
||||||
|
geoIPLookupMutation.reset();
|
||||||
|
geoIPLookupMutation.mutate({
|
||||||
|
provider: operationFields.GeoIPProvider,
|
||||||
|
ip: geoIPTestIP.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const geoIPLookupResult: GeoIPLookupResult | undefined =
|
||||||
|
geoIPLookupMutation.data;
|
||||||
|
|
||||||
const handleProfileSave = () => {
|
const handleProfileSave = () => {
|
||||||
void runBusyAction('profile', async () => {
|
void runBusyAction('profile', async () => {
|
||||||
await updateSelf({
|
await updateSelf({
|
||||||
@@ -946,7 +965,10 @@ export function SettingsPage() {
|
|||||||
[
|
[
|
||||||
['AgentHeartbeatInterval', String(heartbeat)],
|
['AgentHeartbeatInterval', String(heartbeat)],
|
||||||
['NodeOfflineThreshold', String(offline)],
|
['NodeOfflineThreshold', String(offline)],
|
||||||
['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()],
|
[
|
||||||
|
'AgentUpdateRepo',
|
||||||
|
operationFields.AgentUpdateRepo.trim(),
|
||||||
|
],
|
||||||
['GeoIPProvider', operationFields.GeoIPProvider],
|
['GeoIPProvider', operationFields.GeoIPProvider],
|
||||||
],
|
],
|
||||||
'运维设置已保存。',
|
'运维设置已保存。',
|
||||||
@@ -1006,7 +1028,8 @@ export function SettingsPage() {
|
|||||||
Agent 更新仓库
|
Agent 更新仓库
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-[var(--foreground-muted)]">
|
<p className="text-sm text-[var(--foreground-muted)]">
|
||||||
自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。
|
自动更新和手动更新动作在节点页触发,这里维护 Agent
|
||||||
|
自更新使用的仓库地址。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -1031,7 +1054,8 @@ export function SettingsPage() {
|
|||||||
IP 归属方式
|
IP 归属方式
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-[var(--foreground-muted)]">
|
<p className="text-sm text-[var(--foreground-muted)]">
|
||||||
控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。
|
控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind
|
||||||
|
时会按需下载本地 mmdb 数据库。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -1056,6 +1080,84 @@ export function SettingsPage() {
|
|||||||
</ResourceSelect>
|
</ResourceSelect>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-5 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||||
|
<ResourceField
|
||||||
|
label="测试 IP"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={geoIPTestIP}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGeoIPTestIP(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="例如 8.8.8.8"
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleGeoIPLookup}
|
||||||
|
disabled={geoIPLookupMutation.isPending}
|
||||||
|
>
|
||||||
|
{geoIPLookupMutation.isPending
|
||||||
|
? '查询中...'
|
||||||
|
: '查询归属'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{geoIPLookupMutation.isError ? (
|
||||||
|
<InlineMessage
|
||||||
|
tone="danger"
|
||||||
|
message={getErrorMessage(geoIPLookupMutation.error)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{geoIPLookupResult ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
查询 IP
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{geoIPLookupResult.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
国家 / 地区
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{geoIPLookupResult.name || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
ISO Code
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<StatusBadge
|
||||||
|
label={geoIPLookupResult.iso_code || '—'}
|
||||||
|
variant="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
经纬度
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{geoIPLookupResult.latitude !== undefined &&
|
||||||
|
geoIPLookupResult.latitude !== null &&
|
||||||
|
geoIPLookupResult.longitude !== undefined &&
|
||||||
|
geoIPLookupResult.longitude !== null
|
||||||
|
? `${geoIPLookupResult.latitude.toFixed(4)}, ${geoIPLookupResult.longitude.toFixed(4)}`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
@@ -1281,7 +1383,9 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
disabled={busyKey === 'system-general'}
|
disabled={busyKey === 'system-general'}
|
||||||
>
|
>
|
||||||
{busyKey === 'system-general' ? '保存中...' : '保存通用设置'}
|
{busyKey === 'system-general'
|
||||||
|
? '保存中...'
|
||||||
|
: '保存通用设置'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1904,9 +2008,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader title="设置" />
|
||||||
title="设置"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{feedback ? (
|
{feedback ? (
|
||||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ export interface BootstrapTokenPayload {
|
|||||||
discovery_token: string;
|
discovery_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeoIPLookupResult {
|
||||||
|
provider: string;
|
||||||
|
ip: string;
|
||||||
|
iso_code: string;
|
||||||
|
name: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateSelfPayload {
|
export interface UpdateSelfPayload {
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user