Merge branch 'master' into android-1.0
Android Build & Publish / build-android (push) Successful in 13m3s

This commit is contained in:
jif2.zhang
2026-11-12 17:16:35 +08:00
5 changed files with 88 additions and 7 deletions
+6
View File
@@ -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)
+25
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import { api } from './client.js'
// 服务器看到的当前访问者 IP (用于「我」页展示)
export function getClientIp() {
return api('/system/client-ip')
}
+25 -1
View File
@@ -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
View File
@@ -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; }