fix(我的): 修复状态栏遮挡与退出登录被底栏遮住, 新增访问 IP 展示
Docker Build and Push / build-image (push) Successful in 3m26s
Docker Build and Push / build-image (push) Successful in 3m26s
This commit is contained in:
@@ -17,6 +17,7 @@ import projectsRouter, { columnRouter } from './routes/projects.js'
|
|||||||
import tasksRouter from './routes/tasks.js'
|
import tasksRouter from './routes/tasks.js'
|
||||||
import powersyncRouter from './routes/powersync.js'
|
import powersyncRouter from './routes/powersync.js'
|
||||||
import mcpTokensRouter from './routes/mcp-tokens.js'
|
import mcpTokensRouter from './routes/mcp-tokens.js'
|
||||||
|
import systemRouter from './routes/system.js'
|
||||||
import mcpRouter from './mcp/index.js'
|
import mcpRouter from './mcp/index.js'
|
||||||
import { powersyncProxy } from './powersync-proxy.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 HAS_DIST = fs.existsSync(DIST_DIR)
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
// 生产环境前面只有一层 NPM 反向代理: 信任 1 跳, 让 req.ip 取到真实客户端 IP
|
||||||
|
// (否则拿到的是反代/容器网段地址, 「我」页的访问 IP 会显示错误)
|
||||||
|
app.set('trust proxy', 1)
|
||||||
// 同步服务同域反代必须最先挂载 (流式长连接, 不能经过 json/cors 中间件)
|
// 同步服务同域反代必须最先挂载 (流式长连接, 不能经过 json/cors 中间件)
|
||||||
app.use(powersyncProxy())
|
app.use(powersyncProxy())
|
||||||
app.use(cors())
|
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)
|
app.use('/api/auth', authRouter)
|
||||||
|
// 系统信息 (当前访问 IP 等)
|
||||||
|
app.use('/api/system', systemRouter)
|
||||||
// OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前,
|
// OTA 接口是公开的 (检查/下载更新不需要登录), 必须挂载在带 authRequired 的 router 之前,
|
||||||
// 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截
|
// 否则 /api/ota/* 请求会先进入 dataRouter 等并被 authRequired 以 401 拦截
|
||||||
app.use('/api', otaRouter)
|
app.use('/api', otaRouter)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from './client.js'
|
||||||
|
|
||||||
|
// 服务器看到的当前访问者 IP (用于「我」页展示)
|
||||||
|
export function getClientIp() {
|
||||||
|
return api('/system/client-ip')
|
||||||
|
}
|
||||||
+25
-1
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../api/ota.js'
|
} from '../api/ota.js'
|
||||||
import { refreshIfinanceInfo, useIfinanceInfo } from '../lib/ifinance.js'
|
import { refreshIfinanceInfo, useIfinanceInfo } from '../lib/ifinance.js'
|
||||||
import { getMcpEndpoint, mcpTokenApi } from '../api/mcp.js'
|
import { getMcpEndpoint, mcpTokenApi } from '../api/mcp.js'
|
||||||
|
import { getClientIp } from '../api/system.js'
|
||||||
|
|
||||||
// ifinance 构建时间: 优先按 info.json 里的 ISO 字符串原样展示 (北京时间),
|
// ifinance 构建时间: 优先按 info.json 里的 ISO 字符串原样展示 (北京时间),
|
||||||
// 避免设备时区不同导致显示偏移。
|
// 避免设备时区不同导致显示偏移。
|
||||||
@@ -53,7 +54,8 @@ export default function MePage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
|
// 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
|
||||||
// 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
|
// 由 CSS 让 .app-safe-top 使用与头部顶边一致的水平渐变.
|
||||||
|
// 安全区仍在滚动容器之外, 页面滚动时内容不会钻进系统状态栏.
|
||||||
// 离开本页时移除标记, 其他页恢复默认底色.
|
// 离开本页时移除标记, 其他页恢复默认底色.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.add('me-safe-top')
|
document.body.classList.add('me-safe-top')
|
||||||
@@ -85,6 +87,18 @@ export default function MePage() {
|
|||||||
const [mcpCreating, setMcpCreating] = useState(false)
|
const [mcpCreating, setMcpCreating] = useState(false)
|
||||||
const [mcpError, setMcpError] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
mcpTokenApi
|
mcpTokenApi
|
||||||
@@ -468,6 +482,16 @@ export default function MePage() {
|
|||||||
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
|
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
|
||||||
注册时间
|
注册时间
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
extra={
|
||||||
|
clientIp
|
||||||
|
? <span className="me-ip" onClick={() => copyText(clientIp, 'IP')}>{clientIp}</span>
|
||||||
|
: (clientIpError ? '获取失败' : '获取中…')
|
||||||
|
}
|
||||||
|
description={clientIpError || '服务器看到的当前访问 IP'}
|
||||||
|
>
|
||||||
|
访问 IP
|
||||||
|
</List.Item>
|
||||||
<List.Item
|
<List.Item
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
clickable
|
clickable
|
||||||
|
|||||||
+26
-6
@@ -323,15 +323,24 @@ input, textarea, select { font: inherit; }
|
|||||||
|
|
||||||
/* ================== 我的页 ================== */
|
/* ================== 我的页 ================== */
|
||||||
/* 「我」页不设顶部头部条: 头像区直接贴顶 (2026-10-23 界面调整)
|
/* 「我」页不设顶部头部条: 头像区直接贴顶 (2026-10-23 界面调整)
|
||||||
该页头部是通栏蓝色渐变, 若安全区单独占位会出现一条白边/色块把蓝色割断.
|
该页头部是通栏蓝色渐变, 安全区若用页面底色会出现一条白边/色块把蓝色割断.
|
||||||
做法: 让安全区高度归零, 改由 me-profile 用 padding-top 吸收状态栏高度,
|
做法: 安全区继续占位 (高度 var(--safe-top)), 但背景改用与头部顶边一致的
|
||||||
这样蓝色渐变从屏幕最顶端开始, 是一整块连续的色域. */
|
水平渐变 —— 既保持蓝色连续, 又因为安全区在滚动容器之外, 页面滚动时
|
||||||
body.me-safe-top .app-safe-top { height: 0; }
|
文字不会钻进系统状态栏. */
|
||||||
.me-page { display: flex; flex-direction: column; min-height: 100%; background: var(--color-bg); }
|
body.me-safe-top .app-safe-top {
|
||||||
|
background: linear-gradient(90deg, #1677ff, #318cff);
|
||||||
|
}
|
||||||
|
.me-page {
|
||||||
|
display: flex; flex-direction: column; min-height: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
/* .app-main 的 padding-bottom 在内容溢出时不会计入滚动范围,
|
||||||
|
这里给页面自身补足 TabBar 高度的底部留白, 避免「退出登录」被底栏遮住 */
|
||||||
|
padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 12px);
|
||||||
|
}
|
||||||
.me-page .me-profile {
|
.me-page .me-profile {
|
||||||
background: linear-gradient(135deg, #1677ff, #4096ff);
|
background: linear-gradient(135deg, #1677ff, #4096ff);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: calc(24px + var(--safe-top)) 16px 28px;
|
padding: 24px 16px 28px;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
}
|
}
|
||||||
.me-avatar { background: rgba(255,255,255,.2); color: #fff; font-size: 28px; }
|
.me-avatar { background: rgba(255,255,255,.2); color: #fff; font-size: 28px; }
|
||||||
@@ -340,6 +349,17 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
.me-list { margin-top: 12px; }
|
.me-list { margin-top: 12px; }
|
||||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||||
.me-logout { color: var(--color-danger); }
|
.me-logout { color: var(--color-danger); }
|
||||||
|
.me-ip {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 55vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
|
color: var(--color-primary, #1677ff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
|
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
|
||||||
/* AI 接入 (MCP) */
|
/* AI 接入 (MCP) */
|
||||||
.mcp-link { color: var(--color-primary, #1677ff); cursor: pointer; font-size: 13px; }
|
.mcp-link { color: var(--color-primary, #1677ff); cursor: pointer; font-size: 13px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user