diff --git a/server/index.js b/server/index.js
index 8bc79dc..81187a4 100644
--- a/server/index.js
+++ b/server/index.js
@@ -17,6 +17,7 @@ import projectsRouter, { columnRouter } from './routes/projects.js'
import tasksRouter from './routes/tasks.js'
import powersyncRouter from './routes/powersync.js'
import mcpTokensRouter from './routes/mcp-tokens.js'
+import systemRouter from './routes/system.js'
import mcpRouter from './mcp/index.js'
import { powersyncProxy } from './powersync-proxy.js'
@@ -25,6 +26,9 @@ const DIST_DIR = path.join(process.cwd(), 'dist')
const HAS_DIST = fs.existsSync(DIST_DIR)
const app = express()
+// 生产环境前面只有一层 NPM 反向代理: 信任 1 跳, 让 req.ip 取到真实客户端 IP
+// (否则拿到的是反代/容器网段地址, 「我」页的访问 IP 会显示错误)
+app.set('trust proxy', 1)
// 同步服务同域反代必须最先挂载 (流式长连接, 不能经过 json/cors 中间件)
app.use(powersyncProxy())
app.use(cors())
@@ -35,6 +39,8 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
// 业务路由
app.use('/api/auth', authRouter)
+// 系统信息 (当前访问 IP 等)
+app.use('/api/system', systemRouter)
// OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前,
// 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截
app.use('/api', otaRouter)
diff --git a/server/routes/system.js b/server/routes/system.js
new file mode 100644
index 0000000..7855003
--- /dev/null
+++ b/server/routes/system.js
@@ -0,0 +1,25 @@
+// 系统信息: 当前访问者 IP 等
+// 仅供「我」页展示, 需要登录后访问
+import { Router } from 'express'
+import { authRequired } from '../middleware.js'
+
+const router = Router()
+router.use(authRequired)
+router.use((_req, res, next) => {
+ res.set('Cache-Control', 'no-store')
+ next()
+})
+
+// 去掉 IPv4-mapped IPv6 前缀 (::ffff:1.2.3.4 → 1.2.3.4), 便于展示
+function normalizeIp(ip) {
+ const s = String(ip || '').trim()
+ return s.startsWith('::ffff:') ? s.slice(7) : s
+}
+
+// GET /api/system/client-ip 服务器看到的当前访问 IP
+// 依赖 index.js 里的 trust proxy 设置解析 X-Forwarded-For (生产经 NPM 反代)
+router.get('/client-ip', (req, res) => {
+ res.json({ ip: normalizeIp(req.ip || req.socket?.remoteAddress) })
+})
+
+export default router
diff --git a/src/api/system.js b/src/api/system.js
new file mode 100644
index 0000000..93ddaff
--- /dev/null
+++ b/src/api/system.js
@@ -0,0 +1,6 @@
+import { api } from './client.js'
+
+// 服务器看到的当前访问者 IP (用于「我」页展示)
+export function getClientIp() {
+ return api('/system/client-ip')
+}
diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx
index 6ebc11a..bee8ca2 100644
--- a/src/pages/MePage.jsx
+++ b/src/pages/MePage.jsx
@@ -15,6 +15,7 @@ import {
} from '../api/ota.js'
import { refreshIfinanceInfo, useIfinanceInfo } from '../lib/ifinance.js'
import { getMcpEndpoint, mcpTokenApi } from '../api/mcp.js'
+import { getClientIp } from '../api/system.js'
// ifinance 构建时间: 优先按 info.json 里的 ISO 字符串原样展示 (北京时间),
// 避免设备时区不同导致显示偏移。
@@ -53,7 +54,8 @@ export default function MePage() {
const navigate = useNavigate()
// 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
- // 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
+ // 由 CSS 让 .app-safe-top 使用与头部顶边一致的水平渐变.
+ // 安全区仍在滚动容器之外, 页面滚动时内容不会钻进系统状态栏.
// 离开本页时移除标记, 其他页恢复默认底色.
useEffect(() => {
document.body.classList.add('me-safe-top')
@@ -85,6 +87,18 @@ export default function MePage() {
const [mcpCreating, setMcpCreating] = useState(false)
const [mcpError, setMcpError] = useState('')
+ // 访问者 IP: 由服务端读取反代后的真实客户端地址
+ const [clientIp, setClientIp] = useState('')
+ const [clientIpError, setClientIpError] = useState('')
+
+ useEffect(() => {
+ let cancelled = false
+ getClientIp()
+ .then((r) => { if (!cancelled) setClientIp(r.ip || '') })
+ .catch((e) => { if (!cancelled) setClientIpError(e?.message || '获取失败') })
+ return () => { cancelled = true }
+ }, [])
+
useEffect(() => {
let cancelled = false
mcpTokenApi
@@ -468,6 +482,16 @@ export default function MePage() {
注册时间
+ copyText(clientIp, 'IP')}>{clientIp}
+ : (clientIpError ? '获取失败' : '获取中…')
+ }
+ description={clientIpError || '服务器看到的当前访问 IP'}
+ >
+ 访问 IP
+