前端与客户端约定
来源:
intranet-tunnel/docs/ui-refactor.md(整篇)(原文 17732 字符)
Web 管理端 UI 现代化改造说明
本文件记录本次「UI 现代化 + 图形验证码 + 短信验证码登录」改造的范围、约定与回滚方式。
- 改造分支:
feature/ui-refactor - 基线提交:
7d18358 chore: 建立 UI 改造前的代码基线
一、改造目标与红线遵守情况
| 红线要求 | 落实情况 |
|---|---|
| 禁止修改业务 API 接口、路径、参数 | 未改动任何既有业务接口。仅 /api/auth/login 新增可选字段 captcha_id / captcha_code(不传时行为与改造前完全一致)。 |
| 禁止修改路由结构、路由守卫、鉴权逻辑 | router/index.js 的既有路由与 beforeEach 守卫逐字未动,仅新增 /account 一条路由。鉴权中间件新增的是独立的 optionalAuthMiddleware,不影响 authMiddleware。 |
| 禁止修改 store 的 state/actions/getters 语义 | web/src/store/auth.js 零改动。短信登录返回与口令登录完全相同的 tokenPair 结构,因此复用 auth.setSession()。 |
| 禁止修改业务组件的 props/emits/v-model 语义 | 12 个既有页面的 <script setup> 仅新增组件 import(个别页面额外新增了只被模板使用的状态映射函数),业务逻辑与 props/emits/v-model 语义逐字未动。 |
| 禁止删除任何现有功能入口 | 侧栏 12 个入口全部保留(仅重新分组),另新增「账号安全」。 |
| 所有改动可回滚、Git 分支隔离 | 全部改动位于 feature/ui-refactor 分支,main 保持基线状态。 |
关于技术栈的一处偏离(重要)
提示词要求 TypeScript 5.3+ 与 Pinia,但本项目实际不存在 TypeScript 与 Pinia(全部为 .js / <script setup> + reactive store)。
为什么不做全量迁移
- 强制 TS 化意味着改写全部
.vue文件,与「禁止修改业务组件语义」「可回滚」两条红线直接冲突; - 引入 Pinia 会替换现有 store,与「禁止修改 store 语义」冲突;
- 本次改造的核心价值(设计令牌、1Panel 风格、暗色主题、验证码与短信登录)不依赖这两者。
不做全量迁移 ≠ 完全不用
这两件事需要分开看,否则会漏掉零成本的收益:
- 新增文件可以用 TS,无需迁移旧文件。Vite 原生支持
.ts与.vue混编,新写的 composable、API 封装、类型定义完全可以用 TS,旧.vue保持 JS。本次受时间约束未采用,但后续新增代码应默认用 TS。 - 「禁止修改 store 语义」≠「禁止新增 store」。跨组件状态若无规划会散落各处。本项目现有做法是:
composables/下的模块级单例(见useTheme.js)配合组件内ref。这是刻意选择的替代路径——它与旧 store 完全隔离,不触碰store/auth.js的任何语义,同时满足「状态只有一份」的要求。
如果将来要做迁移,建议路径
- 先加类型定义,不改实现:为 API 响应与领域模型新增
web/src/types/*.ts,.vue文件通过 JSDoc@type引用,可在零改写的前提下获得大部分类型检查收益。 - 再逐个文件改
<script setup lang="ts">:从叶子组件开始(components/common/优先),每次一个文件、单独提交,保证可回滚。 - Pinia 只在确有跨页面共享需求时引入:当前
auth的状态集合很小且语义清晰,迁移到 Pinia 属于形式收益。若有新的跨页面状态,建议为它单独建 store,而不是把auth一并搬过去。 - 全程保持
vue-tsc --noEmit作为独立 CI 步骤,不与构建耦合。
devDependencies 中的 typescript / vue-tsc 已就位,可作为上述迁移的起点。
二、设计令牌体系
新增目录 web/src/styles/,导入顺序不可调换:
| 文件 | 职责 |
|---|---|
tokens.scss | 定义全部 --app-* 令牌(亮色 + html.dark 暗色) |
reset.scss | 必要的基础归一化、滚动条、工具类、prefers-reduced-motion |
ep-bridge.scss | 把 --app-* 单向映射到 Element Plus 的 --el-* |
ep-overrides.scss | 变量覆盖不到的结构细节(内边距、分隔线、字号) |
transitions.scss | 过渡类与关键帧(状态点脉冲、骨架屏、路由切换) |
legacy.scss | 旧页面兼容层(见下) |
index.scss | 入口,按上述顺序 @use |
命名空间隔离
所有令牌使用 --app- 前缀,与 Element Plus 的 --el- 完全隔离,避免升级 EP 时冲突。
单向桥接与 html.dark 重复展开
ep-bridge.scss 用 @mixin el-token-bridge 组织映射,并在 :root 与 html.dark 下各展开一次。
重复展开是必需的:Element Plus 官方暗色包(theme-chalk/dark/css-vars.css)会在 html.dark 下重新定义 --el-*,其特异性(0,1,1)高于 :root(0,1,0)。只在 :root 映射会导致暗色下主色被官方值覆盖。展开两处后特异性相同,再由导入顺序(本文件在官方 CSS 之后)稳定胜出。
旧页面兼容层
legacy.scss 保留了改造前 styles.css 的全部全局类名(.page / .card / .stat-card / .chart / .mono …),但把硬编码颜色替换为令牌引用。
效果:12 个既有页面零改动即获得暗色主题支持与统一视觉密度,同时满足「可回滚、不破坏现有页面」的约束。随着页面逐个迁移到新组件,该文件可整段删除。
三、新增通用组件
位于 web/src/components/common/:
| 组件 | 说明 |
|---|---|
PageContainer.vue | 页面版心(1440px 居中)、标题/描述/操作区、工具条插槽 |
DataCard.vue | 卡片容器:标题 / 描述 / 右上角操作 / 底部说明 |
ProTable.vue | 表格外壳:加载态、空状态、分页器、事件透传 |
EmptyState.vue | 轻量空状态 |
StatusDot.vue | 状态点(online 绿 + 2s 脉冲至 2.4 倍,offline 灰,error 红,warning 橙) |
ThemeToggle.vue | 主题切换按钮 |
CaptchaImage.vue | 图形验证码图片(点击刷新、主题切换自动重取、暴露 refresh()) |
LoginMethodSwitcher.vue | 登录方式分段控件 |
ProTable 刻意不接管列定义:列仍由页面用 <el-table-column> 写在默认插槽里。原因是既有页面已有大量含自定义单元格与格式化函数的列定义,抽象成配置数组会显著降低可读性,也不利于回滚。
主题管理
web/src/composables/useTheme.js 基于 @vueuse/core 的 useDark,采用模块级单例,避免同页面出现多份互不同步的暗色状态。存储键 app-theme,未设置过时跟随 prefers-color-scheme。
四、登录增强 API 契约
1. GET /api/auth/config(新增,无需令牌)
登录页据此渲染登录方式,前端不硬编码假设。
{
"password_login": true,
"sms_login": true,
"captcha_enabled": true,
"captcha_sms_required": true,
"sms_provider": "aliyun",
"sms_mock": false,
"bound_phone_count": 1,
"sms_code_length": 6,
"sms_interval": 60
}
sms_login仅在「服务端启用短信」且「已存在绑定手机号的账号」时为true。 这避免了用户填完手机号才发现无法登录。
2. GET /api/auth/captcha?theme=light|dark(新增,无需令牌)
{
"captcha_id": "a1b2...c3.d4e5...f6",
"image_base64": "iVBORw0KGgo...",
"image_type": "image/png",
"expires_in": 300,
"code": "RW52"
}code仅在模拟模式下返回,用于联调与端到端测试;真实服务商配置下该字段被省略。- 主题参数影响图片配色(亮底深字 / 深底浅字)。
3. POST /api/auth/login(既有接口扩展)
{ "username": "admin", "password": "...", "captcha_id": "...", "captcha_code": "RW52" }captcha_id / captcha_code 为可选字段:
- 服务端未启用图形验证码时,不传即可,行为与改造前一致;
- 服务端启用后不传,返回
400 {"error":"请输入图形验证码"}。
服务端启用了图形验证码时,校验发生在 bcrypt 比对之前,因此图形验证码能有效拦截机器批量尝试,而不会让 bcrypt 成为 CPU 放大点。
4. POST /api/auth/sms/send(新增,无需令牌)
{ "phone": "13800138000", "purpose": "login", "captcha_id": "...", "captcha_code": "RW52" }purpose支持login/bind;bind需要携带有效访问令牌。- 该路由挂载了
optionalAuthMiddleware(有令牌则注入声明,无令牌放行)。
成功:
{ "success": true, "message": "验证码已发送,请注意查收", "phone": "138****8000", "expires_in": 300, "interval": 60 }限流(HTTP 429,带 Retry-After 头):
{ "success": false, "error": "验证码发送过于频繁", "retry_after": 59 }5. POST /api/auth/sms/login(新增,无需令牌)
{ "phone": "13800138000", "code": "508295" }成功时返回与口令登录完全一致的 tokenPair:
{ "token": "...", "refresh_token": "...", "expires_at": "...", "expires_in": 3600, "user": { ... } }因此前端 store 无需为短信登录维护第二套会话写入逻辑。
6. 手机号绑定(需令牌)
| 方法 | 路径 | 说明 |
|---|---|---|
POST | /api/auth/phone | { phone, code } 校验验证码后绑定 |
DELETE | /api/auth/phone | 解除绑定 |
7. 短信服务观测(需令牌)
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /api/sms/stats | 统计概览;模拟模式下附 mock_records(含明文验证码) |
GET | /api/sms/logs | 发送日志分页(手机号已脱敏) |
POST | /api/sms/test | { phone } 通道测试发送,用于设置页验证配置 |
/api/sms/test 的三点设计约定:
- 使用独立的
purpose=test,使测试短信在发送日志中可区分——测试短信真实计费,必须可对账; - 不校验图形验证码:调用方已持有有效管理员令牌,再要求验证码只会阻碍排障;
- 仍完整受手机号与 IP 维度的频控保护,因此不会被拿来刷短信。
五、安全设计要点
- 验证码不可逆存储:短信验证码以 bcrypt 摘要入库,数据库泄露无法还原明文。
- 一次性消费:无论校验成功或失败,验证码条目立即失效,杜绝重放。
- 失败计数:同一验证码连续失败达
SMS_MAX_VERIFY_ATTEMPTS次即作废,把在线暴力枚举窗口压缩到个位数。 - 图形验证码一次性:校验即删除,且条目 ID 带 HMAC 签名,无法伪造或遍历。
- 限流口径统一为手机号维度:最小发送间隔不区分用途,否则攻击者可轮换
purpose绕过限制实施短信轰炸。 - 失败也计入配额:发送日志在成功与失败时都记录,统计口径包含失败——否则可用不可达号码无限刷接口。
- 凭据只从
.env读取:短信服务商的 AccessKey / Secret 不在设置页白名单内,后台被攻破也不会连带泄露云账号密钥。 - 模拟模式显式告警:
mock模式启动时打印 WARNING,且验证码明文仅在模拟模式下随响应返回。
六、数据库变更
新增两张表(AutoMigrate 自动创建,四库通用):
| 表 | 用途 |
|---|---|
sms_codes | 验证码记录(code_hash 为 bcrypt 摘要、used、attempts、expires_at) |
sms_send_logs | 发送日志(成功与失败都记录,供限流统计与审计) |
users 表新增列:phone(唯一索引,可空)、phone_verified、phone_bound_at、last_login_ip。
phone使用指针类型(*string):唯一索引在四种数据库下都允许多个 NULL,而空字符串在 MySQL 下会互相冲突。
七、配置项
图形验证码
| 变量 | 默认 | 说明 |
|---|---|---|
AUTH_CAPTCHA_ENABLED | true | 登录是否要求图形验证码 |
AUTH_CAPTCHA_SMS_REQUIRED | true | 发送短信前是否强制图形验证码 |
AUTH_CAPTCHA_LENGTH | 4 | 字符数(3-8) |
AUTH_CAPTCHA_TTL | 5m | 有效期 |
AUTH_CAPTCHA_WIDTH / AUTH_CAPTCHA_HEIGHT | 120 / 40 | 图片尺寸 |
短信服务
| 变量 | 默认 | 说明 |
|---|---|---|
SMS_ENABLED | false | 短信服务总开关 |
SMS_PROVIDER | mock | aliyun / tencent / huawei / webhook / mock |
SMS_MOCK_MODE | false | 强制使用模拟服务商 |
SMS_SIGN_NAME | — | 短信签名 |
SMS_TEMPLATE_CODE | — | 模板编号 |
SMS_CODE_LENGTH | 6 | 验证码位数(4-8) |
SMS_CODE_TTL | 5m | 有效期(1 分钟 - 1 小时) |
SMS_SEND_INTERVAL | 60s | 同手机号最小发送间隔(≥ 30s) |
SMS_MAX_PER_HOUR | 5 | 单手机号每小时上限 |
SMS_MAX_PER_DAY | 10 | 单手机号每日上限 |
SMS_MAX_PER_IP_HOUR | 20 | 单 IP 每小时上限 |
SMS_MAX_VERIFY_ATTEMPTS | 5 | 单个验证码连续失败上限(3-20) |
各服务商凭据:ALIYUN_SMS_*、TENCENT_SMS_*、HUAWEI_SMS_*(含 HUAWEI_SMS_SENDER 通道号)、SMS_WEBHOOK_*。
可在设置页热更新
sms.enabled、sms.provider、sms.sign_name、sms.template_code、sms.code_length、sms.code_ttl、sms.send_interval、sms.max_per_hour、sms.max_per_day、sms.max_per_ip_hour、auth.captcha_enabled、auth.captcha_sms_required、auth.captcha_length。
热更新通过 sms.Manager.Reload() 整体原子替换运行时快照实现(配置 + 供应商实例一起换,避免出现「新配置配旧供应商」的中间态),同时保留图形验证码存储与内存限流计数。
八、短信服务商对接
server/internal/sms/ 下的 Provider 接口屏蔽了各厂商差异:
| 实现 | 签名方式 |
|---|---|
aliyun.go | RPC 风格 HMAC-SHA1(StringToSign = GET&%2F&percentEncode(query)) |
tencent.go | TC3-HMAC-SHA256(规范请求串 → 待签串 → 逐级派生密钥) |
huawei.go | WSSE UsernameToken(PasswordDigest = Base64(SHA256(Nonce + Created + AppSecret))) |
webhook.go | 自定义 HTTP 端点,支持 URL / 方法 / 请求头 / 请求体模板(占位符 {{phone}} {{code}} 等) |
mock.go | 不实际发送,验证码写入日志与内存缓冲(上限 200 条) |
模板变量约定:
- 阿里云:
{"code":"1234"}(变量名固定为code) - 腾讯云:
["1234"](TemplateParamSet) - 华为云:
["1234"](templateParas,JSON 数组字符串)
九、回滚方式
# 停止服务
git checkout main # 回到基线(前端与后端全部回到改造前状态)
cd web && npm run build # 重建前端产物
cd ../server && go build -o ../bin/tunnel-server.exe ./cmd/server
# 如需保留短信功能而只回滚 UI:
git checkout feature/ui-refactor
git revert <UI 相关提交>数据库层面:新增表与新增列都是向后兼容的增补。回滚到旧版本时旧代码不会读取这些列/表,无需数据迁移。若确需清理,可手动 DROP TABLE sms_codes, sms_send_logs。
测试期数据清理(本地):删除 server/data/tunnel.db 后重启即可。
十、验证记录
后端端到端(16 步全通过)
在本地以 SMS_MOCK_MODE=true + AUTH_CAPTCHA_ENABLED=true 启动服务端,脚本化验证:
| 步骤 | 预期 | 实测 |
|---|---|---|
1. /api/auth/config 初始状态 | sms_login=false、bound_phone_count=0 | 一致 |
| 2. 不带验证码登录 | 400 | {"error":"请输入图形验证码"} |
| 3. 错误验证码登录 | 400 | {"error":"图形验证码错误或已失效,请重新获取"} |
| 4. 正确验证码登录 | 200 + token | token 长度 299,expires_in=3600 |
| 5. 发送绑定短信 | 200 | phone=138****8000、expires_in=300 |
| 6. 读取模拟验证码 | 拿到明文 | 508295 |
| 7. 绑定手机号 | 200 | 手机号绑定成功 |
8. /api/auth/me | 反映绑定 | phone=13800138000、phone_verified=true |
| 9. 复用已消费的验证码 | 400 | {"error":"请先获取短信验证码"} |
| 10. 绑定后能力声明 | sms_login=true | bound_phone_count=1 |
| 11. 短信验证码登录 | 200 + token | sms_login_user=admin |
| 12. 用短信登录令牌访问受保护接口 | 200 | role=admin |
| 13. 重放已消费的短信验证码 | 401 | {"error":"请先获取短信验证码"} |
| 14. 间隔内重发 | 429 | retry_after=59 |
| 15. 缺少图形验证码发送短信 | 400 | {"error":"请输入图形验证码"} |
| 16. 非法手机号 | 400 | {"error":"手机号格式不正确"} |
单元测试
server/internal/captcha/captcha_test.go:12 个用例全部通过,覆盖生成/校验/一次性消费/过期/畸形 ID/nil 安全/双主题渲染/参数收敛/字符集排除易混淆字符/随机性与 ID 唯一性。
前端产物
npm run build成功,无告警(已启用 Sassmodern-compilerAPI)。- 静态资源与 history 深链接(
/login、/account、/dashboard、/settings、/tunnels)均返回 200 且为text/html。 - CSS 产物(393 KB)已确认包含
--app-*令牌、暗色覆盖、状态点脉冲关键帧、路由过渡类与 EP 桥接映射。
服务端
go build ./...、go vet ./...、gofmt全部干净。- 启动日志确认新模块装配:
短信验证码登录已启用 provider=mock mock=true captcha=true。
十一、逐页迁移完成情况(12/12)
改造采用「先铺基础设施、再逐页迁移」的两阶段策略:第一阶段通过 legacy.scss 兼容层保证旧页面零改动即可获得暗色支持;第二阶段逐页迁移到新组件。
已完成迁移
| 页面 | 主要改动 |
|---|---|
DashboardView | 统计卡组收进 DataCard;内部类名 .stat-* 重命名为 .metric-*,避免与兼容层同名造成歧义 |
ClientsView | ProTable + StatusDot(在线/离线) |
TunnelsView | ProTable;客户端列的在线/离线改用 StatusDot |
StatsView | 4 张统计卡改 DataCard;图表与明细表各自入 DataCard |
DdnsView | 统计卡改 DataCard + StatusDot(同步结果) |
ProxiesView | ProTable + StatusDot(启用/停用) |
CertsView | ProTable + StatusDot(有效/即将过期/已过期) |
ForwardsView | ProTable + StatusDot(启用/停用) |
LogsView | 三套日志视图分别入 DataCard;筛选条移入 #toolbar;SSE 区块完整保留 |
SettingsView | 5 张卡片改 DataCard;新增「图形验证码与短信登录」分组;修复底部操作栏定位缺陷 |
BackupView | 统计卡与各功能块改 DataCard;历史表改 ProTable |
AccountView | 本次新增页面,直接使用新组件 |
验收标准(全部页面通过)
- 行内
style="出现次数为 0; - 无硬编码颜色(十六进制 / rgb / hsl / 颜色关键字);
- 不引用兼容层旧类名(
.page/.card/.stat-*/.mono/.muted); - 使用
@vue/compiler-sfc对 parse + compileScript + compileTemplate + compileStyle(scss) 全量离线编译通过; npm run build成功且无告警;服务端嵌入新产物后,13 条路由深链接全部返回 200。
功能等价性
各页面改造严格限定在模板结构与样式层面:<script setup> 仅新增组件 import (CertsView / DdnsView 额外新增了一个只被模板使用的状态映射函数), 业务函数、API 调用、事件绑定、v-model、提示文案逐字保留。
零功能增补(复核后修正)
迁移过程中曾出现过两处超出「功能等价」的改动,经复核后已全部回退:
- TunnelsView 新增「状态」列(已删除)。原因:
model.Tunnel.Status虽然带json:"status"tag 会随响应返回,但后端只在enabled变化时同步写它,而常量TunnelStatusError在全仓库只有定义、从无赋值——也就是说该字段目前只是enabled的镜像,error属预留能力。新增该列属于重复表达,且error永远不会出现。 - ForwardsView / ProxiesView 在「启用」列内 switch + StatusDot 并列(已回退为纯 switch)。原因:两者读取同一个
row.enabled,属同一信息的两种呈现。
结论:StatusDot 只用于表达独立的运行状态——客户端在线/离线、证书有效期、DDNS 同步结果、日志级别、短信通道状态;而「启用/停用」这类配置开关由 el-switch 表达,不再叠加状态点。
兼容层的去留
styles/legacy.scss 目前仍然保留:虽然 12 个页面都已不再引用其中的类名, 但删除它属于纯清理、与功能无关。建议在下一版本确认线上无回归后再删除, 届时一并移除 styles/index.scss 中对应的 @use。
十二、视觉走查(无头浏览器 + 可编程审计)
方法与工具
本机没有可用的图像识别能力,无法"看"截图。因此把走查拆成两部分:
- 截图留档:用 Chrome 无头模式 + CDP 驱动真实浏览器渲染(Node 24 自带 WebSocket,零额外依赖,未安装 Playwright),产出 6 组基线图(亮/暗 × 1440/768/375)共 78 张。每张截图都附带自验证结果(实测
html.dark类与location.pathname),确保"截到的是目标页面且主题已生效"。 - 可编程审计:把视觉走查中可客观度量的部分自动化——横向溢出、元素越界、文字对比度(按 WCAG AA)、sticky 祖先 overflow 链、布局关键尺寸、触控目标尺寸。这几项恰好是暗色模式与响应式最常见的真实缺陷来源。
工具脚本为 .verify/shoot.mjs(截图)与 .verify/audit.mjs(审计),位于 .gitignore 内,属本地验证工具。
发现并修复的问题(均为代码审查看不到的)
| # | 问题 | 实测证据 | 修复 |
|---|---|---|---|
| 1 | 侧栏分组标题对比度不足 | #a9aeb8 on #fff = 2.23:1(AA 要求 4.5) | 改用 --app-text-secondary → 5.38:1 |
| 2 | 各页说明文字对比度不足 | .metric-extra / .stat-extra / 空状态文案同为 2.23:1 | 同上 |
| 3 | el-alert 文字对比度不足,且首次修复无效 | info alert 2.92:1;EP 的 description 规则是 .el-alert--info.is-light .el-alert__description(特异性 0,3,0),我最初的 (0,2,0) 被压过 | 改为 .el-alert.is-light .el-alert__description(同特异性 + 导入顺序胜出) |
| 4 | 暗色下主色按钮文字对比度不足 | 白字 on #3c7eff = 3.5:1 | 暗色主色调为 #4d8bff,按钮文字改用 --app-text-inverse → 约 5.2:1 |
| 5 | 暗色下侧栏激活项对比度不足 | #3c7eff on #1c2c48 = 3.9:1 | --app-primary-light 调深为 #16233a → 约 5.0:1 |
| 6 | 第 4 项的修复误伤 plain 类型 | 「刷新」「发送测试短信」跌到 1.05:1(深字压深底) | 选择器加 :not(.is-plain):not(.is-text):not(.is-link) |
| 7 | 分页器在窄屏横向溢出 | 375px 下溢出到 797px | ProTable 用 useMediaQuery 动态收窄 layout 与 pager-count |
| 8 | 设置页表单在窄屏横向溢出 | 375px 下说明文字溢出到 444px | 窄屏下 el-form-item 改标签顶对齐、固定宽度控件占满整行 |
| 9 | sticky 祖先 overflow 的判定误报 | .content(滚动容器)被误判为阻塞 | 审计脚本区分「可滚动祖先」与「裁剪祖先」 |
第 6 项值得特别注意:修复本身引入了新缺陷,若没有修复后的复审就会漏掉。
修复后验证结果:
| 断点 | 亮色 | 暗色 |
|---|---|---|
| 1440 | 结构性缺陷 0;低对比 8 页(均为 EP 固有,见下) | 结构性缺陷 0;低对比 2 页 |
| 768 | 结构性缺陷 0(无任何溢出) | — |
| 375 | 结构性缺陷 0(无任何溢出) | — |
图像能力复核补充(第二轮)
启用视觉识别能力后,对截图做了真正的"看图"复核,又发现三处问题:
| # | 问题 | 证据 | 处理 |
|---|---|---|---|
| 10 | 图形验证码完全不显示 | CaptchaImage.vue 写成 const { data } = await api.captcha(...),但 axios 响应拦截器已 return response.data,再次解构 .data 得到 undefined,data.captcha_id 抛错进 catch 并清空图片 | 已修复为直接取值。复核确认:图片 naturalWidth×Height = 120×40、imgComplete=true、占位图标消失、控制台与网络均无错误;截图可见验证码字符与 API 返回的 code 一致 |
| 11 | 未选中的复选框几乎不可见 | EP 默认边框经映射后为 #e5e6eb on #fff = 1.2:1(暗色 #333335 on #232324 = 1.15:1) | el-checkbox__inner / el-radio__inner 边框改用 --app-text-placeholder(亮 2.2:1 / 暗 3.0:1),截图复核确认方框清晰可见 |
| 12 | 窄屏空状态被挤压 | 375px 下多列表格横向滚动,空状态文案换行 4 次 | 尝试修复后撤销,详见下 |
第 10 项暴露了此前的验证盲区:接口级端到端测试全部通过,却覆盖不到"前端组件是否正确消费了响应"。这正是必须做真实浏览器复核的理由。
第 12 项是一次修复引入回归的实例:最初的修法是给 .el-table__empty-block 加 position: sticky; left: 0; min-width: 100%,截图复核发现空状态文案完全消失——min-width 的 100% 以表格内容宽度(而非滚动容器宽度)为基准,居中文案被推到可视区之外。已撤销并精确回滚(回滚后截图 SHA 与修复前一致)。
图像能力逐页复核(第三轮)
启用视觉识别后,对 6 组基线图做了逐页复核。为控制复核成本,用 System.Drawing 把多页拼成一张联系表(contact sheet,每格带页面名标签)后再看,一次可覆盖 6 页。
| 覆盖范围 | 页数 | 说明 |
|---|---|---|
| 暗色 1440 | 13 / 13 | 全覆盖 |
| 亮色 1440 | 6 | settings / backup / logs / proxies / stats / account(最复杂的表单与列表页) |
| 亮色 768 | 6 | 平板断点 |
| 亮色 375 | 6 | 小屏 |
| 暗色 375 | 6 | 小屏暗色 |
复核确认已修复项在真实渲染中确实生效:
- 图形验证码正常显示——截图中读到的字符为亮色
N6GF、77YP,暗色28CU、78BX,均与当次 API 返回的code一致; - 未选中复选框边框在亮色、暗色、桌面、小屏下均清晰可辨;
el-alert文案在两种主题下都可读(certs 页「证书自动化未启用」、backup 页「本地存储不加密」、settings 页默认口令警告、proxies 页上游安全策略);- 窄屏(375)下表单标签顶对齐、sticky 操作栏吸附在底部、无任何横向溢出;
- 768 平板断点同样为标签顶对齐布局;
- account 页的数据渲染(已绑定手机号、通道统计、发送记录与分页器)与 logs 页的级别 StatusDot 均正常。
未发现新的结构性问题。
未覆盖部分:亮色 1440 的 6 个页面(dashboard / clients / tunnels / ddns / certs / forwards)——它们的暗色版本已复核且结构一致,亮色下也已通过对比度审计;如需彻底覆盖可再跑一轮。
判断 4 的结论:sticky 操作栏有效
逐层实测输出:
sticky: position=sticky bottom=900 可见=true 裁剪祖先=[无] 滚动祖先=[content:hidden auto]- 元素
position: sticky生效,bottom值恒等于视口高度 → 确实吸附在视口底部; - 祖先链中没有任何「裁剪且不可滚动」的元素(
裁剪祖先=[无]); - 唯一的 overflow 祖先是
.content,它正是布局层的滚动容器(scrollHeight > clientHeight),sticky 本就相对它定位——这是正常且必需的,不是失效场景。
三个断点(1440 / 1024 / 812)均验证可见。移动端抽屉 z-index 为 2000、遮罩 1500,操作栏为 1001,抽屉打开时遮罩会正常覆盖它,不存在穿透。
已知项(有意不修复,属 Element Plus 固有设计)
以下低对比项来自 Element Plus 组件的既定设计(与本项目的令牌映射无关)。本次有意不覆盖——过度定制第三方组件会显著增加后续升级成本:
| 场景 | 对比度 | 说明 |
|---|---|---|
el-tag 的 effect="light"(成功/警告/模拟…) | 2.4–2.6:1 | 语义标签有背景色块辅助识别,且多为短文本 |
| 表单控件的 placeholder(「请选择」) | 2.2–3.1:1 | placeholder 语义上应弱化;EP 默认即该量级 |
el-tabs 激活项 | 4.4:1 | 距 AA 差 0.1,实际观感无问题 |
link 类型按钮的语义色(危险操作) | 2.2–3.3:1 | EP 用语义色作为链接文字 |
| 窄屏(≤375px)下的表格空状态 | — | 多列表格横向滚动导致空状态宽度受限、文案换行较多。纯 CSS 无法表达"滚动容器的可视宽度",sticky 方案已验证无效(见第 12 项)。有数据时不会出现,暂接受现状 |
如需进一步提升,建议新增 --app-*-text 系列的深色变体并映射到 EP,而不是直接改语义色令牌。
仍需人工复核的部分
截图已产出,但主观观感——配色是否协调、间距是否舒适、动效是否顺滑、图表可读性、长域名与长日志行的视觉表现——无法由脚本判定,仍需人眼过一遍:
.verify/shots/
├── light-1440/ dark-1440/ # 桌面
├── light-768/ dark-768/ # 平板
└── light-375/ dark-375/ # 手机每个目录含 13 张页面截图(login.png、dashboard.png、clients.png … settings.png、account.png)。
十三、仍未完成
- TypeScript 与 Pinia 迁移:见第一节的「如果将来要做迁移,建议路径」。
来源:
intranet-tunnel/mobile/README.md(整篇)(原文 9376 字符)
内网穿透控制台 —— 移动端 App
uni-app(Vue 3)实现的移动端管理工具,是 Web 管理端的轻量补充: 聚焦「查看 + 轻操作 + 告警」,复杂配置仍在 Web 端完成。
一、当前进度
| 阶段 | 内容 | 状态 |
|---|---|---|
| 一 | 现状识别与技术选型 | ✅ 完成(docs/mobile-phase1-discovery.md) |
| 二 | 项目初始化、设计 Token 同步、登录与鉴权、API 封装、安全存储 | ✅ 完成 |
| 三 | 底部 Tab、仪表盘、客户端/隧道列表与详情 | ✅ 完成 |
| 四 | 告警中心(列表/详情/已读/已处置/未读角标) | ✅ 完成 |
| 五 | 轻操作(隧道启停、强制下线、证书续期、DDNS 同步) | ✅ 完成 |
| 六 | 扩展(扫码、日志、备份、多环境切换、生物识别) | ⏳ 待开始 |
| 七 | 打磨与发布 | ⏳ 待开始 |
阶段二已交付:启动分发、服务器地址配置(多环境)、登录页(口令 / 短信 / 邮箱 + 图形验证码)、 令牌安全存储与自动刷新、统一请求层与错误映射、Mock 层、亮暗双主题。
阶段三已交付:
- 底部四 Tab(首页 / 客户端 / 隧道 / 我的),图标由脚本生成并按主题切换两套配色
- 仪表盘:四张摘要卡 + 流量趋势(1h/6h/24h/7d,24 根柱子)+ 最近告警 + 快捷入口
- 客户端列表(搜索防抖、状态筛选、分页加载)与详情(基本信息 + 名下隧道)
- 隧道列表(类型/状态筛选、搜索)与详情(实时流量 + 趋势图 + 配置摘要 + 一键复制入口)
- 「我的」:账号、多环境、主题三选一、通知偏好(如实标注"仅本地")、关于、退出登录
- 离线缓存:断网时列表与详情显示上次数据并标注时间(需求 5.4)
阶段三附带修复(契约 v1.0.6 的阻塞性变更): 服务端把口令登录改成两阶段(账号开启两步验证时返回质询而非令牌)。 App 若不识别 mfa_required,会把质询当会话存起来 —— 表现为「登录看着成功、进去却是未登录」且全程无报错。 现已适配:登录页增加第二步(6 位验证码 + 120 秒倒计时),判定逻辑抽成 utils/auth-flow.js 并由 19 项单元断言守住(npm run selftest 的 auth-flow 一节)。
阶段四已交付:
- 告警中心:列表(状态/级别/类型三级筛选)、详情、标记已读、标记已处置、未读角标
- 进入详情自动标记已读;已处置的告警不显示"标记已读"(服务端不会把状态回落)
- 告警消息一键复制、关联对象(客户端/隧道)跳转
- 真实接口对接验证(
npm run verify-live,68 项断言):
打真实 HTTP,并直接 import App 的解析函数去处理真实响应 —— 这是唯一能发现"契约文档与实现不一致"的检查(见下)
阶段五已交付(轻操作):
- 隧道:启停、编辑名称/备注、删除(要求输入隧道名称确认)
- 客户端:强制下线(含"不在线返回 409"的友好处理)
- 证书:列表(数据库 + 存储目录两份数据源)、详情抽屉、手动续期(长超时 + 防重复提交)
- DDNS:记录与状态、手动同步(原样透出 DNS 服务商的错误文案)
- 端口转发:列表(实时连接数)、启停(必须提交完整规则,见
api/forwards.js) - 首页快捷入口扩为 8 个(两行),覆盖全部已实现模块
⚠️ 本期不做(服务端契约明确不做,见 docs/mobile-api-contract.md §6): 推送通道与设备注册接口、异地登录告警、客户端 CPU/内存监控、流量配额告警、 RBAC 资源归属、多设备会话管理。App 侧已按此收敛,未写任何"调不通的代码"。 通知偏好的开关因此只存本地,界面上明确标注,不做"看起来能同步"的假象。
二、目录结构
mobile/
├── scripts/
│ ├── sync-tokens.mjs 设计 Token 同步(生成 CSS / SCSS / JS 三份)
│ ├── gen-tabbar-icons.mjs tabBar 图标生成(16 个 PNG,亮暗两套)
│ ├── check-sfc.mjs 模板一致性检查(抓"漏 import"这类构建不报的错)
│ ├── selftest.mjs 纯逻辑与资源自测(219 项断言,Node 直接跑)
│ ├── verify-live-api.mjs **真实接口对接验证**(打真实 HTTP,需先起本地实例)
│ ├── start-e2e-server.ps1 启动隔离的本地服务端(供上面那个脚本用)
│ └── _node-import-hook.mjs 让 Node 能 import 带 @/ 别名的源码模块
├── src/
│ ├── api/ 请求层与接口封装(页面禁止直接调 uni.request)
│ │ ├── error.js ApiError(单独成文件以避免循环引用)
│ │ ├── request.js 统一请求、鉴权、刷新、错误映射、Mock 分流
│ │ ├── auth.js 登录 / 验证码 / 2FA / 刷新 / 探活
│ │ ├── dashboard.js 仪表盘摘要(含字段归一化,兼容契约漂移)
│ │ ├── clients.js 客户端列表 / 详情 / 名下隧道
│ │ ├── tunnels.js 隧道列表 / 详情
│ │ ├── stats.js 流量时间序列与抽稀
│ │ ├── alerts.js 告警列表 / 详情 / 已读 / 处置(返回归一化对象)
│ │ ├── certs.js 证书列表(含存储目录)/ 续期 / 到期判定
│ │ ├── ddns.js DDNS 记录 / 状态 / 手动同步
│ │ └── forwards.js 端口转发列表 / 统计 / **安全的启停封装**
│ ├── components/ CaptchaImage / ConfirmDialog / EmptyState / SkeletonList / StatusDot /
│ │ TrafficBars / TunnelActions
│ ├── composables/ useTheme(页面根节点必须绑 themeClass)、usePagedList
│ ├── config/ 运行期配置、告警元数据(alert-meta)、路由映射(route-map)
│ ├── mock/ Mock 数据(与**实现**同构,不是与文档同构)
│ ├── pages/ launch / login / server / index / clients / tunnels / alerts /
│ │ certs / ddns / forwards / mine
│ ├── store/ Pinia:app(主题、服务器、角标)、auth(会话)
│ ├── styles/ tokens.scss(生成)、tokens.js(生成)、common.scss
│ ├── utils/ storage / secure-storage / crypto / theme / url / format /
│ │ toast / badge / offline-cache / notify-prefs /
│ │ auth-flow(登录响应判定)/ alert-flow(告警解析)
│ ├── static/tabbar/ 16 个 tabBar 图标(生成,勿手改)
│ ├── uni.scss SCSS 变量映射(生成,勿手改)
│ ├── App.vue / main.js / pages.json / manifest.json
├── index.html
├── vite.config.js
└── package.json三、快速开始
cd mobile
npm install
# H5 调试(默认代理到 http://127.0.0.1:47801,见 vite.config.js)
npm run dev:h5
# 连云端服务端
# 启动后在 App 内「服务器地址」页填 https://tunnel.sushike.cloud
# 构建
npm run build:h5
npm run build:app # 需 HBuilderX 或 uni-app CLI + DCloud appid
npm run build:mp-weixin自测与校验
npm run check # 一次跑完下面三项(提交前必跑)
npm run check-tokens # 设计 Token 是否与 Web 端一致
npm run check-sfc # 模板有没有引用未 import 的标识符
npm run selftest # 219 项纯逻辑与资源断言
npm run gen-tabbar # 重新生成 tabBar 图标(改了 Token 颜色后执行)
# 真实接口对接验证:需要先起一个本地服务端实例
# (用法见 docs/mobile-test-checklist.md 的「十五、真实接口对接验证」)
npm run verify-live三个静态检查各自防的是不同的坑:
| 检查 | 防的是什么 | 不跑会怎样 |
|---|---|---|
check-tokens | 有人改了 Web 端 tokens.scss 却没同步移动端 | 两边品牌色慢慢分叉,只能靠肉眼发现 |
check-sfc | 模板里写了 {{ formatBytes(x) }} 但忘了 import | 构建照样成功,打开该页面才白屏 |
selftest | 纯逻辑回归(编解码、格式化、地址校验、路由映射、2FA 判定、告警解析、图标形状) | 改动悄悄破坏既有行为而无人察觉 |
verify-live | 契约文档与实现不一致 | 样本本身是错的 → 自测全绿、真实对接取到 undefined |
verify-live的存在理由值得单独说明:前三项的断言样本都是"我按契约文档写的"。 本项目已经实测到两处文档与实现不符(告警未读数的嵌套结构、仪表盘的三个字段名), 都是"不报错、只让功能静默失效"的类型 —— 只有打真实 HTTP 才能发现。
check-sfc的存在理由值得单独说明:Vue 的模板编译不检查<script setup>里的标识符。 本机也没有可用的浏览器自动化(模型不支持读图、无 puppeteer/modlens), 因此"页面能不能跑起来"只能靠这个静态检查 + 人工按测试清单走一遍来补。
四、设计 Token 同步
移动端的颜色、间距、圆角、字号不在本项目手工维护,而是由脚本从 web/src/styles/tokens.scss 生成三份文件:
| 生成物 | 内容 | 为什么这样拆 |
|---|---|---|
src/styles/tokens.scss | CSS 变量定义(亮色 109 项 / 暗色 46 项) | 在 App.vue 里 import 一次 |
src/uni.scss | SCSS 变量映射(91 个 $app-*) | uni-app 会注入每个组件,只能放变量声明 |
src/styles/tokens.js | 14 个常用颜色的 JS 取值 × 2 主题 | 原生控件吃不到 CSS 变量(tabBar 配色与图标、将来的 canvas 填充色) |
组件样式里不要 import tokens.js 取色 —— 那种场景直接用 CSS 变量 (var(--app-primary)),否则主题切换会失效。tokens.js 只服务"必须用 JS 赋值"的场合。
⚠️ 不要把 CSS 规则写进 src/uni.scss:它会被注入到每个组件, 规则会随之重复输出,产物体积随组件数线性膨胀。
跨端主题方案
- 亮色:
page, .theme-light { ... } - 暗色:
html.dark, .theme-dark { ... }
每个页面的根节点都必须绑 themeClass(来自 composables/useTheme.js):
<template>
<view class="page" :class="themeClass"> ... </view>
</template>漏绑的后果是「这个页面在暗色模式下仍是亮色」——不会报错,只是看起来不对。 H5 端额外由 utils/theme.js 给 <html> 加 dark 类,让原生弹窗与滚动条一起跟随。
pages.json 里的导航栏颜色是编译期常量(无法引用 Token), 运行时由 applyNavigationBar() 按主题改写。
五、安全说明(务必读完再发布)
| 项 | 实现 | 说明 |
|---|---|---|
| Token 存储 | utils/secure-storage.js | 三级自动降级:系统安全区(Android Keystore)→ 原生插件 → 混淆存储 |
| 当前安全等级 | 「我的 → 关于 → 令牌存储」实时显示 | 如实告知实际等级,不假装已接入安全区 |
| 通信加密 | utils/url.js 强制 HTTPS | 仅放行本机/内网地址的 http://,公网明文直接拒绝 |
| 明文入库 | 禁止 | 加密失败时放弃持久化,绝不退化为明文 |
| 设备绑定 | 混淆存储层叠加设备标识 | 存储目录被整体拷到另一台设备后解不开 |
| 备份导出 | 打包时 allowBackup=false(已实测) | adb backup 导不出 App 数据 |
| 截屏防护 | Android FLAG_SECURE(Native.js) | 默认开启,可在「我的 → 安全」关闭 |
| 退出登录 | 清除令牌与本地缓存 | 服务端无令牌撤销机制,故不发登出请求 |
令牌存储的三个等级
写入时按强度从高到低选择;读取时按数据自身的存储前缀决定用哪个后端 (k1: = 安全区,v1: = 混淆)—— 这样日后升级后端不会让老数据失效。
| 等级 | 触发条件 | 存储形态 |
|---|---|---|
keystore | Android 6.0+ 且 Native.js 调通 | AES-256-GCM,密钥在系统安全区、不可导出 |
native | 装了 DSH-SecureStorage 原生插件 | 由插件自行实现(历史方案,保留兼容) |
obfuscated | 以上都不可用(含 H5 / 小程序) | 随机 IV 流加密 + 设备绑定,不是安全区 |
⚠️ keystore 走 Native.js(plus.android),不需要任何原生插件, 因此云打包即可生效 —— 不必为此改走离线打包。
代价是那段代码无法在本机验证(没有 Android 运行时),只能在真机上通过 「关于」页显示的是哪一级来确认。src/utils/keystore.js 的文件头列出了 两个必须在真机上确认的不确定点;纯计算部分(UTF-8 / Base64 / 载荷格式) 已拆到 utils/keystore-codec.js 并被自测覆盖。
截屏防护
用窗口级 FLAG_SECURE(同样是 Native.js,无需插件):开启后截屏与录屏只会得到空白画面, 「最近任务」里的缩略图也会被遮住。
默认开启 —— 默认关闭的安全项等于没做(没人会主动去打开它)。 代价是你自己也不能截图了,所以「我的 → 安全」里有开关, 需要截图反馈问题时临时关掉即可。
⚠️ 只有 Android 有对应能力。不支持的平台上不显示开关,而是显示一行说明 —— 让用户拨一个永远没效果的开关,比不做这个功能更糟:他以为自己受保护了。
⚠️ 判定"到底有没有生效"要看最近任务的缩略图(生效时应为空白), 或用 adb shell screencap(生效时是黑屏)。 在电脑模拟器上,模拟器自带的截图按钮照样能截到内容 —— 它走的是宿主机窗口抓取,FLAG_SECURE 拦不住,不能拿它当判据。
仍然没有做的事(如实列出,不假装做了)
- 证书固定(Certificate Pinning):uni-app 网络层支持有限,当前依赖系统信任链。
- 反调试 / 反模拟器 / 完整性校验:未做。
- App 加固:未做(DCloud 提供付费加固)。
- iOS 的截屏防护:
FLAG_SECURE是 Android 独有,iOS 需另一套做法(截屏检测),本期未实现。
六、API 契约与 Mock
- 契约唯一来源:
docs/mobile-api-contract.md(服务端会话维护,v1.0.5) - 协作与边界:
docs/mobile-parallel-work.md - 契约差异集中在两处,服务端改字段只需要改这里:
src/api/—— 所有请求与响应归一化(缺字段一律有默认值)src/config/route-map.js—— 服务端route(Web 路由)→ App 页面路由
Mock
服务端 MOBILE_ENABLED 默认 false,云上未升级时 /api/mobile/* 全部 404。 开 mock 可以让 UI 开发不被阻塞:
# mobile/.env.local
VITE_USE_MOCK=trueMock 只覆盖读接口与登录;写接口(启停隧道等)不 mock —— 它们的"成功"没有意义,会掩盖真实错误。
容错要求(已实现,勿回退)
- 忽略未知字段;
- 缺字段有默认值;
- 能力探测 + 优雅降级:
/api/mobile/*不可用时首页仍有降级路径,
角标不显示而不是弹错;
- 区分两种 404:总开关关闭的 404(看文案
移动端接口未启用)与资源不存在的 404,
见 ApiError.isMobileDisabled / isNotFound。
七、联调前置条件
| 项 | 要求 |
|---|---|
| 服务端版本 | v1.0.5 或更高(/healthz 的 version 字段确认) |
MOBILE_ENABLED | true(默认 false;改动需 recreate 容器,restart 不重读 env_file) |
PANEL_OUTSIDE_ACCESS | true,否则公网来源调用管理 API 直接 403(云上已确认) |
| 短信登录 | 需 SMS_ENABLED=true 且账号已绑定手机号 |
| 图形验证码 | 开启时 App 会自动出现验证码输入框(并支持暗色配色) |
判据:公网无令牌访问 /api/overview 应返回 401(缺令牌),不是 403(被来源策略挡)。 401 说明链路是通的,只差认证。
八、测试
- 自动化:
node scripts/selftest.mjs(63 项,覆盖混淆编解码、格式化、地址校验、路由映射) - 手动清单:
docs/mobile-test-checklist.md(登录、主题、错误处理、降级、退出登录、回归)
⚠️ 构建通过 ≠ 运行时正确。Vue 的模板编译不检查 <script setup> 里的标识符, 漏 import 也照样构建成功。因此每次改动后必须按手动清单在 H5 上实际走一遍。
来源:
intranet-tunnel/docs/mobile-phase1-discovery.md→## 七、技术选型对比与推荐(原文 2885 字符)
七、技术选型对比与推荐
7.1 四个方案的对比(结合本项目实际情况)
| 维度 | A. uni-app (Vue 3) | B. Flutter | C. React Native | D. 微信小程序原生 |
|---|---|---|---|---|
| 与现有 Web 栈的关系 | ✅ 同语言同框架(Vue 3 SFC + 组合式 API) | ❌ 全新 Dart | ❌ React 生态 | ⚠️ 相近但受限 |
| 复用设计 Token | ✅ tokens.scss 的 --app-* 变量可直接搬(小程序/App 均支持 CSS 变量) | ⚠️ 需手工翻译成 Dart 常量 | ⚠️ 需手工翻译 | ✅ 同 CSS |
| 复用 API 封装思路 | ✅ axios 拦截器 → uni.request 拦截器,1:1 对应 | ⚠️ Dio 拦截器,思路相同但需重写 | ⚠️ axios 可直接用 | ✅ 同 axios 风格 |
| 推送能力(本项目核心价值) | ⚠️ 需 uniPush 2.0 或原生插件接厂商通道 | ✅ 官方插件生态成熟,到达率可控 | ✅ 生态最丰富 | ❌ 无法后台常驻,只能订阅消息 |
| 生物识别 / Keychain / Keystore | ⚠️ 需原生插件或 uts | ✅ 官方 local_auth / flutter_secure_storage | ✅ 生态齐全 | ❌ 无 Keystore |
| 证书固定(Certificate Pinning) | ⚠️ 网络层支持弱,需原生插件 | ✅ HttpClient.badCertificateCallback + 自签校验 | ⚠️ 需原生模块 | ❌ 不可行 |
| 截图防护(FLAG_SECURE / 模糊) | ⚠️ 需原生插件 | ✅ 插件可用 | ✅ | ❌ |
| 扫码 | ✅ uni.scanCode 开箱即用 | ✅ | ✅ | ✅ |
| 一套代码覆盖小程序/H5 | ✅ 独有优势 | ❌ | ❌ | ❌ |
| 交付速度(本团队) | ✅ 最快 | ❌ | ❌ | ⚠️ 仅限微信场景 |
| 性能 | 中(webview 渲染,列表页足够) | 高 | 高 | 中 |
7.2 推荐:方案 A(uni-app),理由与前提
推荐理由(按权重)
- 技术栈零切换成本:现有 Web 端是 Vue 3.5 + 组合式 API + SCSS Token,团队已具备;uni-app 的语法、
<script setup>、组合式 API 基本一致,设计 Token 与 API 封装的迁移是"搬"而不是"翻译"。 - 本项目移动端的定位是"轻量补充"(查看 + 轻操作 + 告警),不是重交互应用。列表/详情/表单/图表四类页面占 90%,webview 渲染的性能完全够用;而选 Flutter 换来的性能优势在这个场景里用不上。
- 可同时输出 H5 与微信小程序:运维场景下"不想装 App 时用小程序看一眼"是真实需求,方案 B/C 直接放弃这块。
- 限流与轮询压力下,代码复用带来的边际收益更高:双端共用一套请求/错误处理/刷新逻辑,能避免"Web 修了 401 重放、App 没修"这类分裂。
必须承认的三个短板与应对
| 短板 | 应对 |
|---|---|
| 推送到达率依赖插件(厂商通道需逐个接) | 若推送到达率是不可妥协的指标,则改推 方案 B(Flutter);或 uni-app + 极光/友盟聚合推送(一次接入覆盖厂商通道),代价是引入第三方 SDK 与依赖 |
| 证书固定、Keychain/Keystore、截图防护需原生能力 | 用 uts 插件或 uni-app 原生插件封装;这三项是安全底线,不能因为麻烦而降级为"可选" |
| 小程序端不支持 EventSource(日志实时流) | 小程序端降级为轮询 /api/logs/*;App/H5 端保留 SSE |
方案 B(Flutter)的触发条件(满足任一即改推 B)
- 决定只做 iOS + Android,且推送到达率/后台保活是硬指标;
- 团队已有移动端原生开发资源,且能接受 Dart 与 Vue 技术栈分裂;
- 后续规划中有大量原生能力(蓝牙、后台常驻、复杂离线)。
方案 D(微信小程序原生)不推荐单独采用:它的核心缺陷是无法后台常驻,而本项目的移动端核心价值是"随时接收告警"——这与小程序的模型直接冲突。它可以作为 uni-app 的附加输出端存在,但不能作为主方案。
7.3 若确认方案 A,建议的技术形态
| 项 | 建议 | 理由 |
|---|---|---|
| 框架 | uni-app(Vue 3 + Vite + <script setup>) | 与 Web 端一致 |
| 语言 | JavaScript(与 Web 端一致,不引入 TS 工具链) | Web 端是纯 JS;双端保持一致可减少心智负担。若团队有意向迁 TS,应 Web/移动端一起规划,不要只在新项目上引入 |
| UI 库 | 自建轻量组件(复用 Web 的设计 Token)+ 按需引入(可选 uview-plus) | 现有 Web 用的是 Element Plus,uni-app 生态无官方对应;需求强调的是 Token 一致而非组件库一致 |
| 状态管理 | Pinia(uni-app 官方支持 + 持久化插件) | Web 端用的 reactive 单例在多环境账号切换场景下不够用;这里是有意识的不一致,需在文档中记录 |
| 请求层 | uni.request 封装 + 拦截器(对齐 web/src/api/client.js) | 统一鉴权、错误映射、刷新串行化 |
| 安全存储 | 原生插件(iOS Keychain / Android Keystore) | 需求底线。小程序端降级为 uni.setStorageSync 并在文档中明确标注风险 |
| 图表 | 轻量 Canvas 手绘 或 uCharts | ECharts 在 uni-app 小程序端体积过大 |
| Token 同步方式 | 从 web/src/styles/tokens.scss 生成 mobile/styles/tokens.scss(人工同步 + CI 校验两者 Token 名一致) | 避免"改了一边忘了另一边" |
来源:
intranet-tunnel/docs/mobile-android-build.md→## 一、打包:两条路线(原文 1498 字符)
一、打包:两条路线
| 路线 | 怎么做 | 建议 |
|---|---|---|
命令行(cli pack) | HBuilderX 自带 cli.exe,一条命令提交云打包 | ✅ 推荐:可复现、不依赖找菜单 |
| GUI | HBuilderX 菜单「发行 → 原生App-云打包」 | 备选(菜单在新版 IDE 里的位置可能变) |
两条路线用的是同一套云打包服务,产物完全一样。
路线 A:命令行(推荐)
cd H:\Works\intranet-tunnel\mobile
powershell -ExecutionPolicy Bypass -File scripts\pack-android.ps1脚本会:检查 cli.exe、检查 appid 是否已填(否则提前报错,不必等云端返回)、 确认 HBuilderX 在运行(必要时 cli open 激活),然后提交打包并回显状态。
常用变体:
# 查询打包进度
powershell -ExecutionPolicy Bypass -File scripts\pack-android.ps1 -Status
# 取消排队中的任务
powershell -ExecutionPolicy Bypass -File scripts\pack-android.ps1 -Cancel
# 换成 DCloud 公共证书(默认用的是"老版证书",两者都是免费测试证书)
powershell -ExecutionPolicy Bypass -File scripts\pack-android.ps1 -CertType 1直接调 CLI 也行(脚本只是把前置检查与 ANSI 清理包了一层):
& 'D:\HBuilderX\cli.exe' pack `
--project 'H:\Works\intranet-tunnel\mobile' `
--platform android `
--android.packagename cloud.sushike.tunnel.console `
--android.androidpacktype 2⚠️ CLI 第一次打包会提示「正在下载依赖插件,请稍后重试」—— 那是 HBuilderX 在下载对应版本的编译器插件,等一两分钟重试即可(不是错误)。
⚠️
cli需要 HBuilderX 正在运行才能通信(它通过本地通道与 IDE 交互)。 若报「没有可用的命令,尝试使用 cli.exe open」,先执行cli.exe open。
路线 B:GUI
见下方「三、GUI 打包步骤」。
前提说明:本机没有 Android 打包工具链 —— 无 JDK、无 Android SDK、无 Gradle;但 HBuilderX 已安装(
D:\HBuilderX)。 uni-app 的 APK 打包必须经由 HBuilderX 的云打包或离线打包 SDK, 本机走的是前者:一条cli命令即可,不需要 JDK、SDK 或自备证书。(本段此前写着"也没有 HBuilderX",与上面的状态表自相矛盾,已更正。)
