Merge pull request #42 from cloud-neutral-toolkit/codex/homepage-live-stats

Codex/homepage live stats
This commit is contained in:
Haitao Pan 2026-02-04 15:17:01 +08:00 committed by GitHub
commit 4f16ed15b6
8 changed files with 217 additions and 10 deletions

View File

@ -80,6 +80,12 @@ yarn build
> For detailed steps on configuring GitHub and Google OIDC authentication, please refer to the [OIDC Authentication Guide](./docs/integrations/oidc-auth.md).
## 统计配置 (Homepage Stats Configuration)
首页“注册用户数 / 访问量”所需 Cloudflare 变量说明,请参阅 [Cloudflare Web Analytics 集成配置](./docs/integrations/cloudflare-web-analytics.md)。
> For Cloudflare variables used by homepage stats, see the [Cloudflare Web Analytics integration guide](./docs/integrations/cloudflare-web-analytics.md).
## 开发指南 (Development Guidelines)
有关详细的编码标准、架构规则和 Agent 特定说明,请参阅 [AGENTS.md](./AGENTS.md)。

View File

@ -46,6 +46,7 @@ This directory follows a standard open-source documentation layout and mirrors t
- Integrations
- `integrations/databases.md`
- `integrations/cloud.md`
- `integrations/cloudflare-web-analytics.md`
- `integrations/ai-providers.md`
- Advanced
- `advanced/performance.md`

View File

@ -0,0 +1,80 @@
# Cloudflare Web Analytics 集成配置
本页说明首页统计接口 `/api/marketing/home-stats` 依赖的 3 个 Cloudflare 环境变量如何获取,以及应配置到哪里。
## 需要的环境变量
```bash
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=
```
## 变量获取方式
### 1) `CLOUDFLARE_API_TOKEN`
用途:服务端调用 Cloudflare GraphQL API 读取访问量。
获取路径:
1. 打开 Cloudflare 控制台,右上角头像 -> **My Profile**
2. 进入 **API Tokens**
3. 点击 **Create Token**
4. 建议创建仅只读 token至少包含**Account Analytics:Read**(作用域限定到目标 Account
5. 复制生成后的 token只显示一次
### 2) `CLOUDFLARE_ACCOUNT_ID`
用途GraphQL 查询时定位账号。
获取方式(任选其一):
- 在 Cloudflare 控制台 URL 中,账号路径段通常就是 account id。
- 在账号总览页面Overview侧边栏/页面信息中复制 Account ID。
### 3) `CLOUDFLARE_WEB_ANALYTICS_SITE_TAG`
用途:定位具体 Web Analytics 站点。
获取方式(任选其一):
- 你当前这类链接中可直接看到:
`.../web-analytics/overview?siteTag~in=<SITE_TAG>&excludeBots=Yes`
其中 `<SITE_TAG>` 就是变量值。
- 在 Cloudflare Web Analytics 的站点设置/安装脚本中,`siteTag`(或 beacon token即对应值。
## 配置写入位置
### 本地开发
写入 `console.svc.plus/.env.local`
```bash
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=...
```
### 线上部署
把同名变量写入 `console.svc.plus` 的部署环境(例如 Vercel/Cloud Run 的环境变量配置)。
> 注意:这些变量属于服务端密钥,不要暴露到 `NEXT_PUBLIC_*`
## 联调验证
部署后访问:
```bash
curl -fsSL https://console.svc.plus/api/marketing/home-stats
```
期望返回中 `visits.daily/weekly/monthly` 为数字(非 `null`)。
如果是 `null`,优先检查:
1. token 权限是否包含 Analytics Read
2. Account ID 是否与 siteTag 属于同一账号
3. 环境变量是否已在当前运行实例生效(重启/重新部署后再测)

View File

@ -9,6 +9,8 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type AccountUser = {
id?: string
uuid?: string
proxyUuid?: string
proxyUuidExpiresAt?: string
name?: string
username?: string
email: string
@ -24,6 +26,7 @@ type AccountUser = {
role?: string
groups?: string[]
permissions?: string[]
readOnly?: boolean
tenantId?: string
tenants?: Array<{
id?: string
@ -37,6 +40,23 @@ type SessionResponse = {
error?: string
}
function normalizeRole(role: unknown): string {
if (typeof role !== 'string') {
return 'user'
}
const normalized = role.trim().toLowerCase()
if (!normalized) {
return 'user'
}
if (normalized === 'root' || normalized === 'super_admin') {
return 'admin'
}
if (normalized === 'readonly' || normalized === 'read_only') {
return 'user'
}
return normalized
}
async function fetchSession(token: string) {
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
@ -86,10 +106,8 @@ export async function GET(request: NextRequest) {
: false
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
const normalizedRole =
typeof rawUser.role === 'string' && rawUser.role.trim().length > 0
? rawUser.role.trim().toLowerCase()
: 'user'
const normalizedRole = normalizeRole(rawUser.role)
const rawRole = typeof rawUser.role === 'string' ? rawUser.role.trim().toLowerCase() : ''
const normalizedGroups = Array.isArray(rawUser.groups)
? rawUser.groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
@ -100,6 +118,21 @@ export async function GET(request: NextRequest) {
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedReadOnly =
Boolean(rawUser.readOnly) ||
normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') ||
rawRole === 'readonly' ||
rawRole === 'read_only' ||
String(rawUser.email ?? '').trim().toLowerCase() === 'demo@svc.plus'
const normalizedProxyUuid =
typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0
? rawUser.proxyUuid.trim()
: undefined
const normalizedProxyUuidExpiresAt =
typeof rawUser.proxyUuidExpiresAt === 'string' && rawUser.proxyUuidExpiresAt.trim().length > 0
? rawUser.proxyUuidExpiresAt.trim()
: undefined
const normalizedTenantId =
typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
? rawUser.tenantId.trim()
@ -158,6 +191,9 @@ export async function GET(request: NextRequest) {
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
readOnly: normalizedReadOnly,
proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},

View File

@ -13,6 +13,8 @@ export type TenantMembership = {
type User = {
id: string
uuid: string
proxyUuid?: string
proxyUuidExpiresAt?: string
email: string
name?: string
username: string
@ -25,6 +27,7 @@ type User = {
isUser: boolean
isOperator: boolean
isAdmin: boolean
isReadOnly: boolean
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
@ -50,6 +53,10 @@ type UserStore = {
}
const KNOWN_ROLE_MAP: Record<string, UserRole> = {
root: 'admin',
super_admin: 'admin',
readonly: 'user',
read_only: 'user',
admin: 'admin',
administrator: 'admin',
operator: 'operator',
@ -97,6 +104,9 @@ async function fetchSessionUser(): Promise<User | null> {
role?: string
groups?: string[]
permissions?: string[]
proxyUuid?: string
proxyUuidExpiresAt?: string
readOnly?: boolean
tenantId?: string
tenants?: TenantMembership[]
mfa?: {
@ -141,6 +151,7 @@ async function fetchSessionUser(): Promise<User | null> {
}
const normalizedRole = normalizeRole(role)
const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : ''
const normalizedGroups = Array.isArray(groups)
? groups
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
@ -151,6 +162,22 @@ async function fetchSessionUser(): Promise<User | null> {
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
: []
const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : ''
const inferredReadOnly =
rawRole === 'readonly' ||
rawRole === 'read_only' ||
normalizedEmail === 'demo@svc.plus' ||
normalizedGroups.some((value) => value.toLowerCase() === 'readonly role')
const normalizedReadOnly = Boolean(sessionUser.readOnly ?? inferredReadOnly)
const normalizedProxyUuid =
typeof sessionUser.proxyUuid === 'string' && sessionUser.proxyUuid.trim().length > 0
? sessionUser.proxyUuid.trim()
: undefined
const normalizedProxyUuidExpiresAt =
typeof sessionUser.proxyUuidExpiresAt === 'string' && sessionUser.proxyUuidExpiresAt.trim().length > 0
? sessionUser.proxyUuidExpiresAt.trim()
: undefined
const normalizedTenantId =
typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0
? sessionUser.tenantId.trim()
@ -189,6 +216,8 @@ async function fetchSessionUser(): Promise<User | null> {
return {
id: identifier,
uuid: identifier,
proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
email,
name: normalizedName,
username: normalizedUsername ?? email,
@ -202,6 +231,7 @@ async function fetchSessionUser(): Promise<User | null> {
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',
isReadOnly: normalizedReadOnly,
tenantId: normalizedTenantId,
tenants: normalizedTenants,
}

View File

@ -126,6 +126,7 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps
const setupRequested = searchParams.get('setupMfa') === '1'
const hasPendingMfa = Boolean(status?.totpPending && !status?.totpEnabled)
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const isReadOnlyAccount = Boolean(user?.isReadOnly)
const resolveErrorMessage = useCallback(
(code?: string | null) => {
@ -146,6 +147,7 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps
'mfa_challenge_creation_failed': copy.errors.provisioningFailed,
'mfa_status_failed': copy.errors.network,
'account_service_unreachable': copy.errors.network,
'read_only_account': copy.errors.disableFailed,
'mfa_disable_failed': copy.errors.disableFailed,
'mfa_not_enabled': copy.errors.disableFailed,
'mfa_code_required': copy.errors.missingCode,
@ -450,6 +452,19 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps
)
}
if (isReadOnlyAccount) {
return (
<Card>
<h2 className="text-xl font-semibold text-[var(--color-text)]">{copy.title}</h2>
<p className="mt-2 text-sm text-[var(--color-text-subtle)]">
{language === 'zh'
? 'Demo 体验账号已关闭 MFA且账号为只读模式。你可以浏览控制台与使用二维码但不能执行修改操作。'
: 'MFA is disabled for the Demo account and the account is read-only. You can browse and use the QR code, but changes are blocked.'}
</p>
</Card>
)
}
const statusLabel = displayStatus?.totpEnabled
? copy.state.enabled
: displayStatus?.totpPending || hasPendingMfa

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Copy } from 'lucide-react'
@ -45,15 +45,27 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const mfaCopy = translations[language].userCenter.mfa
const user = useUserStore((state) => state.user)
const isLoading = useUserStore((state) => state.isLoading)
const refresh = useUserStore((state) => state.refresh)
const logout = useUserStore((state) => state.logout)
const [copied, setCopied] = useState(false)
const displayName = useMemo(() => resolveDisplayName(user), [user])
const uuid = user?.uuid ?? user?.id ?? '—'
const vlessUuid = user?.uuid ?? user?.id ?? null
const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null
const username = user?.username ?? '—'
const email = user?.email ?? '—'
const docsUrl = mfaCopy.actions.docsUrl
const isDemoReadOnly = Boolean(user?.isReadOnly && user?.email?.toLowerCase() === 'demo@svc.plus')
const demoUuidExpiresAtText = useMemo(() => {
if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) {
return null
}
const date = new Date(user.proxyUuidExpiresAt)
if (Number.isNaN(date.getTime())) {
return null
}
return date.toLocaleString()
}, [isDemoReadOnly, user?.proxyUuidExpiresAt])
const mfaStatusLabel = useMemo(() => {
if (user?.mfaEnabled) {
@ -68,7 +80,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const handleCopy = useCallback(async () => {
const identifier = user?.uuid ?? user?.id
const identifier = user?.proxyUuid ?? user?.uuid ?? user?.id
if (!identifier) {
return
}
@ -92,7 +104,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
} catch (error) {
console.warn('Failed to copy UUID', error)
}
}, [user?.id, user?.uuid])
}, [user?.id, user?.proxyUuid, user?.uuid])
const handleGoToSetup = useCallback(() => {
router.push('/panel/account?setupMfa=1')
@ -104,10 +116,34 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
router.refresh()
}, [logout, router])
useEffect(() => {
if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) {
return
}
const expiresAt = new Date(user.proxyUuidExpiresAt).getTime()
if (!Number.isFinite(expiresAt)) {
return
}
const delay = Math.max(1000, expiresAt - Date.now() + 1500)
const timer = window.setTimeout(() => {
void refresh()
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [isDemoReadOnly, refresh, user?.proxyUuidExpiresAt])
return (
<div className="space-y-6 text-[var(--color-text)] transition-colors">
<div>
<p className="text-sm text-[var(--color-text-subtle)] opacity-90">{copy.uuidNote}</p>
{isDemoReadOnly ? (
<p className="mt-2 rounded-[var(--radius-md)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-2 text-xs text-[var(--color-warning-foreground)]">
{language === 'zh'
? `Demo 体验账号为只读模式:可浏览控制台、可使用 VLESS 二维码但不能修改任何配置。UUID 每 1 小时自动刷新${demoUuidExpiresAtText ? `(下次刷新约 ${demoUuidExpiresAtText}` : ''}`
: `Demo account runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour${demoUuidExpiresAtText ? ` (next refresh around ${demoUuidExpiresAtText})` : ''}.`}
</p>
) : null}
</div>
{!hideMfaMainPrompt && requiresSetup ? (

View File

@ -58,6 +58,10 @@ type AccountSessionResponse = {
}
const KNOWN_ROLE_MAP: Record<string, AccountUserRole> = {
root: 'admin',
super_admin: 'admin',
readonly: 'user',
read_only: 'user',
admin: 'admin',
administrator: 'admin',
operator: 'operator',
@ -246,4 +250,3 @@ export async function getAccountSession(request?: NextRequest): Promise<AccountS
return { token, user: null }
}
}