Compare commits

..

88 Commits

Author SHA1 Message Date
YANG JIANKUAN
542c35b22d feat(phone): 5.5 寸手持端首页按新设计稿改版
按 documents/5.5寸首页新样式/ 设计稿还原 PDAEnterActivity 四个 Tab(仅 module_p,平板不经过该页):

- 国内/国际 Tab:分组标题栏 + 三列宫格(PdaMenuGroup / PdaMenuRenderer),分组与顺序按设计稿;
  20 个入口换新图标(源 GIF/PNG 转 PNG 放 drawable-nodpi,放 xxhdpi 会先缩后放发糊),
  设计稿未覆盖的入口追加到组尾沿用旧图标
- 权限过滤 authList.contains(key) 与改版前逐字一致:ADMIN 账号入口集合改版前后 diff 为空
  (国内 9、国际 12),21 个入口逐个点击跳转目标不变
- 首页航班动态:卡片 + 国内/国际、进港/出港分段 + 日期卡片(‹ › 翻日)+ 浅色表格 + 设置悬浮钮;
  默认方向改为进港,日期统一补零,日期选择器从当前选中日期打开
- 底部 Tab 栏(16dp 图标与文字居中)、消息铃铛与角标(四个 Tab 统一位置、切换不再移动)、
  状态栏 #3B71E8;「我的」顶栏同步新导航栏样式;首页 AutoSize 基准 400 → 390
- 删除 module_p 中不再引用的旧菜单/Tab 图标副本
- CLAUDE.md / phone-adapt skill 更新首页菜单入口说明,补图标发糊、分段控件描边与取整两个坑

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:37:04 +08:00
YANG JIANKUAN
a909f96d61 fix(gjc): 出港移库编辑页手机卡片改为与出港货物运单信息一致
- 四张卡片统一 bg_phone_card + 左侧蓝色短竖条标题 + 分割线,
  间距/内边距对齐 fragment_gjc_query_waybill(页面 12dp、卡片 14dp)
- 字段改用 PhoneKvItem 两列网格;运单号/移交状态并入基本信息首行,
  运单类型/品名改为整行字段
- 备注沿用无下划线输入,字号与 PhoneKvItem 一致,详情态空值占位与字段同色
- 删除上次为本页单独新增的 move_edit_ 色值与 drawable,及主卡 clipToOutline 设置

Refs: 禅道 Bug #632

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:10:34 +08:00
YANG JIANKUAN
00f2dc98dd feat(skill): zentao-bug 新增 assign 命令,支持指派 Bug 并附备注
等同网页「指派」表单:--to 接受账号或姓名,状态保持激活,提交后回读校验指派人;
多行备注用 --comment-file,换行转 <br /> 以便网页分行显示。
SKILL.md 补充用法,约定转给后端时备注照搬《后端接口对接问题清单》对应行。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:44:31 +08:00
YANG JIANKUAN
2ff052c6e2 fix(phone): 卡片右上角箭头改为原型灰色
ULD 管理、出港查询、出港仓库、进港查询、进港仓库卡片右上角箭头改为 gray-500
(phone_text_body),出港计重改为 gray-400(phone_text_hint),与各页原型一致。
仅在各卡片布局上加 tint,公共图标 ic_phone_chevron_right 不变;
「查看详情 ›」的蓝色箭头保持原样。

Refs: 禅道 Bug #620

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:35:25 +08:00
YANG JIANKUAN
1b6f14da09 fix(gjc): 出港运抵已运抵卡片同样去掉海关放行与运单类型
此前只在「待运抵」隐藏该行,现两个 Tab 都不显示,直接从手机卡片布局移除。

Refs: 禅道 Bug #626

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:35:25 +08:00
YANG JIANKUAN
42c0e26bde fix(gjc): 出港运抵卡片无分单也显示「查看分单 0」,分单明细补空态
- 手机卡片「查看分单」按钮始终显示,分单数为空时按 0 显示
- 无分单的运单可进入分单明细页,补「暂无分单」空态,避免整页空白

Refs: 禅道 Bug #631

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:29:24 +08:00
YANG JIANKUAN
39b9669a87 fix(gjc): 出港移库编辑页手机卡片按原型重做样式
- 主信息卡去掉蓝色表头,改为白底 + 左侧 3dp 蓝色装饰条(clipToOutline 随圆角裁切)
- 移库信息 / 交接图片 / 补充信息三张分区卡:白底深色标题 + 分割线,替代彩色表头
- 字号、字重、颜色、圆角、描边按「移库编辑」HTML 原型逐项对齐
- 备注行改为本页自有输入行,去掉下划线
- 卡片阴影改用原型的蓝灰半透明色,容器 clipToPadding=false,避免阴影发脏与被截断

只改本页手机布局:页面专用色值与 drawable 以 move_edit_ 前缀放在 module_gjc,
公共颜色、组件与平板布局均未改动。

Refs: 禅道 Bug #632

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:29:24 +08:00
YANG JIANKUAN
1488547220 build: autosize 依赖固定为 1.2.1,消除每次构建的动态版本查询
`me.jessyan:autosize:1.2.+` 让 Gradle 每次构建都去所有仓库列版本,
其中私服 118.31.6.84:8081 连接后挂起再返回空响应,失败结果不缓存,
导致无改动构建也要约 7 分钟。固定为本地一直在用的 1.2.1 后,
联网的无改动构建降到 1~2 秒,运行行为不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:29:24 +08:00
YANG JIANKUAN
d738a4acfe feat(skill): zentao-bug 新增 resolve 命令,支持在禅道解决 Bug
- resolve <id...>:等同网页「解决」表单,默认解决方案 fixed、解决版本 trunk(主干),
  支持 --dry-run 预览;只处理激活状态,提交后回读校验
- 接口提交不会自动补默认值:解决日期与指派给先从解决表单 HTML 读取默认值
  (服务器北京时间 / 创建人)再原样提交,避免用本机时区时间
- SKILL.md 补写回说明,并注明 login 需在独立终端执行(! 前缀无 TTY)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:29:08 -07:00
YANG JIANKUAN
a58845121b docs: 后端接口对接问题清单新增 #42~#43(禅道 Bug 排查发现)
- #42 国际出港运抵 arrivalStatus 归类错误:已申报(01/W/E)运单仍在待运抵返回(禅道 #627)
- #43 国际进港提取出库出库后任何条件都查不到记录,outState=1 不生效(禅道 #628)
均经 2026-10-14 内网直调确认;「使用说明」补更新记录。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:22:10 -07:00
YANG JIANKUAN
a4167c3cf3 fix(gjj): 进港仓库补齐始发站与库位件重,运单信息去掉出库时间
- 运单信息 Tab 去掉「出库时间」
- 始发站显示 "-":详情 warehouseList 返回 fdep 而非 dep,列表 IntImpStorage/pageQuery
  不返回 dep/fdep、始发站在 origin。GjjWarehouse 新增 fdep 与 depDisplay
  (fdep → dep → origin),GjcMaWb 新增 depDisplay(dep → origin),仓库卡片与列表卡片改绑
- 库位卡片:GjjStorageUse 补 inPc/inWeight/outPc/outWeight,增加出入库件数与重量两行

Refs: 禅道 Bug #635

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:22:01 -07:00
YANG JIANKUAN
9db20749e2 fix(gjc): 出库交接卡片装机状态标签移到查看详情行最左侧
「允许/不允许装机」原在卡片头部右侧,与 IMP 代码标签抢同一位置;
头部右侧只保留 IMP 代码,查看详情点击区域仍为标签右侧整段。

Refs: 禅道 Bug #634

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:22:00 -07:00
YANG JIANKUAN
eb0fb12c8d fix(gjc): 出港货物详情调整字段顺序,库位卡片增加出入库件数与重量
- 运单信息:「包装类型 | 业务类型」同行,「入库时间」单独一行放在备注下面
- 库位信息:IntExpSearch/detail 的 storageUseList 已返回 inPc/inWeight/outPc/outWeight,
  卡片增加「入库件数 | 入库重量」「出库件数 | 出库重量」两行

修改库位「原库位」应列出全部已分配库位:模拟器实测已列出全部 4 个,未复现,待确认。

Refs: 禅道 Bug #633

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:22:00 -07:00
YANG JIANKUAN
2996e670e0 fix(gjc): 出港移库编辑拆分交接图片与补充信息卡片,补上图片删除
- 交接图片单独成卡:橙色表头 + 三列虚线加号槽位,与国内进港移库「异常照片」一致;
  其下新增「补充信息」卡片(表头底色 #F3F4F6)承载备注录入
- 删除不了图片:选图组件长按回调 onItemClick(rl) 以显示删除角标,ViewModel 只处理了
  点角标删除,角标永远出不来;补上长按切换(详情态不可删,平板共用一并生效)
- 公共选图格子 item_image_select_new 拆双变体:平板原样移到 layout-sw600dp/,
  手机版改为灰色虚线框 + 灰色加号(国内移库拍照页同步统一)

表头文字颜色暂用深色(Bug 单写白字,白字在 #F3F4F6 上不可读),待确认。

Refs: 禅道 Bug #632

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:50 -07:00
YANG JIANKUAN
36d74c3f0d fix(phone): 出港/进港仓库卡片备注行前补图标
备注行原为纯文字,与上方带 14dp 图标的字段行不一致;改为 ic_phone_clipboard 图标 + 文字,
进港仓库卡片同步(#630 排查确认进港备注行本身正常,仅清仓时填了备注才显示)。

Refs: 禅道 Bug #629, #630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:34 -07:00
YANG JIANKUAN
9e545ee0ec fix(gjc): 出港运抵待运抵卡片不显示海关放行与运单类型
该行改为仅在「已运抵」Tab 显示。

Refs: 禅道 Bug #626

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:10 -07:00
YANG JIANKUAN
66c7c0f4f6 fix(app): ULD 列表手机卡片去掉「所在港 / 位置」一行
与原型保持一致;「来源」「入库时间」为数据本身为空(入库时间取 inStoreTime)。

Refs: 禅道 Bug #625

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:10 -07:00
YANG JIANKUAN
075b6bf1ea fix(gjc): 出港待计重手机端去掉「新增计重」浮动按钮
原型只有「计重记录」一个浮动按钮;平板搜索栏的「+」不变。

Refs: 禅道 Bug #624

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:10 -07:00
YANG JIANKUAN
d1d995c698 fix(phone): PhoneDataLayout 下拉改为底部弹出列表
筛选面板与弹框里的 PhoneDataLayout 原用系统 Spinner,点开是贴在输入框下的下拉菜单。
SPINNER 形态改为与 PhoneFormRow 同款 XPopup 底部列表(标题取 hint),保留原取值语义:
无 hint 默认选中第一项、有 hint 为空值,筛选「重置」逻辑不变。19 个手机布局随之生效。

Refs: 禅道 Bug #623

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:04 -07:00
YANG JIANKUAN
fa6c3f473e fix(gjc): 计重记录运单详情补齐航班日期、航班号、航程与业务类型
IntExpCheckIn/checked/pageQuery 出参中 fdate/fno/range/businessType/remark 恒为空,
详情页只展示列表传入的数据,因此全部显示 "-"。进入后按 11 位运单号调用
IntExpCheckIn/queryWbByNo 补齐空值,业务类型代码按国际出港字典(IO)转中文名。

Refs: 禅道 Bug #622

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:04 -07:00
YANG JIANKUAN
2c8fa689a5 fix(gjc): 开始计重手机端去掉地磅重量,平板车自重左对齐并自动带出
- 手机布局删除「地磅重量」;手机端不再按「地磅重量 − 托盘总重」自动计算运抵重量
  (地磅为 0 时会被写成 "0"),改为手输、默认空白;平板逻辑不变
- 「平板车自重」改用不可编辑的 INPUT 形态,与其他行一样左对齐
- 运单加载后若已带平板车号,直接回查自重(平板共用 ViewModel,一并生效)
- PhoneFormRow 的 INPUT 形态补上输入完成回调(失焦 / 键盘完成、下一项),
  原先从不触发,手输平板车号、运单号后不会回查

Refs: 禅道 Bug #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:20:57 -07:00
YANG JIANKUAN
b22d55a380 fix(phone): 三格统计卡左侧色条改为随圆角弯曲的整条左边框
原实现是卡片内上下各缩进 12dp 的独立短竖条,与原型 rounded-lg border-l-[4px] 不符。
新增 bg_phone_stat_blue/orange/green(白底 12dp 圆角 + 左侧 4dp 色边),替换出港待计重、
计重记录、出港查询、出港仓库、进港查询、进港仓库 6 个页面共 18 处统计卡。

Refs: 禅道 Bug #620

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:20:50 -07:00
YANG JIANKUAN
7f1a413172 feat(skill): 新增 zentao-bug skill,读取禅道 18.x Bug 单并按项目规范修复
禅道 18.8 无 RESTful API 2.0,官方 zentao-cli/MCP 不可用。zentao.py 以 v1 tokens 换会话,
走页面 JSON 模式读取「我的 Bug」列表与 Bug 详情(描述/截图/评论/修改历史/附件),
截图下载到本地供 Agent 查看;Token 存 ~/.config(0600),密码仅存 macOS 钥匙串。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:20:40 -07:00
YANG JIANKUAN
f41d4186ea style(gjc): 开始组装页运单列表 5 列间距均分,表头两种状态各用一套列宽
- 新增 AssembleWaybillColumns:「件数/重量」与「剩余件数/总件数」两种表头状态
  各一套列宽,表头按 hasLoadedOnce、列表项按 showRemaining 切换,保证列对齐
- 件数与重量之间加弹性间隔;剩余态重量列放宽到 140dp,大重量不换行
- module_base 新增通用 layoutWeight 绑定适配器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:29:33 -07:00
YANG JIANKUAN
d9dc927525 fix(gjj): 国际事故签证「发现时间」改为下拉选择,数据源 POST typeCode/searchDateType
- Api 新增 getSearchDateTypeList(POST 无参,返回 code 1~8 + 中英文 name),
  与同路径的 GET 通用字典 getDictList 区分
- 编辑页 DATE → SPINNER,与 Web 端一致提交字典 name
- 历史数据(日期值、带尾随换行的 name)不在字典内时补进下拉列表,
  避免编辑/详情回显为空、保存时被清掉
- phone-adapt api-doc 记录该接口 GET/POST 语义

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:29:33 -07:00
YANG JIANKUAN
d5631930c4 chore: 清理 settings.local.json 中 vfox 迁移遗留的权限项
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:20:55 -07:00
YANG JIANKUAN
5b217ab032 feat(sync-air-cargo): 以 Synced-From trailer 记录同步点,同步后自动在 air-cargo 本地提交
- 默认区间模式:基线取 air-cargo 最近的 Synced-From,目标为 main,内容取自提交对象
- 无记录时按内容反推基线(仅预览,apply 须 --base 确认),每次预览做基线校验
- 新增三方校验,列出将覆盖的 air-cargo 侧独立修改;分支/工作区/祖先关系保护
- --worktree 同步未提交变更且不推进同步点;提交信息仅「<Label> <日期>」+ trailer,不 push
- 新增 config(main → feature/hefei,air-cargo 位于项目根目录)
- 排除清单补充 .mcp.json、.claude.*、CLAUDE.local.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:20:55 -07:00
YANG JIANKUAN
f1ff42f2bf chore: 忽略根目录 air-cargo/,CLAUDE.md 声明其仅作另一仓库的提交通道
- .gitignore 新增 air-cargo/(同一份源码的独立 git 仓库,远程不同)
- CLAUDE.md 新增「air-cargo 目录:一律忽略」:扫描、开发、查历史均跳过,
  仅 sync-air-cargo skill 可操作

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:20:55 -07:00
YANG JIANKUAN
388d605869 docs: 新增双端合入主干交接文档,修正 CLAUDE.md 过期信息
- documents/双端合入主干交接文档.md:分支状态、平板/手机开发状态、合并内容、
  开发规则、验证覆盖、待决事项与注意事项
- CLAUDE.md:版本 1.8.7 (87);JDK 17 运行 / Java 1.8 编译目标;模拟器路径与
  AVD 名(AeroLogic_Phone sw=394dp / AeroLogic_Tablet);运抵长按浮层与已删除的
  补充信息页;分支约定处链接交接文档

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:10:45 -07:00
YANG JIANKUAN
131eb031ba merge: 手机适配分支 feature/phone-ui-adaptation 合入 main,平板与 5.5 寸手机统一在主干开发
合并原则:业务层以 main 平板为准,手机端只做 UI 适配。
- 冲突布局:平板改动三方合并进 layout-sw600dp/,手机布局保留并补齐主干新增业务
- Bean/ViewModel 冲突取平板业务逻辑(计重托盘联动、开始计重运抵重量自动计算、
  出港查询 dateState、仓库入/出库件数重量、清仓备注等),手机仅保留展示字段
- 主干新功能同步到手机:运抵海关放行/分单号筛选/回执走 getResponse、删除申报;
  出库交接装机状态与允许装机/配运;出港移库详情与编辑共用 IntExpMoveEditActivity;
  出入港仓库清仓备注、出库弹框件数重量、进港仓库 pcLabel;ULD 所在港/位置
- 新增手机变体:出库/入库/清仓/配运/删除申报弹框、移库详情编辑页、海关回执页;
  PhoneBottomBar 增次操作按钮
- 手机对齐平板:去掉 likeNo 与平板没有的筛选入参、恢复平板默认查询日期,
  补齐平板表单/筛选项与操作入口;移除手机专属 IntExpMoveDetailActivity
- CLAUDE.md 记录单分支约定与「业务以平板为准」规则,CHANGELOG 补合并记录

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:20:03 -07:00
871fa4c5ce chore: 引入 mobile-mcp 项目级 MCP 并沉淀 ADB×MCP 工具选型纪要
mobile-mcp 固定 1.0.7 版本;verification.md 新增 2026-10-02 工具选型纪要
(ADB/ui_probe.sh 与 Mobile MCP 的分工规则、实测踩坑、本机环境更新)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 12:10:07 +08:00
dda0ff873a chore: SDK 版本管理由 vfox 迁移至 mise 原生配置
- 新增 mise.toml(java = 17,vendor 由全局 shorthand_vendor 控制)
- 删除 vfox 配置 .tool-versions / .vfox.toml
- 删除指向旧机器 vfox npx 路径的过时 MCP 定义 .claude/mcp.json
- 清理 .gitignore 与 settings.local.json 中的 vfox 残留

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 15:44:06 +08:00
9e66e892f2 feat: 国际进港舱单开始理货改用理货库位查询与开始理货接口
- 新增 getSortingStorage 接口定义,开始理货前查询运单当前库位并自动回填下拉选中
- 对话框确认提交由修改库位接口(modifyStorage)改为开始理货接口(inStorage)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-29 13:23:42 +08:00
9b820ba8ef style: 国际进出港分单复选框统一复用全选图标资源
出港运抵、出港理货、出港仓库、进港舱单、原始舱单、进港理货、进港仓库
七个页面展开子列表的复选框由 radiobtn_*_style 改为 img_check_all_*,
高度保持 20dp;同步更新 CLAUDE.md 中的子列表复选框规范。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-24 17:21:54 +08:00
74f1280896 feat: 国际进港仓库入库/出库支持填写件数重量
- 入库弹窗新增件数、重量输入,默认取选中运单件数重量(装机单入库不显示)
- 出库改用出库弹窗(替换确认框),新增件数、重量输入,默认取库位入库件数重量
- 出库接口请求体由数组改为对象,选中库位放入 storageUseList
- 库位子列表新增入库件数/重量、出库件数/重量四列

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 11:32:43 +08:00
2dc86bcae5 feat: 国际出港仓库入库/出库支持填写件数重量
- 入库弹窗新增件数、重量输入,默认取选中运单件数重量
- 出库改用自定义弹窗(替换 AlertDialog),新增件数、重量输入,默认取库位入库件数重量
- 出库接口请求体由数组改为对象,选中库位放入 storageUseList
- 库位子列表新增入库件数/重量、出库件数/重量四列

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 10:47:03 +08:00
aebf1b1090 style: 调整出港仓库列表项与开始计重表单的字段位置
出港仓库列表项中品名与件数互换位置;开始计重页运抵行与托盘行互换,
业务类型移至托盘自重之后,备注改为平板车自重后的三分之一列,不再独占一行。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:29:00 +08:00
698046c9fb style: 国际进港事故签证列表项按内容宽度重排列 weight
按各列实际内容宽度重新分配五列 weight(1.1/0.75/1.0/1.0/1.15),
使列间距均匀一致,避免原来疏密不均的问题。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:13:30 +08:00
94508f8c40 feat: 国际出港开始组装 ULD 触发板型查询后自动选中首项
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:03:56 +08:00
d54e1e429d feat: 航班详情页去掉航程表单项
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 14:56:34 +08:00
b7a2be0f35 style: 出港运抵标签改为运抵模式,进港理货报告更名为进港理货
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 14:54:32 +08:00
75bfe85f62 feat: 航班表单三个枚举下拉对接字典接口并补全字段
新增页补上飞行状态输入项;飞行状态、航班服务种类、航班状态三个
下拉改为调用 typeCode 字典接口,替换原有的硬编码选项(旧的航班
状态码值与服务端实际码值完全不同)。详情页加载同一批字典,按值
反查并显示 label。新增页与详情页字段对齐为同一套。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 12:21:01 +08:00
f39127436a feat: 国际出港开始组装板型下拉改为控件下方展开
板型由底部弹框选择改为锚定在输入框正下方的 ListPopupWindow,
复用相邻 BUP Spinner 的弹层背景与列表项布局,外观保持一致,
且不影响手动输入。

板型数据源的承运人参数改由 ULD 编号触发:选中运单不再重载,
改为 ULD 信息查询成功后取 ULD 编号后两位作为 carrier 重新加载。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:49:06 +08:00
6b02f71e8a fix: 国际进港仓库清仓入参改为 body 传递
清仓原先把 clearNormal/clearRemark 放在 URL query 上,与接口文档不符。
改为与国际出港一致的单对象 body:clearNormal、clearRemark 在顶层,
选中数据放 warehouseList(出港侧对应字段为 maWbList)。
同时移除逐条写入主单 clearRemark 的兼容写法。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:24:58 +08:00
50275457a4 feat: 国际出港查询筛选弹框特码改为下拉选择
特码由手动输入改为从特码字典接口获取的下拉列表,
接口参数与国际出港待记重页面一致(flag=1 国际、ieFlag 为空),
首项「全部」表示不过滤

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:45:04 +08:00
b14649b228 fix: 国际进港舱单新增/编辑页航程不再随始发站目的站联动
航程改为仅由航班信息决定:航班查询成功取航班起始站-目的站,
编辑模式取舱单 fdep-fdest,从列表页带入时取传入的航班始发/目的站;
用户手动修改始发站、目的站时航程保持不变

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:27:31 +08:00
52d1527121 fix: 国际进港原始舱单与装机单未选航班时不再请求列表接口
- 原始舱单、装机单搜索时校验航班号必填,为空提示「请输入航班号」
- 装机单仅在父页面传入 fid 时才初始加载,否则等待手动查询
- 进港舱单、装机单 getData 在 fid 为空时直接清空列表与汇总,不发请求;
  修复装机单编辑保存后广播刷新事件,导致未选航班的父页面请求接口
  并报「业务异常:请先选择航班」

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:11:23 +08:00
74cf1346a6 style: 国际出港/进港列表项文字样式统一为四个基准页规范
主题未覆盖 textViewStyle,旧列表项未显式写 textColor 导致文字发灰、
字号与卡片边距不一。以出港运抵/组装/装载/理货为基准统一:
- Key/Value 显式 15sp + text_normal,首要字段 colorPrimary 加粗,
  状态值 14sp 带颜色表达式
- 卡片外边距 10dp/10dp,bg_item 统一为 bg_white_radius_8,
  图标与内容区间距 15dp
- 原始舱单子列表改为标准白卡片 + 表头底色,查询详情表格改用 text_normal
- 修复出港查询布局 xmlns:tools 误绑 android 命名空间
- CLAUDE.md 列表项规范表同步更新为基准页真实数值

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:52:36 +08:00
8853405ffc fix: 计重明细重量与托盘自重改为和恒定双向联动并校验非负
原逻辑托盘自重变化时重量同向增减,且修改重量不联动托盘自重。
改为两者之和恒等于接口初始值,修改任一方另一方反向同步变动;
任一方将小于 0 时拒绝输入、提示并回退显示。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:25:02 +08:00
ee0606230f fix: 国内进港移库列表项两行列宽不一致导致标签错位
主应用移库列表第三列「重量」weight 为 0.7 而「起始站」为 0.8,
两行 weight 总和不同使各列起点偏移;统一为 0.8 后上下对齐。
同时修正 module_gnj 旧版移库列表项同列 completeSpace 不一致。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 16:30:26 +08:00
07ea8853f3 feat: 国际出港开始组装板型下拉对接后端板型接口
进入页面调用 /typeCode/bordType 取全量板型,选中待组装运单后
以航班号前两位作 carrier 重新加载覆盖下拉数据源;后端 carrier
必填,无承运人时传空串。替换原 BOARDTYPE 字典实现。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 16:20:08 +08:00
6d4908da1a style: 海关回执页面内容区背景改为与列表卡片一致的圆角
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 16:03:46 +08:00
a1d078db7a fix: 国际进港舱单理货完成去掉库位弹框直接调用发放接口
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:58:32 +08:00
f6e5b37eaa fix: 国际进港出库特码筛选由输入框改为字典下拉选择
与国际进港查询筛选栏保持一致,加载国际特码字典供选择,
去掉原特码扫码入口。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:53:03 +08:00
2f4e723dd7 feat: 国际进港仓库清仓弹框新增备注并在列表第三行显示
清仓时备注同时通过 clearRemark query 参数和运单字段发送,兼容服务端两种取值方式;
列表项新增独占一行的备注展示,与国际出港仓库保持一致。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:44:34 +08:00
52584b9cfe fix: 国际出港板箱过磅重进页面时自动选中上次通道号
原实现只把上次通道号排到字典列表首位,但带 hint 的 Spinner 默认停在 hint 项,
导致退出再进入(含从列表点 ULD 进入的编辑模式)时通道号始终显示「请选择通道号」。
改为字典加载完成后按「本条记录通道号 → 上次记住的通道号」优先级显式赋值。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:08:50 +08:00
efe7a1d2a9 refactor: 国际出港移库详情/编辑按最新接口文档重新对接
- 新增 GjcMoveDetail Bean 对应 /IntExpMove/detail 返回与 /IntExpMove/update 请求体
- 承运人改取 by、运单类型改取 awbTypeName,去掉文档中不存在的航班字段,新增移交状态展示
- 保存时回传完整详情对象,仅替换备注与 pic/originalPic/picNumber
- 列表 Bean 随 Intent 传入,回填 detail 返回为空的承运人、出运路径等展示字段

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:55:54 +08:00
85fd66b005 refactor: 国际出港查询筛选面板去掉出库状态筛选项
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:29:32 +08:00
de64c8ddb6 style: 国际进港查询筛选栏航班号与代理位置互换
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:26:15 +08:00
2a83c1393e feat: 航班新增/编辑表单精简字段并补充必填校验
去掉进出港、货邮状态、运单前缀三个输入项;地区类型、服务类型、
计划起飞、预计起飞、计划降落、预计降落改为必填并显示星号。
编辑模式下被移除字段仍原值回传,避免更新时被清空。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:23:02 +08:00
9dddb7c079 feat: 国际出港仓库列表项新增第三行显示清仓备注
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:08:31 +08:00
2ba70d1a09 feat: 国际出港开始组装新增 IMP代码、板型、BUP 输入项
- ULD 信息卡新增第二行:IMP代码(大写过滤)、板型(手输 + 下拉选择,箭头与 Spinner 同源)、BUP(否/是,默认否)
- 装货/卸货提交时 dgrCode、boardType、bupFlag 同时写入顶层参数与 useInfo,输入 ULD 后从 getUld 回填
- 板型选项走通用字典接口,字典 code 暂定 BOARDTYPE,测试环境返回为空时提示手动输入

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 10:40:43 +08:00
ea83361102 feat: 国际进出港 5 个列表页侧滑新增海关回执查看
原始舱单、进港理货、出港运抵、出港装载、出港理货主单卡片
新增侧滑「回执」按钮,调用各模块 getResponse 接口后跳转
module_base 共用的只读回执页。请求同时传 prefix、no 与
wbNo,回执优先取 data、为空再取 msg,兼容后端实际返回;
回执为空时提示「暂无海关回执」不跳转。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 10:12:52 +08:00
7a09bbbeab feat: 国际出港开始组装运单列表首次装货后显示剩余/总数
首次装货成功前件数、重量列显示总数;成功后表头切换为
「剩余件数/总件数」「剩余重量/总重量」,剩余值由 pc 减
checkInPc、weight 减 checkInWeight 得出。三处接口数据转
Bean 的重复代码合并为扩展函数。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 20:43:55 +08:00
6b2006fdb7 feat: 国际出港出库交接新增装机状态显示与允许装机操作
列表项新增「装机状态」列,依据分页接口 allowLoad 字段展示,
允许装机绿色、不允许红色,并重新分配列权重避免长字段换行。
底部新增「允许装机」按钮,支持多选后批量调用允许装机接口。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 20:17:31 +08:00
b368efa827 fix: 国际进港仓库列表件数改取 pcLabel 字段
分页接口新增 pcLabel 字段(如 0/8),用于展示已入库/总件数,
列表项件数由 pc 改为优先显示 pcLabel,缺失时回退 pc。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 20:04:01 +08:00
fcb247c3f5 feat: 国际出港理货新增分单全选功能
底部栏新增「分单全选」按钮,与主单全选相互独立。
依据分页接口新增的 allTally 字段判断:主单存在分批申报(allTally=0)时跳过其分单,
只有主单全部申报完毕(allTally=1)才允许全选该主单下的分单,跳过时 toast 提示。
分单单个勾选后同步刷新分单全选状态。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 19:55:44 +08:00
5edb1bea06 feat: 国际进港装机单列表改为 ULD 主单与运单子单结构
适配装机单查询接口按 ULD 分组的新返回结构,主单展示 ULD 编号、
总件数、总重量、航班信息,展开后显示关联运单的运单号、件数、重量、品名,
样式与展开交互与其他主子单列表一致;点击子单行进入装机单编辑。
右下角“修改库位”“入库”按钮暂时隐藏。同步修正进港舱单页修改库位时
从新分组结构中取运单记录。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 19:43:26 +08:00
51c5ac6e96 feat: 国际出港移库新增详情查看与侧滑编辑
列表项右侧增加箭头,点击进入详情页;左滑显示“编辑”按钮进入编辑页。
详情与编辑复用同一表单页,编辑模式仅支持修改备注与交接图片上传/删除,
表单结构参考国内进港移交编辑。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 16:00:48 +08:00
ea298ec322 feat: 开始计重新增托盘信息并自动计算运抵重量
国际出港开始计重页面新增托盘数量、托盘自重输入框(自重默认18可编辑),
运抵重量自动按“地磅称重 -(托盘数量 × 托盘自重)”计算;
分托计重与完成计重提交均携带 trayNumber、trayTotalWeight;
“托盘车号”“托盘车自重”标签改为“平板车”“平板车自重”。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 13:11:17 +08:00
b57a3cd133 fix: 计重明细托盘自重改取托盘重量并支持编辑联动
托盘自重原误取托盘车重(carWeight),改为托盘重量(trayTotalWeight)。
编辑模式下托盘自重可编辑,重量按编辑前后差值同步浮动,底部统计随之更新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 11:59:13 +08:00
4e6e654558 feat: 国际出港运抵列表新增运抵状态筛选
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 11:34:46 +08:00
b65e61595b feat: 国际进港舱单编辑支持交接图片新增与删除
编辑/新增表单页增加交接图片区(选图、上传、长按删除),
保存时随舱单提交 pic/originalPic/picNumber 三字段,
编辑模式回填已有图片且不重复上传。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 11:23:43 +08:00
86f77353c0 feat: 国际出港板箱过磅通道号默认沿用上一次选择
称重提交成功后本地记住通道号,后续新增称重自动默认选中;
重置时不再清空通道号,编辑模式仍按记录自带通道号回填。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 10:44:31 +08:00
39b5cc3689 feat: 国际出港板箱过磅挂签支持多选批量打印
多选时逐张串行发送,张间节流留出出纸时间,写入失败即中止
并提示已打印张数与失败的 ULD 号;单选与详情页打印路径不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 10:44:25 +08:00
a1b55f656f feat: 航班管理列表新增增删改功能
- 列表页右上角新增加号入口,新增/编辑共用表单页(HbFlightEditActivity)
- 列表项支持侧滑编辑、删除,删除使用 ConfirmDialogModel 二次确认
- 接入 /flt/saveFlight、/flt/updateFlight、/flt/delete 接口
- 编辑回填增加枚举中文名转代码处理;FlightBean 地区类型兼容中文值显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 10:13:37 +08:00
13c9c6f5dc feat: 国际出港运抵计重状态改为海关放行
- 主单列表项第二行「计重状态」改为「海关放行」,取运抵分页接口
  返回的 cmdStatus,按普通文本显示,与查询详情页「海关指令」一致
- 展开的分单列表在「申报状态」与「品名(中)」之间新增「海关放行」列
- GjcMaWb 补充 cmdStatus 字段,此前仅 GjcHaWb 有该字段

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:27:31 +08:00
de62b903d4 feat: 国际出港板箱过磅新增耗材重量并计入货重
- 过磅表单新增只读的耗材重量,数据随列表项 Bean 带入,扫码新增时
  取 queryUsingUldByUld 返回的 consumeWeight
- 货重公式由「装机重 - ULD自重」改为「装机重 - ULD自重 - 耗材自重」
- queryUsingUldByUld 返回后补触发重量计算,此前该接口异步返回时
  耗材重量不会参与计算
- 表单保持每行三列:第三列自上而下为总重、装机重、耗材重量、货重,
  目的港单独占一行三分之一宽,备注换行整行撑满

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:12:21 +08:00
2d42ce9cc3 feat: 国际出港重量回填页改名并新增耗材重量
- 页面标题由「国际出港修改组装重量」改为「国际出港重量回填」
- ULD 信息卡片在货重之后新增耗材重量,取组装分页接口返回的
  consumeWeight 字段,数据随列表页传入的 Bean 一并带过来

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:46:01 +08:00
861ce8ca1e feat: 国际出港交接复磅状态改为 IMP 代码
列表项第二行第三列由「复磅状态」改为「IMP 代码」,取交接分页接口
返回的 dgrCode 字段;值非空且不等于 ELI、ELM 时以红色显示,
空值与 ELI、ELM 保持常规颜色。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:31:27 +08:00
f312ef866c feat: 国际进港舱单新增开始理货按钮
- 底部「货物发放」改名为「理货完成」,「分拣理货」改名为「装机单」
- 新增蓝色「开始理货」按钮,选中唯一一条运单后弹出修改库位对话框,
  提交 /IntImpManifest/modifyStorage
- 弹窗默认回填该运单已有的理货库位:舱单列表接口不返回 locationTally,
  改为先按 fid + wbNo 查一次装机单记录取出库位再回填
- GjjManifest 补充 locationTallyId 字段
- 装机单列表项运单号、品名列 completeSpace 调整为 5 以对齐

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:19:47 +08:00
6f27c15d9a fix: 修复国际进港出库运单号搜索不生效
运单号原本传给了 no 参数,但接口中 no 是不含前缀的主单号
(如 33362265),wbNo 才是完整 11 位运单号,导致条件被忽略。
同模块提取记录、进港查询用的均为 wbNo,此处保持一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:08:04 +08:00
ded57554fa feat: 国际出港查询新增日期类型与航班航次统计
- 合计栏在总重量后追加航班航次(取统计接口 flightNumber)
- 日期范围前新增日期类型下拉(入库/离港),对应接口 dateState 参数
- 航班号从筛选弹框移至列表上方,出库状态移入筛选弹框
- dateState、outState 按接口文档改为整型传参

原航班号查询无效的原因并非参数名有误(fno 与文档一致),
而是缺少 dateState 导致后端按默认日期口径过滤,补齐后已实测生效。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:49:46 +08:00
2f3ae2a814 feat: 国际进港查询统计新增航班航次并调整筛选条件布局
- 底部统计栏在总重量后新增航班航次显示
- 航班号从筛选面板移至页面上方搜索区,出库状态移入筛选面板
- 筛选红点提示与重置逻辑同步调整字段归属

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 17:08:47 +08:00
19d02700b8 feat: 国际出港仓库清仓弹窗增加备注输入框
清仓操作弹窗在「清仓正常」下拉基础上新增选填备注,提交时随 clearRemark 字段传给 updateClear 接口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 16:41:49 +08:00
aa6cc43bfa feat: 国际事故签证新增支持运单号联想并自动带出舱单信息
- 运单号输入启用自动联想(1条自动填充、多条弹窗选择)
- 运单号满11位自动查询进港舱单,带出航班日期、航班号、始发/目的站、总件数、总重量、品名及交接图片
- 仅新增模式触发带出,避免编辑模式覆盖已保存数据和图片

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 16:31:16 +08:00
dcb335ecdf feat: ULD新增编辑表单增加来源、入库时间、所在港字段
- 编辑表单新增来源(空运/陆运)、入库时间、所在港(默认HFE)三个字段
- 列表项同步显示来源、所在港、位置、入库时间,去掉备注展示并收窄首列
- PadDataLayoutNew 新增 DATETIME 类型,支持年月日时分秒选择

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-27 15:30:23 +08:00
567b76014b fix: 国内进港卸机相关页面标题去掉"国内"二字
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 10:35:36 +08:00
346 changed files with 12396 additions and 2955 deletions

View File

@@ -39,9 +39,6 @@
"Bash(git -C /Users/kid/Development/Fusion/Projects/aerologic-app stash)", "Bash(git -C /Users/kid/Development/Fusion/Projects/aerologic-app stash)",
"WebFetch(domain:api.apifox.cn)", "WebFetch(domain:api.apifox.cn)",
"Bash(sips:*)", "Bash(sips:*)",
"Bash(vfox list:*)",
"Bash(vf list:*)",
"Bash(vfox use:*)",
"Bash(wait)", "Bash(wait)",
"Bash(while read file)", "Bash(while read file)",
"Bash(do sed -i '' 's/MutableLiveData\\(DateUtils\\\\.getCurrentTime\\(\\)\\\\.formatDate\\(\\)\\)/MutableLiveData<String>\\(DateUtils.getCurrentTime\\(\\).formatDate\\(\\)\\)/g' \"$file\" echo \"Modified: $file\" done)", "Bash(do sed -i '' 's/MutableLiveData\\(DateUtils\\\\.getCurrentTime\\(\\)\\\\.formatDate\\(\\)\\)/MutableLiveData<String>\\(DateUtils.getCurrentTime\\(\\).formatDate\\(\\)\\)/g' \"$file\" echo \"Modified: $file\" done)",

View File

@@ -183,14 +183,16 @@ if (DeviceUtil.isPhone()) viewModel.initPhoneExtras()
手机端首页是 `module_p` 的 `PDAEnterActivity`,「国际」Tab 的菜单在 手机端首页是 `module_p` 的 `PDAEnterActivity`,「国际」Tab 的菜单在
`module_p/.../ui/enter/gj/GjViewModel.kt`(国内在 `gn/GnViewModel.kt`)。 `module_p/.../ui/enter/gj/GjViewModel.kt`(国内在 `gn/GnViewModel.kt`)。
**菜单是手写清单,不是接口下发**;权限串只做过滤(`authList.contains(bean.key)`)。 **菜单是手写清单,不是接口下发**;权限串只做过滤(`authList.contains(bean.key)`)。
2026-10-21 起清单按 `documents/5.5寸首页新样式/` 设计稿分组(`PdaMenuGroup`),新入口加进对应分组。
适配完页面不回来加一行,手机上就**根本没有入口**——出库交接第一版就漏了这步, 适配完页面不回来加一行,手机上就**根本没有入口**——出库交接第一版就漏了这步,
只能靠 adb 直启才能进去。加法: 只能靠 adb 直启才能进去。加法:
```kotlin ```kotlin
// 加在 GjViewModel.groups 的「国际出港」分组里
ActionBean( ActionBean(
"出港移库", "出港移库",
R.drawable.gjc_yi_ku_icon, // 图标复用 Pad 同款,复制 PNG 到 module_p/res/drawable-xxhdpi/ R.drawable.img_pda_menu_gjc_move, // 设计稿宫格图标(icons/ 源图转 PNG 放 drawable-nodpi,放 xxhdpi 会糊);没有新图标的沿用旧图标
Constant.AuthName.GjcYiKuListActivity, // 权限串与 Pad 端菜单一致 Constant.AuthName.GjcYiKuListActivity, // 权限串与 Pad 端菜单一致
ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 填了 route 就走通用跳转,无需 when 分支 ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 填了 route 就走通用跳转,无需 when 分支
), ),

View File

@@ -65,6 +65,9 @@ curl -s -X POST "$URL" -H "$AUTH" -H "Content-Type: application/json" \
- `IntExpMove`:出参无航班字段(缺口 #1)、无操作人姓名(缺口 #2);`opId`/`opdate`/`businessType` - `IntExpMove`:出参无航班字段(缺口 #1)、无操作人姓名(缺口 #2);`opId`/`opdate`/`businessType`
文档有定义但实测恒 null(缺口 #3);`pageQueryTotal` 正常(与 pageQuery 口径一致,`moveState` 过滤生效) 文档有定义但实测恒 null(缺口 #3);`pageQueryTotal` 正常(与 pageQuery 口径一致,`moveState` 过滤生效)
- `typeCode/getSpecialCodeByParam`:`flag=1` 国际,`ieFlag` 必须传空串(传 `"E"` 返回空数组) - `typeCode/getSpecialCodeByParam`:`flag=1` 国际,`ieFlag` 必须传空串(传 `"E"` 返回空数组)
- `typeCode/searchDateType`:**同一路径两个接口**——`GET ?code=xxx` 是通用字典(`Api.getDictList`),
`POST` 无参是「发现时间类型」(`Api.getSearchDateTypeList`,出参 `code` 1~8 + `name` 中英文时机);
国际事故签证 `seachDate` 与 Web 端一致存 **name**(历史数据另有 `2026-09-24` 日期值与带 `\r\n` 的 name)
- `eqm/uld`(ULD信息,6 个端点):查询入参仅 `uld`/`status`/`uldSuffix`(uldSuffix 名义是 - `eqm/uld`(ULD信息,6 个端点):查询入参仅 `uld`/`status`/`uldSuffix`(uldSuffix 名义是
"uld后两位",实际就是**按所属航司筛选**,配 `DictUtils.getIntCarrierList`);`status` 仅两态 "uld后两位",实际就是**按所属航司筛选**,配 `DictUtils.getIntCarrierList`);`status` 仅两态
0 正常/1 故障(三态需求=缺口 #7);无「来源」字段(#8)、无 `checkInDate`/`ifNo` 筛选入参(#9)、 0 正常/1 故障(三态需求=缺口 #7);无「来源」字段(#8)、无 `checkInDate`/`ifNo` 筛选入参(#9)、

View File

@@ -1,49 +1,70 @@
--- ---
name: sync-air-cargo name: sync-air-cargo
description: 将 aerologic-app 项目中未提交的变更(已修改、新增、已删除、包括未跟踪的文件)以文件级 cp/rm 方式同步到同级目录下的 air-cargo 项目——两者是同一份源码的两个独立 git 仓库副本,分别推送到不同的远程地址。只做文件复制/删除,不对内容做任何二次编辑或转换。每当用户提到"同步到 air-cargo"、"同步到另一个仓库/项目"、"推送/复制到 cargo 项目"、"两个项目要保持一致",或者在 aerologic-app 里改完代码后问要不要同步/推送到另一个仓库时,应主动使用此 skill,而不是手动逐个 cp 文件。 description: 将 aerologic-app 的变更以文件级 cp/rm 方式同步到项目根目录下的 air-cargo/ 仓库并在其本地提交(不 push)——两者是同一份源码的两个独立 git 仓库副本,分别推送到不同的远程地址。同步点记录在 air-cargo 提交信息的 Synced-From trailer 中,自动从上次同步点同步到 main 最新提交(覆盖已提交未 push、已 push 的变更),也支持只同步未提交的工作区变更。只做文件复制/删除,不对内容做任何二次编辑或转换。每当用户提到"同步到 air-cargo"、"同步到另一个仓库/项目"、"推送/复制到 cargo 项目"、"两个项目要保持一致"、"把某几个 commit 同步过去",或者在 aerologic-app 里改完代码后问要不要同步/推送到另一个仓库时,应主动使用此 skill,而不是手动逐个 cp 文件。
--- ---
# 同步变更到 air-cargo # 同步变更到 air-cargo
## 背景 ## 背景
`aerologic-app` 和同级目录下的 `air-cargo` 是同一份项目源码的两个独立 git 仓库副本,目录结构完全一致,分别推送到不同的远程仓库地址。日常开发只在 `aerologic-app` 中进行,完成后需要把未提交的变更原样同步一份到 `air-cargo`,再由用户在 `air-cargo` 里自行提交、推送。 `aerologic-app` 和 `air-cargo` 是同一份项目源码的两个独立 git 仓库副本,分别推送到不同的远程仓库地址。日常开发只在 `aerologic-app` 中进行,完成后把变更同步到 `air-cargo` 并在本地提交,**push 由用户自己做**。
同步的判定完全基于 `git status`:在 `aerologic-app` 里,工作区中存在的文件(无论是修改、新增还是未跟踪)就复制覆盖到 `air-cargo` 对应路径;工作区中已不存在的文件(即被删除的)就在 `air-cargo` 里删除对应路径。这样天然覆盖了修改/新增/删除/未跟踪/重命名(重命名会被当作"删除旧路径 + 新增新路径"处理)等所有情况,不需要逐个状态码去分支判断。 - **位置**:`air-cargo` 在 aerologic-app 根目录下(`/Users/tim/Development/Projects/aerologic-app/air-cargo/`),已在 aerologic-app 的 `.gitignore` 中忽略
- **分支映射**(`config` 文件):aerologic-app `main` → air-cargo `feature/hefei`。air-cargo 的 `develop` 是 2023 年的旧快照,不能往上同步;当前分支不是 `feature/hefei` 时脚本直接报错
- **不参与同步**:`CLAUDE.md` / `CLAUDE.local.md`、`.claude/` 目录、根目录 `.claude.*`(如 `.claude.local.md`)与 `.mcp.json`(Claude Code 的配置/记忆),不复制也不删除。air-cargo 中不应存在任何 Claude / Agent 相关文件,其本地 `.git/info/exclude` 已屏蔽这些文件名,防止误提交
- **air-cargo 只是提交通道**:不参与本项目开发,代码扫描、功能实现、查历史时都忽略它(见 CLAUDE.md「air-cargo 目录」一节)
**只同步工程项目本身的文件**:`CLAUDE.md` 以及 `.claude/` 目录(包括其下的 `skills/`、`commands/`、`settings.local.json`、`mcp.json` 等)属于 Claude Code 的工具配置/记忆,两个仓库各自独立维护,不属于"项目源码",因此始终排除在同步范围之外,不会被复制也不会被删除。脚本会在输出里单独列出"已忽略,不参与同步"的清单,方便核对。 ## 同步点:air-cargo 提交信息里的 Synced-From trailer
每次同步提交的信息只有一句带日期的通用语句加一个 trailer,**不写修改明细**:
```
Update 20261012
Synced-From: aerologic-app@388d6058696c05cfd5892018f52120880a440524
```
脚本以 air-cargo 当前分支上最近一个 `Synced-From` 作为基线,把 `基线..main` 的变更同步过去。记录跟着 air-cargo 的提交历史走,只有真正提交了才算数。
- **区间模式(默认)**:基线 = 最近的 `Synced-From`,目标 = aerologic-app `main` 最新提交。**文件内容从目标提交对象中读取**,与 aerologic-app 工作区无关,因此已 commit 未 push、已 push 的变更都用它。提交时写入新的 `Synced-From`
- **工作区模式(`--worktree`)**:同步 aerologic-app 未提交的变更。提交信息**不写** `Synced-From`,因为这份内容不对应任何提交,不能推进同步点。之后这些改动在 aerologic-app 提交后,下次区间同步会再覆盖一次(内容相同,无副作用)
- **兜底:按内容反推**:找不到 `Synced-From` 时(首次同步,或有人手工提交时丢了 trailer),在 main 的第一父链上找与 air-cargo HEAD 内容差异最少的提交作为候选基线。只用于预览,`apply` 必须显式传 `--base`
- **基线校验**:每次预览都会比较 air-cargo HEAD 和基线的内容。正常情况下只剩 air-cargo 自己的 6 个元文件:`.gitignore`(air-cargo 额外忽略 `*.jks`)、`key密码`、`CONTRIBUTING.md`、`备忘录.md`、`国际出港进港权限对照表.xlsx`、`.docs/大写字母数字输入过滤器使用指南.md`。超过 50 个会提示基线或分支可能不对
## 使用方法 ## 使用方法
脚本位于 `scripts/sync.sh`,用法:
```bash ```bash
bash scripts/sync.sh [SOURCE_DIR] [TARGET_DIR] [preview|apply] cd /Users/tim/Development/Projects/aerologic-app
bash .claude/skills/sync-air-cargo/scripts/sync.sh [preview|apply] [--base <ref>] [--to <ref>] [--worktree] [--label <word>]
``` ```
- `SOURCE_DIR` 默认为当前目录(一般就是 aerologic-app 项目根目录) | 参数 | 说明 |
- `TARGET_DIR` 默认为 `SOURCE_DIR` 同级的 `air-cargo` 目录,一般不需要手动指定 |------|------|
- 第三个参数默认 `preview`(只打印清单,不做任何改动),传 `apply` 才会真正执行复制/删除 | `preview` / `apply` | 默认 `preview`,只打印计划;`apply` 复制/删除并在 air-cargo 本地提交 |
| `--base <ref>` | 指定基线:反推结果经确认后传入,或用来纠正错误的同步点 |
| `--to <ref>` | 指定目标提交,默认 `main`(只同步到某个较早的提交时使用) |
| `--worktree` | 工作区模式,不能和 `--base`/`--to` 同时用 |
| `--label <word>` | 提交信息的通用词,默认 `Update`。用户要求时可换成 `Fixed`、`Optimized` 等 |
### 步骤 ### 步骤
1. **先预览**,让用户看到将要发生什么: 1. **预览**:先确定模式——用户要同步已提交的代码用默认区间模式;明确说的是"没提交的改动"才加 `--worktree`。然后执行 `sync.sh`(区间模式不加参数)。
```bash - 输出「无需同步」:直接告诉用户,结束
bash /Users/kid/Development/Fusion/Projects/aerologic-app/.claude/skills/sync-air-cargo/scripts/sync.sh - 基线来源是「按内容反推」:把候选基线连同基线校验结果一起拿给用户确认(反推可能取到比预期更早的提交,例如 air-cargo 从未同步过的环境配置提交),确认后加 `--base <sha>` 再预览一次
``` 2. **用 `AskUserQuestion` 确认**,不要只在文字里问一句。问题描述里要包含:基线 → 目标、区间提交数、待复制/待删除数量(及主要模块分布)、**「⚠ 将覆盖 air-cargo 侧独立修改」清单(逐个列出)**、提交信息。选项至少包含「确认同步并提交」和「取消」。覆盖清单不为空时要特别说明这些文件是 air-cargo 自己改过的(如 `.gitignore` 里的 `*.jks` 忽略),由用户决定。
输出会分别列出"待复制/覆盖"和"待删除"的文件清单。 3. **执行**:把预览时的参数原样带上,`preview` 换成 `apply`。脚本会复制/删除文件、精确暂存这些路径并提交,最后输出提交号和变更统计。
4. **不要 push**。向用户报告提交号,并说明 air-cargo 比 `origin/feature/hefei` 超前几个提交,push 由用户自己做。
2. **用 `AskUserQuestion` 工具向用户确认**,不要只是在文字里问一句让用户打字回复。这是一个会覆盖 `air-cargo` 中已有文件、且删除操作不可逆的操作,必须先把预览清单(待复制、待删除的文件数量和列表)放进问题描述里,让用户能一眼看到影响范围,选项至少包含"确认同步"和"取消"。得到确认后才能继续执行 `apply`(这遵循整体规则:覆盖性/破坏性操作需要用户确认,不要因为 skill 本身能直接跑就跳过确认)。 ### 脚本的保护措施
3. **用户确认后执行同步**: - air-cargo 当前分支不是 `config` 里的 `TARGET_BRANCH` → 报错
```bash - air-cargo 工作区有本次同步范围**以外**的未提交改动 → 报错;范围内的改动(例如上次同步还没提交)允许,重跑会覆盖成相同内容
bash /Users/kid/Development/Fusion/Projects/aerologic-app/.claude/skills/sync-air-cargo/scripts/sync.sh . /Users/kid/Development/Fusion/Projects/air-cargo apply - 基线不是目标的祖先(aerologic-app 被 rebase / force-push 过)→ 报错,用 `--base` 重新指定
``` - 区间内只有不参与同步的文件变化 → 仍然提交一个空提交,推进同步点
执行完会输出"复制/更新 N 个文件,删除 M 个文件"的汇总。
4. **同步之后不要自动 commit/push**。脚本只做文件复制,`air-cargo` 那边的 `git add` / `commit` / `push` 由用户自行决定和操作——两个仓库指向不同的远程地址,提交推送这类会影响远程共享状态的操作应始终由用户主导确认。
## 注意事项 ## 注意事项
- 脚本只识别 git 能感知到的变更(`git status` 看得到的),`.gitignore` 忽略的文件(如构建产物 `build/`、`.gradle/`)不会被同步,这也是符合预期的——不应该把本地构建缓存同步过去。 - 只同步 git 跟踪的内容,`.gitignore` 忽略的构建产物(`build/`、`.gradle/`)不会同步,`air-cargo/` 本身也不在变更清单里
- 如果 `air-cargo` 里对应路径本身也有未提交的本地修改,会被直接覆盖覆盖掉,预览阶段要留意这一点并提醒用户。 - 不要在 air-cargo 里手动提交同步内容。如果确实手动提交了,trailer 格式必须完全一致(`Synced-From: aerologic-app@<完整 sha>`),否则下次只能走反推
- 首个同步点:2026-10-12,air-cargo `3dd792f9`,对应 aerologic-app `388d605`(基线选 `871fa4c`)。跳过的 `dda0ff8`(vfox→mise)随后在 air-cargo 单独提交 `3c76385b` 补上:删除 `.tool-versions` / `.vfox.toml` / `.vfox/`,新增与 aerologic-app 相同的 `mise.toml`。这个提交不带 trailer,同步点不变
- 两个仓库都用 mise 管理 JDK(`mise.toml`:`java = "17"`)。air-cargo 位于 aerologic-app 之下,mise 会逐级读取配置,所以 air-cargo 里不能再出现 `.tool-versions`,否则会覆盖成未安装的旧版本

View File

@@ -0,0 +1,8 @@
# sync-air-cargo 配置,由 scripts/sync.sh 以 shell 变量形式读入
# aerologic-app 的哪个分支同步到 air-cargo 的哪个分支(一对一)
SOURCE_BRANCH=main
TARGET_BRANCH=feature/hefei
# air-cargo 仓库相对 aerologic-app 根目录的位置(已在 aerologic-app 的 .gitignore 中忽略)
TARGET_SUBDIR=air-cargo

View File

@@ -1,98 +1,276 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 将 SOURCE_DIR 中未提交的变更(修改/新增/删除)以 cp/rm 的方式同步到 TARGET_DIR。 # 将 aerologic-app 的变更以文件级 cp/rm 同步到 air-cargo,并在 air-cargo 本地提交(不 push)。
# 两个目录是同一份项目源码的独立 git 仓库副本,只做文件级复制/删除,不做任何内容编辑。 # 两个目录是同一份项目源码的独立 git 仓库副本,只做文件级复制/删除,不做任何内容编辑。
#
# 同步点记录在 air-cargo 提交信息的 trailer「Synced-From: aerologic-app@<sha>」中:
# - 区间模式(默认):基线 = air-cargo 当前分支最近一个 Synced-From,目标 = 源仓库 SOURCE_BRANCH 最新提交。
# 文件内容取自目标提交对象,与源仓库工作区无关;提交时写入新的 Synced-From,推进同步点
# - 工作区模式(--worktree):同步源仓库未提交的变更;提交时不写 Synced-From,不推进同步点
# 没有同步记录时按内容反推基线(目标第一父链上与 air-cargo HEAD 差异最少的提交),仅供确认,apply 须显式 --base
set -euo pipefail set -euo pipefail
export GIT_LITERAL_PATHSPECS=1
SOURCE_DIR="${1:-$(pwd)}" usage() {
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)" cat <<'EOF'
TARGET_DIR="${2:-$(dirname "$SOURCE_DIR")/air-cargo}" 用法: sync.sh [preview|apply] [选项]
MODE="${3:-preview}" # preview | apply preview 只打印同步计划,不做任何改动(默认)
apply 复制/删除文件并在 air-cargo 本地提交(不 push)
选项:
--base <ref> 指定基线:没有 Synced-From 记录(首次同步)或需要纠正时使用
--to <ref> 指定目标提交,默认 config 中的 SOURCE_BRANCH
--worktree 工作区模式:同步源仓库未提交的变更,不推进同步点
--label <word> 提交信息的通用词,默认 Update(可用 Fixed、Optimized 等)
EOF
}
if [ ! -d "$SOURCE_DIR/.git" ]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "错误:$SOURCE_DIR 不是一个 git 仓库根目录" >&2 # shellcheck source=../config
exit 1 . "$SCRIPT_DIR/../config"
fi SOURCE_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
if [ ! -d "$TARGET_DIR" ]; then TARGET_DIR="$SOURCE_DIR/$TARGET_SUBDIR"
echo "错误:目标目录不存在:$TARGET_DIR" >&2 SOURCE_NAME="$(basename "$SOURCE_DIR")"
exit 1
fi
# 只同步工程项目本身的文件,Claude Code 相关的配置/记忆文件(CLAUDE.md、.claude/ 目录, MODE=preview BASE_ARG="" TO_ARG="" WORKTREE=0 LABEL=Update
while [ $# -gt 0 ]; do
case "$1" in
preview|apply) MODE="$1" ;;
--base) BASE_ARG="${2:?--base 缺少参数}"; shift ;;
--to) TO_ARG="${2:?--to 缺少参数}"; shift ;;
--worktree) WORKTREE=1 ;;
--label) LABEL="${2:?--label 缺少参数}"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数:$1" >&2; usage >&2; exit 1 ;;
esac
shift
done
die() { echo "错误:$*" >&2; exit 1; }
S() { git -C "$SOURCE_DIR" "$@"; }
T() { git -C "$TARGET_DIR" "$@"; }
short() { S rev-parse --short "$1"; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# 只同步工程项目本身的文件,Claude Code 相关的配置/记忆文件(CLAUDE.md、CLAUDE.local.md、.claude/ 目录、根目录 .claude.* 与 .mcp.json,
# 包括其中的 skills/commands 等)两个仓库各自独立维护,不参与同步。 # 包括其中的 skills/commands 等)两个仓库各自独立维护,不参与同步。
is_excluded() { is_excluded() {
local path="$1" case "$1" in
case "$path" in .claude|.claude/*|.claude.*|.mcp.json) return 0 ;;
.claude/*|.claude) return 0 ;; CLAUDE.md|*/CLAUDE.md|CLAUDE.local.md|*/CLAUDE.local.md) return 0 ;;
CLAUDE.md|*/CLAUDE.md) return 0 ;;
esac esac
return 1 return 1
} }
# --no-renames:把 rename 拆成"删除旧路径 + 新增新路径"两条记录,避免解析组合格式 # 列出提交中参与同步的文件「路径<TAB>blob」(已排序),用于按内容比对两个仓库
# --untracked-files=all:新增的整个目录也会逐个文件列出,而不是折叠成一行目录 filtered_tree() {
# -z:以 NUL 分隔,规避文件名转义/含空格的问题 git -C "$1" -c core.quotepath=false ls-tree -r "$2" | awk -F'\t' '
copy_list=() $2 ~ /^\.claude($|\/|\.)/ || $2 == ".mcp.json" || $2 ~ /(^|\/)CLAUDE(\.local)?\.md$/ { next }
delete_list=() { split($1, m, " "); print $2 "\t" m[3] }' | LC_ALL=C sort
excluded_list=() }
while IFS= read -r -d '' entry; do # 两份 filtered_tree 输出中内容不同的路径(去重)
status="${entry:0:2}" diff_paths() {
path="${entry:3}" LC_ALL=C comm -3 "$1" "$2" | sed $'s/^\t//' | cut -f1 | LC_ALL=C sort -u
[ -z "$path" ] && continue }
if is_excluded "$path"; then
excluded_list+=("$status|$path") blob_at() { S rev-parse -q --verify "$1:$2" 2>/dev/null || true; }
continue
fi # ---------- 前置检查 ----------
if [ -e "$SOURCE_DIR/$path" ]; then [ -d "$SOURCE_DIR/.git" ] || die "$SOURCE_DIR 不是 git 仓库根目录"
copy_list+=("$status|$path") [ -d "$TARGET_DIR/.git" ] || die "目标仓库不存在:$TARGET_DIR"
cur_branch="$(T branch --show-current)"
[ "$cur_branch" = "$TARGET_BRANCH" ] ||
die "air-cargo 当前分支为「${cur_branch:-游离 HEAD}」,应为「${TARGET_BRANCH}」(见 config),请先与用户确认"
# ---------- 确定基线与目标 ----------
BASE="" BASE_FROM="" TO="" INFERRED=0
if [ "$WORKTREE" -eq 1 ]; then
[ -z "$BASE_ARG$TO_ARG" ] || die "--worktree 不能与 --base / --to 同时使用"
BASE="$(S rev-parse HEAD)"
BASE_FROM="源仓库 HEAD(工作区模式)"
else
TO="$(S rev-parse -q --verify "${TO_ARG:-$SOURCE_BRANCH}^{commit}")" || die "源仓库找不到 ${TO_ARG:-$SOURCE_BRANCH}"
if [ -n "$BASE_ARG" ]; then
BASE="$(S rev-parse -q --verify "$BASE_ARG^{commit}")" || die "源仓库找不到基线 $BASE_ARG"
BASE_FROM="--base 参数"
else else
delete_list+=("$status|$path") record="$(T log -1 --grep='^Synced-From: ' --format='%(trailers:key=Synced-From,valueonly)' | sed -n '1p')"
if [ -n "$record" ]; then
BASE="$(S rev-parse -q --verify "${record##*@}^{commit}")" ||
die "air-cargo 记录的同步点 ${record##*@} 在源仓库中不存在(源仓库被 rebase / force-push 过?),请用 --base 指定"
BASE_FROM="air-cargo 提交记录中的 Synced-From"
fi
fi fi
done < <(git -C "$SOURCE_DIR" status --porcelain=v1 -z --no-renames --untracked-files=all)
echo "源目录: $SOURCE_DIR" filtered_tree "$TARGET_DIR" HEAD > "$TMP/target.tree"
echo "目标目录: $TARGET_DIR" if [ -z "$BASE" ]; then
echo "" # 兜底:按内容反推,在目标的第一父链上找与 air-cargo HEAD 差异最少的提交
echo "=== 待复制/覆盖(${#copy_list[@]})===" best_n=-1
for item in "${copy_list[@]+"${copy_list[@]}"}"; do for c in $(S rev-list --first-parent --max-count=300 "$TO"); do
echo " [${item%%|*}] ${item#*|}" filtered_tree "$SOURCE_DIR" "$c" > "$TMP/cand.tree"
n="$(diff_paths "$TMP/cand.tree" "$TMP/target.tree" | wc -l | tr -d ' ')"
if [ "$best_n" -lt 0 ] || [ "$n" -lt "$best_n" ]; then BASE="$c"; best_n="$n"; fi
[ "$n" -eq 0 ] && break
done
[ -n "$BASE" ] || die "无法反推基线,请用 --base 指定"
INFERRED=1
BASE_FROM="按内容反推(air-cargo 无 Synced-From 记录),需确认后以 --base 传入"
fi
S merge-base --is-ancestor "$BASE" "$TO" || die "基线 $(short "$BASE") 不是目标 $(short "$TO") 的祖先"
fi
# ---------- 计算变更清单 ----------
copy_list=() delete_list=() excluded_list=()
classify() { # $1=状态 $2=路径 $3=同步后文件是否应存在(1/0)
[ -z "$2" ] && return
if is_excluded "$2"; then excluded_list+=("$1|$2")
elif [ "$3" -eq 1 ]; then copy_list+=("$1|$2")
else delete_list+=("$1|$2")
fi
}
if [ "$WORKTREE" -eq 1 ]; then
# --no-renames:rename 拆成「删除旧路径 + 新增新路径」;--untracked-files=all:新目录逐个文件列出
while IFS= read -r -d '' entry; do
p="${entry:3}"
if [ -e "$SOURCE_DIR/$p" ] || [ -L "$SOURCE_DIR/$p" ]; then classify "${entry:0:2}" "$p" 1; else classify "${entry:0:2}" "$p" 0; fi
done < <(S status --porcelain=v1 -z --no-renames --untracked-files=all)
else
# git diff -z 的 name-status 输出为「状态\0路径\0」交替
while IFS= read -r -d '' st && IFS= read -r -d '' p; do
if [ "$st" = D ]; then classify "$st" "$p" 0; else classify "$st" "$p" 1; fi
done < <(S diff --name-status --no-renames -z "$BASE" "$TO")
fi
new_blob() { # 同步后该路径应有的内容
if [ "$WORKTREE" -eq 1 ]; then S hash-object -- "$1"; else blob_at "$TO" "$1"; fi
}
# 三方校验:air-cargo 现有内容既不等于基线、也不等于同步后的内容,说明 air-cargo 侧有独立修改,会被覆盖
conflict_list=()
for item in ${copy_list[@]+"${copy_list[@]}"} ${delete_list[@]+"${delete_list[@]}"}; do
st="${item%%|*}" p="${item#*|}"
[ -f "$TARGET_DIR/$p" ] || continue
tb="$(T hash-object -- "$p")"
[ "$tb" = "$(blob_at "$BASE" "$p")" ] && continue
[ "$st" != D ] && [ "$tb" = "$(new_blob "$p")" ] && continue
conflict_list+=("$p")
done done
echo "" # air-cargo 工作区只允许存在本次同步范围内的改动(如上次同步未提交,重跑会覆盖为相同内容)
echo "=== 待删除(${#delete_list[@]})===" for item in ${copy_list[@]+"${copy_list[@]}"} ${delete_list[@]+"${delete_list[@]}"}; do
for item in "${delete_list[@]+"${delete_list[@]}"}"; do printf '%s\n' "${item#*|}"
echo " [${item%%|*}] ${item#*|}" done | LC_ALL=C sort -u > "$TMP/synced"
done T status --porcelain=v1 -z --no-renames --untracked-files=all | tr '\0' '\n' | cut -c4- | LC_ALL=C sort -u > "$TMP/dirty"
unrelated="$(LC_ALL=C comm -23 "$TMP/dirty" "$TMP/synced")"
echo "" # ---------- 提交信息 ----------
echo "=== 已忽略,不参与同步(${#excluded_list[@]})===" printf '%s %s\n' "$LABEL" "$(date +%Y%m%d)" > "$TMP/msg"
for item in "${excluded_list[@]+"${excluded_list[@]}"}"; do [ "$WORKTREE" -eq 1 ] || printf '\nSynced-From: %s@%s\n' "$SOURCE_NAME" "$TO" >> "$TMP/msg"
echo " [${item%%|*}] ${item#*|}"
done
if [ "$MODE" != "apply" ]; then # ---------- 打印计划 ----------
print_list() { # $1=标题 其余=「状态|路径」
local title="$1"; shift
echo "" echo ""
echo "(预览模式,未执行任何操作。确认无误后加 apply 参数执行同步)" echo "=== ${title}($#)==="
for item in "$@"; do echo " [${item%%|*}] ${item#*|}"; done
}
echo "源仓库: $SOURCE_DIR"
echo "目标仓库: ${TARGET_DIR}(分支 ${TARGET_BRANCH})"
if [ "$WORKTREE" -eq 1 ]; then
echo "模式: 工作区模式(同步未提交变更,不推进同步点)"
else
echo "模式: 区间模式"
echo "基线: $(short "$BASE") $(S log -1 --format=%s "$BASE")"
echo " 来源:$BASE_FROM"
echo "目标: $(short "$TO") $(S log -1 --format=%s "$TO")(${TO_ARG:-$SOURCE_BRANCH})"
echo "区间提交数: $(S rev-list --count "$BASE..$TO")"
[ "$(S rev-parse HEAD)" = "$TO" ] || echo "提示: 源仓库 HEAD 不在目标提交上,同步内容以目标提交为准"
# 内容校验:同步点记录正确时,air-cargo HEAD 与基线应只有少量元文件差异
filtered_tree "$SOURCE_DIR" "$BASE" > "$TMP/base.tree"
diff_paths "$TMP/base.tree" "$TMP/target.tree" > "$TMP/verify"
vn="$(wc -l < "$TMP/verify" | tr -d ' ')"
echo ""
echo "=== 基线校验:air-cargo HEAD 与基线有 $vn 个文件内容不同 ==="
sed -n '1,20s/^/ /p' "$TMP/verify"
[ "$vn" -le 20 ] || echo " ……(其余 $((vn - 20)) 个省略)"
[ "$vn" -le 50 ] || echo " ⚠ 差异较大:基线可能不对,或 air-cargo 分支落后/不对,请先核实"
fi
if [ "$WORKTREE" -eq 0 ] && [ "$BASE" = "$TO" ]; then
echo ""
echo "air-cargo 已同步到 $(short "$TO"),无需同步。"
exit 0 exit 0
fi fi
copied=0 print_list "待复制/覆盖" ${copy_list[@]+"${copy_list[@]}"}
deleted=0 print_list "待删除" ${delete_list[@]+"${delete_list[@]}"}
print_list "已忽略,不参与同步" ${excluded_list[@]+"${excluded_list[@]}"}
echo ""
echo "=== ⚠ 将覆盖 air-cargo 侧独立修改(${#conflict_list[@]})==="
for p in ${conflict_list[@]+"${conflict_list[@]}"}; do echo " $p"; done
if [ -n "$unrelated" ]; then
echo ""
echo "=== ✖ air-cargo 工作区存在与本次同步无关的未提交改动 ==="
printf '%s\n' "$unrelated" | sed 's/^/ /'
fi
echo ""
echo "=== 提交信息 ==="
sed 's/^/ /' "$TMP/msg"
for item in "${copy_list[@]+"${copy_list[@]}"}"; do if [ "$MODE" != apply ]; then
path="${item#*|}" echo ""
mkdir -p "$(dirname "$TARGET_DIR/$path")" echo "(预览模式,未执行任何操作)"
cp -f "$SOURCE_DIR/$path" "$TARGET_DIR/$path" exit 0
copied=$((copied+1)) fi
done
for item in "${delete_list[@]+"${delete_list[@]}"}"; do # ---------- 执行 ----------
path="${item#*|}" [ "$INFERRED" -eq 0 ] || die "基线为反推结果,确认无误后请用 --base $(short "$BASE") 重新执行"
if [ -e "$TARGET_DIR/$path" ]; then [ -z "$unrelated" ] || die "air-cargo 工作区存在与本次同步无关的未提交改动,请先处理"
rm -f "$TARGET_DIR/$path"
deleted=$((deleted+1)) copied=0 deleted=0
stage_list=()
for item in ${copy_list[@]+"${copy_list[@]}"}; do
p="${item#*|}" dst="$TARGET_DIR/${item#*|}"
mkdir -p "$(dirname "$dst")"
rm -f "$dst"
if [ "$WORKTREE" -eq 1 ]; then
cp -f "$SOURCE_DIR/$p" "$dst"
else
mode="$(S ls-tree "$TO" -- "$p" | cut -d' ' -f1)"
case "$mode" in
120000) ln -s "$(S cat-file blob "$TO:$p")" "$dst" ;;
160000) echo "跳过子模块:$p" >&2; continue ;;
*) S cat-file blob "$TO:$p" > "$dst"; [ "$mode" != 100755 ] || chmod +x "$dst" ;;
esac
fi fi
stage_list+=("$p")
copied=$((copied + 1))
done done
for item in ${delete_list[@]+"${delete_list[@]}"}; do
p="${item#*|}"
if [ -e "$TARGET_DIR/$p" ] || [ -L "$TARGET_DIR/$p" ]; then
rm -f "$TARGET_DIR/$p"
deleted=$((deleted + 1))
fi
# 只暂存 air-cargo 中已跟踪的路径,否则 git add 会因 pathspec 不匹配而失败
! T ls-files --error-unmatch -- "$p" > /dev/null 2>&1 || stage_list+=("$p")
done
[ ${#stage_list[@]} -eq 0 ] || printf '%s\0' "${stage_list[@]}" | T add -A --pathspec-from-file=- --pathspec-file-nul
echo "" echo ""
echo "同步完成:复制/更新 $copied 个文件,删除 $deleted 个文件。" echo "同步完成:复制/更新 $copied 个文件,删除 $deleted 个文件。"
if T diff --cached --quiet; then
if [ "$WORKTREE" -eq 1 ]; then
echo "air-cargo 内容已一致,无需提交。"
exit 0
fi
# 区间内只有不参与同步的文件变化时,仍提交一个空提交推进同步点
T commit -q --allow-empty -F "$TMP/msg"
else
T commit -q -F "$TMP/msg"
fi
echo "已在 air-cargo 本地提交(未 push):$(T log -1 --format='%h %s')"
T show --stat --format= HEAD | tail -1

View File

@@ -0,0 +1,83 @@
---
name: zentao-bug
description: 读取禅道(njlkairport.chandao.net,开源版 18.8)中的 Bug 单——我的 Bug 列表、单个 Bug 的标题/描述/截图/评论/修改历史/附件——并按 AirLogistics 项目规范定位代码完成修复,也可在禅道上解决、指派 Bug(如转给后端并附问题说明)。禅道 18.8 没有 API 2.0,官方 zentao-cli / MCP 不可用,必须用本 skill 的 zentao.py。当用户提到"禅道"、"bug 单"、"我的 bug"、"修 bug"、"处理 bug"、给出 Bug 编号(如 "#632"、"bug 632")或禅道链接(bug-view-xxx.html / my-work-bug.html)时使用。
---
# 禅道 Bug 读取与修复
## 工具
```bash
Z=.claude/skills/zentao-bug/zentao.py
python3 $Z whoami # 当前账号 + 会话是否有效
python3 $Z list # 指派给我的 Bug(= 网页「地盘 → 我的Bug」)
python3 $Z list --status active # 只看激活的
python3 $Z list --type resolvedBy # 由我解决 / openedBy / closedBy
python3 $Z list --json # 结构化输出
python3 $Z view 632 # Bug 详情(Markdown)
```
`view` 输出并保存到 `~/.cache/zentao-lite/bugs/<id>/bug.md`:基本信息、重现步骤、附件、历史记录与评论(评论以 `>` 引用块呈现)。
描述和评论中的截图下载到 `images/`,文中以绝对路径 `![图N](/Users/.../images/1154.jpeg)` 引用。
**截图是需求的主要载体**(团队习惯把原型/标注贴在描述和评论里,几乎不用附件),**必须逐张用 Read 查看**,不能只看文字。
## 登录(只能由用户本人执行)
退出码 2 表示未登录或会话过期。禅道会话默认空闲 24 分钟失效。让用户在自己的终端执行:
```bash
python3 .claude/skills/zentao-bug/zentao.py login --account <账号>
```
必须在**独立的终端窗口**里执行——Claude Code 的 `! <命令>` 没有 TTY,密码提示会直接 EOFError。
密码交互输入、不回显;默认存入 macOS 钥匙串,此后会话过期会自动重新登录(`--no-keychain` 则不保存)。
**不要向用户索要密码,也不要替用户在命令行里带密码登录。**
## 修复流程
1. **读单**:`view <id>`,Read 全部截图;评论里常有后续补充或改口,以最新评论为准。
2. **定位页面**:Bug 标题/描述通常写的是菜单名(如"出港移库""进港仓库")。按 CLAUDE.md「页面定位规则」,
以首页菜单实际跳转的 Activity 为准(平板 `app/.../HomeFragment.kt`,手机 `module_p` 的 `GnViewModel`/`GjViewModel`)。
3. **判断端**:截图是手机竖屏(如 5.5 寸设计稿样式)还是平板横屏,决定改 `res/layout/` 还是 `res/layout-sw600dp/`;
业务逻辑改动两端都要检查(CLAUDE.md「业务以平板为准」)。截图提到"原型"时对照 `documents/5.5寸手持端设计文件/`。
4. **修改并验证**:遵守 CLAUDE.md 的 UI 规范;`./gradlew assembleDebug` 编译通过;能在模拟器验证的就验证并截图比对。
5. **汇报**:按 Bug 逐条说明改了什么、改在哪里、如何验证、有无未覆盖项。多个 Bug 时每个 Bug 单独提交,
提交信息带 `Bug #<id>`。不确定截图意图时先问用户,禁止臆测。
## 写回禅道
```bash
python3 $Z resolve 621 622 --comment 已解决 --dry-run # 先预览
python3 $Z resolve 621 622 --comment 已解决 # 提交
```
等同网页「解决」表单,默认:解决方案 `fixed`(已解决)、解决版本 `trunk`(主干);**解决日期与指派给取网页表单渲染出的默认值**
(服务器当前时间 / 创建人,可用 `--date` / `--assign-to` 覆盖),其余字段不动;备注默认留空,团队习惯只写「已解决」,不写业务细节。
只处理激活状态的单子,提交后回读校验状态。
- **写操作会改动线上状态,只在用户明确要求时执行**;只解决确实修好、且没有待确认疑问的单子。
无缺陷 / 需后端修复 / 有疑问的保持激活,并向用户列出原因(无缺陷应选「设计如此」等其他方案,不要标「已解决」)
- 接口提交**不会**自动补表单默认值,所以先 GET 解决表单 HTML 读出 `resolvedDate` 与 `assignedTo` 的选中项再原样提交;
解决日期不能用本机时间:本机可能在美西时区,禅道服务器是北京时间(#621 曾因此写成本机时间)
### 指派(转给后端等)
```bash
python3 $Z assign 627 --to 陶雨佳 --comment-file /path/c627.txt --dry-run # 先预览
python3 $Z assign 627 --to 陶雨佳 --comment-file /path/c627.txt # 提交
```
等同网页「指派」表单:`--to` 接受账号(如 `TYJ`)或姓名,状态保持激活,提交后回读校验指派人。
需后端修复的单子转给后端时,备注照搬 `documents/后端接口对接问题清单.xlsx` 对应行
(接口地址 / 问题类型 / 问题描述 / 期望调整 / 前端现状),并注明清单序号;多行备注用 `--comment-file`,换行会转成 `<br />`。
## 实现说明
- 认证:`POST /api.php/v1/tokens` 换 Token;数据走页面 JSON 模式(`.html` → `.json`),Cookie `zentaosid=<Token>`。
- 接口:`/my-work-bug-<type>-0-id_desc-0-<perPage>-<page>.json`、`/bug-view-<id>.json`、`/file-read-<fileID>.<ext>`;
解决:GET `/bug-resolve-<id>.json` 取版本/用户选项,POST `/bug-resolve-<id>.html?onlybody=yes` 表单
(resolution/resolvedBuild/resolvedDate/assignedTo/status=resolved/comment);
指派:GET `/bug-assignTo-<id>.json` 取用户,POST `/bug-assignTo-<id>.html?onlybody=yes` 表单(assignedTo/status/mailto[]/comment)。
- REST v1 的 `/products/<id>/bugs` 只返回激活中的 Bug,不能替代"我的 Bug"页面。
- 凭据:Token 在 `~/.config/zentao-lite/config.json`(0600),密码只在钥匙串(服务名 `zentao-lite`)。`logout` 一并清除。

View File

@@ -0,0 +1,678 @@
#!/usr/bin/env python3
"""禅道 18.x Bug 读取工具(仅依赖 Python 标准库)。
禅道 18.8 没有 RESTful API 2.0,官方 zentao-cli 无法使用;本脚本组合两条 18.x 可用的通道:
- 认证:POST /api.php/v1/tokens 换取 Token(即会话 ID)
- 数据:页面 JSON 模式(把页面地址 .html 换成 .json),Cookie 带 zentaosid=<Token>
- 我的 Bug:/my-work-bug-<type>-0-id_desc-0-<perPage>-<page>.json
- Bug 详情:/bug-view-<id>.json(含描述、修改记录、评论)
- 图片:/file-read-<fileID>.<ext>
凭据:Token 存 ~/.config/zentao-lite/config.json(0600);密码只存 macOS 钥匙串,
用于会话过期(禅道默认 24 分钟空闲失效)后自动重新登录,任何文件里都不落明文密码。
"""
import argparse
import base64
import getpass
import html
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
from pathlib import Path
CONFIG_DIR = Path(os.environ.get("ZENTAO_LITE_HOME", Path.home() / ".config" / "zentao-lite"))
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_OUT = Path.home() / ".cache" / "zentao-lite" / "bugs"
KEYCHAIN_SERVICE = "zentao-lite"
DEFAULT_URL = "https://njlkairport.chandao.net"
STATUS = {"active": "激活", "resolved": "已解决", "closed": "已关闭"}
TYPES = {
"codeerror": "代码错误", "config": "配置相关", "install": "安装部署", "security": "安全相关",
"performance": "性能问题", "standard": "标准规范", "automation": "测试脚本",
"designdefect": "设计缺陷", "others": "其他",
}
RESOLUTIONS = {
"fixed": "已解决", "bydesign": "设计如此", "duplicate": "重复Bug", "external": "外部原因",
"notrepro": "无法重现", "postponed": "延期处理", "willnotfix": "不予解决", "tostory": "转为需求",
}
ACTIONS = {
"opened": "创建", "edited": "编辑", "commented": "备注", "resolved": "解决", "activated": "激活",
"closed": "关闭", "assigned": "指派", "confirmed": "确认", "bugconfirmed": "确认",
"tostory": "转需求", "totask": "转任务", "linked2build": "关联版本", "linked2release": "关联发布",
}
FIELDS = {
"steps": "重现步骤", "title": "标题", "assignedTo": "指派给", "status": "状态", "severity": "严重程度",
"pri": "优先级", "type": "类型", "resolution": "解决方案", "resolvedBuild": "解决版本",
"module": "模块", "deadline": "截止日期", "keywords": "关键词", "openedBuild": "影响版本",
"resolvedBy": "解决者", "resolvedDate": "解决日期", "closedBy": "关闭者", "closedDate": "关闭日期",
"confirmed": "是否确认", "mailto": "抄送给", "linkBug": "相关Bug", "activatedCount": "激活次数",
}
USER_FIELDS = {"assignedTo", "resolvedBy", "closedBy", "openedBy"}
BUG_TYPES = ("assignedTo", "openedBy", "resolvedBy", "closedBy")
class AuthError(Exception):
pass
def die(msg, code=1):
print(f"错误:{msg}", file=sys.stderr)
sys.exit(code)
# ---------------------------------------------------------------- 配置与凭据
def load_config():
if not CONFIG_FILE.exists():
die("尚未登录,请先执行:python3 .claude/skills/zentao-bug/zentao.py login", 2)
return json.loads(CONFIG_FILE.read_text())
def save_config(cfg):
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = CONFIG_FILE.with_suffix(".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
os.replace(tmp, CONFIG_FILE)
def keychain_get(account):
if sys.platform != "darwin":
return None
r = subprocess.run(
["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"],
capture_output=True, text=True,
)
return r.stdout.rstrip("\n") if r.returncode == 0 else None
def keychain_set(account, password):
if sys.platform != "darwin":
return False
r = subprocess.run(
["security", "add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", password],
capture_output=True, text=True,
)
return r.returncode == 0
def keychain_delete(account):
if sys.platform == "darwin":
subprocess.run(["security", "delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account],
capture_output=True)
def request_token(url, account, password):
body = json.dumps({"account": account, "password": password}).encode()
req = urllib.request.Request(url + "/api.php/v1/tokens", data=body,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
except urllib.error.HTTPError as e:
try:
data = json.load(e)
except Exception:
data = {"error": f"HTTP {e.code}"}
if not data.get("token"):
raise AuthError(data.get("error") or "登录失败")
return data["token"]
# ---------------------------------------------------------------- 请求
class Client:
def __init__(self, cfg):
self.cfg = cfg
self.url = cfg["url"].rstrip("/")
self.relogged = False
def _relogin(self):
if self.relogged:
raise AuthError("重新登录后仍无权限")
password = keychain_get(self.cfg["account"])
if not password:
raise AuthError("会话已过期,且钥匙串中没有保存密码,请重新执行 login")
self.cfg["token"] = request_token(self.url, self.cfg["account"], password)
save_config(self.cfg)
self.relogged = True
def _open(self, path):
req = urllib.request.Request(self.url + path, headers={
"Cookie": f"zentaosid={self.cfg['token']}", "Token": self.cfg["token"],
})
try:
return urllib.request.urlopen(req, timeout=60)
except urllib.error.HTTPError as e:
if e.code == 401:
raise AuthError("Token 无效")
raise
def page(self, path):
"""读取页面 JSON;会话失效时自动用钥匙串密码重登一次。"""
for _ in range(2):
try:
with self._open(path) as resp:
outer = json.load(resp)
data = outer.get("data")
if isinstance(data, str):
data = json.loads(data)
if outer.get("status") != "success" or (isinstance(data, dict) and "user-login" in str(data.get("locate", ""))):
raise AuthError("会话已过期")
return data
except AuthError:
self._relogin()
raise AuthError("无法获取数据")
def post_form(self, path, fields):
"""提交页面表单(与网页点击「保存」相同);返回响应 HTML(禅道用 <script>alert/parent 跳转回执)。"""
body = urllib.parse.urlencode(fields).encode()
for _ in range(2):
req = urllib.request.Request(self.url + path, data=body, headers={
"Cookie": f"zentaosid={self.cfg['token']}", "Token": self.cfg["token"],
"Content-Type": "application/x-www-form-urlencoded",
"Referer": self.url + path,
})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
text = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
if e.code != 401:
raise
text = "user-login"
if "user-login" not in text:
return text
self._relogin()
raise AuthError("无法提交表单")
def download(self, path, dest):
for _ in range(2):
try:
with self._open(path) as resp:
ctype = resp.headers.get("Content-Type", "")
content = resp.read()
if "text/html" in ctype or "application/json" in ctype:
if b"user-login" in content:
raise AuthError("会话已过期")
raise RuntimeError(f"返回的不是文件内容({ctype})")
dest.write_bytes(content)
return
except AuthError:
self._relogin()
# ---------------------------------------------------------------- HTML → Markdown
class Html2Md(HTMLParser):
BLOCK = {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "table", "blockquote", "pre"}
def __init__(self, on_image):
super().__init__(convert_charrefs=True)
self.out = []
self.lists = []
self.on_image = on_image
self.href = None
self.skip = 0
def _nl(self, n=1):
text = "".join(self.out)
have = len(text) - len(text.rstrip("\n"))
if text.strip():
self.out.append("\n" * max(0, n - have))
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag in ("style", "script"):
self.skip += 1
elif tag in self.BLOCK:
self._nl(2 if tag != "tr" else 1)
if tag[0] == "h" and tag[1:].isdigit():
self.out.append("**") # 标题降级为加粗,避免与外层 Markdown 标题层级混淆
elif tag == "br":
self.out.append("\n")
elif tag in ("ul", "ol"):
self._nl(1)
self.lists.append([tag, 0])
elif tag == "li":
self._nl(1)
indent = " " * max(0, len(self.lists) - 1)
if self.lists and self.lists[-1][0] == "ol":
self.lists[-1][1] += 1
self.out.append(f"{indent}{self.lists[-1][1]}. ")
else:
self.out.append(f"{indent}- ")
elif tag in ("strong", "b"):
self.out.append("**")
elif tag == "td" or tag == "th":
self.out.append("| ")
elif tag == "a":
self.href = a.get("href")
self.out.append("[")
elif tag == "img":
src = a.get("src") or a.get("data-src") or ""
if src:
self.out.append(self.on_image(src))
def handle_endtag(self, tag):
if tag in ("style", "script"):
self.skip = max(0, self.skip - 1)
elif tag in self.BLOCK:
if tag[0] == "h" and tag[1:].isdigit():
self.out.append("**")
self._nl(2 if tag != "tr" else 1)
elif tag in ("ul", "ol"):
if self.lists:
self.lists.pop()
self._nl(2)
elif tag in ("strong", "b"):
self.out.append("**")
elif tag in ("td", "th"):
self.out.append(" ")
elif tag == "a":
self.out.append(f"]({self.href})" if self.href else "]")
self.href = None
def handle_data(self, data):
if not self.skip:
self.out.append(re.sub(r"[ \t\r\n]+", " ", data) if not data.isspace() else (" " if self.out and not self.out[-1].endswith(("\n", " ")) else ""))
def markdown(self):
text = "".join(self.out).replace("****", "")
text = "\n".join(line.rstrip() for line in text.splitlines())
return re.sub(r"\n{3,}", "\n\n", text).strip()
class ImageFetcher:
"""把富文本中的图片下载到本地,返回 Markdown 图片引用(绝对路径,可直接用 Read 查看)。"""
def __init__(self, client, img_dir):
self.client = client
self.dir = img_dir
self.files = []
self.errors = []
def __call__(self, src):
src = html.unescape(src.strip())
m = re.fullmatch(r"\{(\d+)\.(\w+)\}", src) # 数据库原始占位符 {1154.jpeg}
if m:
src = f"/file-read-{m.group(1)}.{m.group(2)}"
try:
dest = self._fetch(src)
except Exception as e: # 单张图片失败不影响整体
self.errors.append(f"{src}:{e}")
return f"![图片下载失败]({src})"
if dest not in self.files:
self.files.append(dest)
return f"![图{self.files.index(dest) + 1}]({dest})"
def _fetch(self, src):
self.dir.mkdir(parents=True, exist_ok=True)
if src.startswith("data:"):
head, b64 = src.split(",", 1)
ext = re.search(r"image/(\w+)", head)
dest = self.dir / f"inline-{len(self.files) + 1}.{ext.group(1) if ext else 'png'}"
dest.write_bytes(base64.b64decode(b64))
return dest
parsed = urllib.parse.urlparse(src)
if parsed.netloc and parsed.netloc not in self.client.url:
dest = self.dir / Path(parsed.path).name
if not dest.exists():
urllib.request.urlretrieve(src, dest)
return dest
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
m = re.search(r"file-read-(\d+)(?:\.(\w+))?", path)
name = f"{m.group(1)}.{m.group(2) or 'bin'}" if m else Path(parsed.path).name
dest = self.dir / name
if not dest.exists() or dest.stat().st_size == 0:
self.client.download(path, dest)
return dest
# ---------------------------------------------------------------- 命令
def cmd_login(args):
url = (args.url or os.environ.get("ZENTAO_URL") or DEFAULT_URL).rstrip("/")
account = args.account or os.environ.get("ZENTAO_ACCOUNT") or input("禅道账号:").strip()
password = os.environ.get("ZENTAO_PASSWORD") or getpass.getpass("禅道密码(输入不回显):")
try:
token = request_token(url, account, password)
except AuthError as e:
die(f"登录失败:{e}")
cfg = {"url": url, "account": account, "token": token}
save_config(cfg)
saved = False if args.no_keychain else keychain_set(account, password)
print(f"登录成功:{account} @ {url}")
print(f"Token 已保存到 {CONFIG_FILE}(权限 0600)")
print("密码已存入 macOS 钥匙串,会话过期时自动重新登录" if saved
else "未保存密码:会话过期(默认 24 分钟空闲)后需重新 login")
def cmd_logout(args):
if CONFIG_FILE.exists():
keychain_delete(json.loads(CONFIG_FILE.read_text()).get("account", ""))
CONFIG_FILE.unlink()
print("已退出并清除本地 Token 与钥匙串密码")
def cmd_whoami(args):
client = Client(load_config())
data = client.page("/my-work-bug-assignedTo-0-id_desc-0-1-1.json") # 顺带验证会话可用
users = data.get("users", {})
account = client.cfg["account"]
print(f"{account}({users.get(account, '')}) @ {client.url},会话有效")
def fetch_bug_list(client, bug_type, per_page=100):
bugs, page, users = [], 1, {}
while True:
data = client.page(f"/my-work-bug-{bug_type}-0-id_desc-0-{per_page}-{page}.json")
users.update(data.get("users") or {})
bugs.extend(data.get("bugs") or [])
pager = data.get("pager") or {}
if page >= int(pager.get("pageTotal") or 1):
return bugs, users
page += 1
def cmd_list(args):
client = Client(load_config())
bugs, users = fetch_bug_list(client, args.type)
if args.status != "all":
bugs = [b for b in bugs if b.get("status") == args.status]
if args.json:
print(json.dumps([{
"id": int(b["id"]), "title": html.unescape(b["title"]), "status": b["status"],
"severity": int(b["severity"]), "pri": int(b["pri"]), "type": b["type"],
"product": b.get("productName"), "openedBy": b["openedBy"], "openedDate": b["openedDate"],
"assignedTo": b["assignedTo"], "deadline": b.get("deadline"),
} for b in bugs], ensure_ascii=False, indent=2))
return
title = {"assignedTo": "指派给我", "openedBy": "由我创建", "resolvedBy": "由我解决", "closedBy": "由我关闭"}[args.type]
print(f"# 我的 Bug({title},状态:{args.status})共 {len(bugs)} 条\n")
print("| ID | 严重 | 优先级 | 状态 | 类型 | 标题 | 创建人 | 创建时间 |")
print("|---|---|---|---|---|---|---|---|")
for b in bugs:
t = html.unescape(b["title"]).replace("|", "\\|")
print(f"| {b['id']} | {b['severity']} | {b['pri']} | {STATUS.get(b['status'], b['status'])} | "
f"{TYPES.get(b['type'], b['type'])} | {t} | {users.get(b['openedBy'], b['openedBy'])} | {b['openedDate'][:16]} |")
def cmd_view(args):
client = Client(load_config())
data = client.page(f"/bug-view-{args.id}.json")
bug = data.get("bug")
if not bug:
die(f"Bug #{args.id} 不存在或无权查看")
users = data.get("users") or {}
who = lambda a: f"{users.get(a, a)}({a})" if a and users.get(a) and users.get(a) != a else (a or "-")
def show(field, value):
if not value:
return "空"
if field in USER_FIELDS:
return who(value)
if field == "mailto":
return "、".join(who(v) for v in value.split(",") if v) or "空"
if field == "confirmed":
return "是" if value == "1" else "否"
return {"status": STATUS, "resolution": RESOLUTIONS, "type": TYPES}.get(field, {}).get(value, value)
out_dir = Path(args.out or DEFAULT_OUT) / str(args.id)
fetcher = ImageFetcher(client, out_dir / "images")
def md(html_text):
p = Html2Md(fetcher)
p.feed(html_text or "")
p.close()
return p.markdown()
module = " / ".join(m.get("name", "") for m in (data.get("modulePath") or [])) or "/"
product = (data.get("product") or {}).get("name") or (data.get("products") or {}).get(bug.get("product"), "")
lines = [
f"# Bug #{bug['id']} {html.unescape(bug['title'])}", "",
f"- 链接:{client.url}/bug-view-{bug['id']}.html",
f"- 产品 / 模块:{product} / {module}",
f"- 状态:{STATUS.get(bug['status'], bug['status'])}"
+ (f"({RESOLUTIONS.get(bug['resolution'], bug['resolution'])})" if bug.get("resolution") else "")
+ f" 确认:{'是' if bug.get('confirmed') == '1' else '否'}",
f"- 严重程度:{bug['severity']} 优先级:{bug['pri']} 类型:{TYPES.get(bug['type'], bug['type'])}",
f"- 创建:{who(bug['openedBy'])} {bug['openedDate']} 影响版本:{bug.get('openedBuild', '')}",
f"- 指派给:{who(bug['assignedTo'])} {bug.get('assignedDate', '')}",
]
if bug.get("resolvedBy"):
lines.append(f"- 解决:{who(bug['resolvedBy'])} {bug.get('resolvedDate', '')} 解决版本:{bug.get('resolvedBuild', '')}")
if bug.get("deadline") and not bug["deadline"].startswith("0000"):
lines.append(f"- 截止日期:{bug['deadline']}")
if bug.get("keywords"):
lines.append(f"- 关键词:{bug['keywords']}")
lines += ["", "## 重现步骤", "", md(bug.get("steps")) or "(空)"]
files = list((bug.get("files") or {}).values()) if isinstance(bug.get("files"), dict) else (bug.get("files") or [])
if files:
lines += ["", "## 附件", ""]
att_dir = out_dir / "attachments"
att_dir.mkdir(parents=True, exist_ok=True)
for f in files:
name = f"{f['id']}-{f.get('title', 'file')}"
if not name.lower().endswith("." + f.get("extension", "").lower()):
name += "." + f.get("extension", "bin")
dest = att_dir / re.sub(r"[/\\]", "_", name)
try:
if not dest.exists():
client.download(f"/file-read-{f['id']}.{f.get('extension', '')}", dest)
lines.append(f"- [{f.get('title')}]({dest})({f.get('size', '?')} 字节)")
except Exception as e:
lines.append(f"- {f.get('title')}:下载失败({e})")
actions = sorted((data.get("actions") or {}).values(), key=lambda a: (a.get("date", ""), int(a.get("id", 0))))
comment_count = sum(1 for a in actions if (a.get("comment") or "").strip())
lines += ["", f"## 历史记录与评论({len(actions)} 条,含评论 {comment_count} 条)", ""]
for a in actions:
name = ACTIONS.get(a.get("action", ""), a.get("action", ""))
extra = a.get("extra") or ""
if a.get("action") == "resolved" and extra:
extra = RESOLUTIONS.get(extra.split(",")[0], extra)
elif a.get("action") == "assigned" and extra:
extra = f"→ {who(extra)}"
lines.append(f"### {a.get('date', '')} {who(a.get('actor'))} {name}{(' ' + extra) if extra else ''}")
for h in a.get("history") or []:
field = FIELDS.get(h.get("field"), h.get("field"))
if h.get("field") == "steps":
lines.append(f"- 修改了 {field}(以上方当前内容为准)")
else:
lines.append(f"- 修改了 {field}:{show(h.get('field'), h.get('old'))} → {show(h.get('field'), h.get('new'))}")
if (a.get("comment") or "").strip():
lines += ["", "**评论:**", ""] + [f"> {l}" if l else ">" for l in md(a["comment"]).splitlines()]
lines.append("")
if fetcher.files:
lines += ["## 本地图片", ""] + [f"- 图{i + 1}:{p}" for i, p in enumerate(fetcher.files)] + [""]
if fetcher.errors:
lines += ["## 图片下载失败", ""] + [f"- {e}" for e in fetcher.errors] + [""]
text = "\n".join(lines).rstrip() + "\n"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "bug.md").write_text(text)
if args.json:
bug["_images"] = [str(p) for p in fetcher.files]
bug["_actions"] = actions
print(json.dumps(bug, ensure_ascii=False, indent=2))
else:
print(text)
print(f"(已保存到 {out_dir / 'bug.md'},图片 {len(fetcher.files)} 张)")
def form_defaults(client, bug_id):
"""读取网页「解决」表单渲染出的默认值:解决日期(服务器时间,北京时间)与指派给(禅道默认选中创建人)。
接口提交不会自动补默认值,必须先取表单再原样带上;不能用本机时间,本机可能在其他时区。"""
with client._open(f"/bug-resolve-{bug_id}.html?onlybody=yes") as resp:
page = resp.read().decode("utf-8", "replace")
date = re.search(r"name='resolvedDate'[^>]*value='([^']+)'", page)
select = re.search(r"<select name='assignedTo'.*?</select>", page, re.S)
assigned = select and re.search(r"<option value='([^']*)' selected", select.group(0))
if not date or not assigned or not assigned.group(1):
die(f"#{bug_id} 未能从解决表单读取默认的解决日期 / 指派给,可用 --date / --assign-to 指定")
return date.group(1), assigned.group(1)
def cmd_resolve(args):
"""解决 Bug:等同网页「解决」表单。默认解决方案=已解决、解决版本=主干(trunk)、
解决日期与指派给取网页表单默认值(服务器时间 / 创建人);只处理激活状态的单子,提交后回读校验。"""
client = Client(load_config())
failed = 0
for bug_id in args.ids:
form = client.page(f"/bug-resolve-{bug_id}.json")
bug = (form or {}).get("bug")
if not bug:
print(f"#{bug_id} 跳过:不存在或无权查看")
failed += 1
continue
title = html.unescape(bug["title"])
if bug["status"] != "active":
print(f"#{bug_id} 跳过:当前状态为「{STATUS.get(bug['status'], bug['status'])}」,只解决激活的 Bug({title})")
continue
builds = form.get("builds") or {}
if args.build not in builds:
die(f"解决版本 {args.build} 不在可选列表中:{', '.join(k for k in builds if k)}")
default_date, default_assigned = form_defaults(client, bug_id)
assigned = args.assign_to or default_assigned
fields = {
"resolution": args.resolution, "duplicateBug": "", "buildExecution": "",
"resolvedBuild": args.build, "buildName": "",
"resolvedDate": args.date or default_date,
"assignedTo": assigned, "status": "resolved", "comment": args.comment or "",
}
desc = (f"#{bug_id} {title} → {RESOLUTIONS.get(args.resolution, args.resolution)},"
f"版本 {builds[args.build]},指派给 {(form.get('users') or {}).get(assigned, assigned)}")
if args.dry_run:
print(f"[dry-run] {desc}")
continue
resp = client.post_form(f"/bug-resolve-{bug_id}.html?onlybody=yes", fields)
after = client.page(f"/bug-view-{bug_id}.json").get("bug") or {}
if after.get("status") == "resolved":
print(f"已解决 {desc}")
else:
alerts = re.findall(r"alert\((['\"])(.*?)\1\)", resp)
print(f"#{bug_id} 失败:提交后状态仍为 {after.get('status')}"
+ (f",禅道提示:{'; '.join(html.unescape(a[1]) for a in alerts)}" if alerts else ""))
failed += 1
if failed:
sys.exit(1)
def resolve_user(users, who):
"""按账号或姓名定位用户:先精确匹配账号,再按「姓名」匹配(禅道显示名形如 T:陶雨佳)。"""
if who in users:
return who
hits = [k for k, v in users.items() if k and v.split(":", 1)[-1] == who]
if len(hits) == 1:
return hits[0]
die(f"找不到唯一的用户「{who}」" + (f",候选:{', '.join(hits)}" if hits else ""))
def comment_html(text):
"""备注是富文本,纯文本换行在网页上会被合并,逐行转义后用 <br /> 连接。"""
return "<br />".join(html.escape(line) for line in text.splitlines())
def cmd_assign(args):
"""指派 Bug:等同网页「指派」表单(指派给 + 备注),状态保持激活;提交后回读校验指派人。"""
client = Client(load_config())
comment = args.comment
if args.comment_file:
comment = Path(args.comment_file).read_text(encoding="utf-8").strip()
failed = 0
for bug_id in args.ids:
form = client.page(f"/bug-assignTo-{bug_id}.json")
bug = (form or {}).get("bug")
if not bug:
print(f"#{bug_id} 跳过:不存在或无权查看")
failed += 1
continue
users = form.get("users") or {}
account = resolve_user(users, args.to)
title = html.unescape(bug["title"])
desc = f"#{bug_id} {title} → 指派给 {users.get(account, account)}"
if args.dry_run:
print(f"[dry-run] {desc}")
if comment:
print(" 备注:\n " + comment.replace("\n", "\n "))
continue
fields = {"assignedTo": account, "status": bug["status"], "mailto[]": "",
"comment": comment_html(comment) if comment else ""}
resp = client.post_form(f"/bug-assignTo-{bug_id}.html?onlybody=yes", fields)
after = client.page(f"/bug-view-{bug_id}.json").get("bug") or {}
if after.get("assignedTo") == account:
print(f"已指派 {desc}")
else:
alerts = re.findall(r"alert\((['\"])(.*?)\1\)", resp)
print(f"#{bug_id} 失败:提交后指派人仍为 {after.get('assignedTo')}"
+ (f",禅道提示:{'; '.join(html.unescape(a[1]) for a in alerts)}" if alerts else ""))
failed += 1
if failed:
sys.exit(1)
def main():
ap = argparse.ArgumentParser(description="禅道 18.x Bug 读取工具")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("login", help="登录并保存 Token(密码存 macOS 钥匙串)")
p.add_argument("--url", help=f"禅道地址,默认 {DEFAULT_URL}")
p.add_argument("--account", help="账号")
p.add_argument("--no-keychain", action="store_true", help="不在钥匙串保存密码")
p.set_defaults(func=cmd_login)
sub.add_parser("logout", help="清除 Token 与钥匙串密码").set_defaults(func=cmd_logout)
sub.add_parser("whoami", help="查看当前账号并验证会话").set_defaults(func=cmd_whoami)
p = sub.add_parser("list", help="我的 Bug 列表")
p.add_argument("--type", choices=BUG_TYPES, default="assignedTo", help="默认 assignedTo(指派给我)")
p.add_argument("--status", choices=("active", "resolved", "closed", "all"), default="all")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_list)
p = sub.add_parser("view", help="Bug 详情(描述、图片、历史、评论、附件)")
p.add_argument("id", type=int)
p.add_argument("--out", help=f"输出目录,默认 {DEFAULT_OUT}")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_view)
p = sub.add_parser("resolve", help="解决 Bug(写操作,会改动禅道线上状态)")
p.add_argument("ids", type=int, nargs="+")
p.add_argument("--resolution", choices=tuple(RESOLUTIONS), default="fixed", help="默认 fixed(已解决)")
p.add_argument("--build", default="trunk", help="解决版本,默认 trunk(主干)")
p.add_argument("--assign-to", help="指派给(账号),默认取表单默认值(即创建人)")
p.add_argument("--date", help="解决日期 YYYY-MM-DD HH:MM:SS,默认取服务器当前时间")
p.add_argument("--comment", default="", help="备注,默认留空")
p.add_argument("--dry-run", action="store_true", help="只打印将要做的修改,不提交")
p.set_defaults(func=cmd_resolve)
p = sub.add_parser("assign", help="指派 Bug 并附备注(写操作,会改动禅道线上状态)")
p.add_argument("ids", type=int, nargs="+")
p.add_argument("--to", required=True, help="指派给:账号(如 TYJ)或姓名(如 陶雨佳)")
p.add_argument("--comment", default="", help="备注,多行用换行分隔")
p.add_argument("--comment-file", help="从文件读取备注(多行内容推荐用这个)")
p.add_argument("--dry-run", action="store_true", help="只打印将要做的修改,不提交")
p.set_defaults(func=cmd_assign)
args = ap.parse_args()
try:
args.func(args)
except AuthError as e:
die(f"{e}。请执行:python3 .claude/skills/zentao-bug/zentao.py login", 2)
except urllib.error.URLError as e:
die(f"无法访问禅道:{e}")
if __name__ == "__main__":
main()

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# Created by https://www.toptal.com/developers/gitignore/api/androidstudio,gradle,java,kotlin # Created by https://www.toptal.com/developers/gitignore/api/androidstudio,gradle,java,kotlin
# Edit at https://www.toptal.com/developers/gitignore?templates=androidstudio,gradle,java,kotlin # Edit at https://www.toptal.com/developers/gitignore?templates=androidstudio,gradle,java,kotlin
air-cargo/
### Java ### ### Java ###
# Compiled class file # Compiled class file
*.class *.class

View File

@@ -8,6 +8,30 @@
## [Unreleased] ## [Unreleased]
### 变更 (Changed) ### 变更 (Changed)
- 【5.5 寸首页改版 · 2026-10-21】手持端首页 `PDAEnterActivity` 四个 Tab 按 `documents/5.5寸首页新样式/` 设计稿
1:1 还原(仅 module_p,平板不经过该页,零影响):
① 国内/国际 Tab 改为「分组标题栏 + 三列宫格」(新增 `PdaMenuGroup` / `PdaMenuRenderer`),分组与顺序按设计稿,
20 个入口换设计稿新图标(源 GIF/PNG 统一转 PNG 放 drawable-nodpi——放 xxhdpi 实测解码后再放大发糊),
设计稿未画的入口追加到组尾沿用旧图标;
权限过滤 `authList.contains(key)` 与改版前逐字一致——ADMIN 账号改版前后入口集合 diff 为空(国内 9、国际 12),
21 个入口逐个点击跳转目标与原映射一致。
② 首页航班动态改为卡片 + 国内/国际、进港/出港分段 + 日期卡片(‹ › 前后翻日)+ 浅色表格 + 右下设置悬浮钮;
默认方向按设计稿改为「进港」;日期改为补零格式(原选择器会写出 `2026-10-5`),选择器从当前选中日期打开。
③ 底部 Tab 栏、消息铃铛与角标、状态栏(#3B71E8)按设计稿重做(Tab 图标 16dp 并与文字水平居中,
不照搬设计稿偏左上的 16px 图形),「我的」顶栏同步新导航栏样式;分段控件 padding 计入 1dp 描边以上下居中;消息铃铛四个 Tab 统一按国内/国际设计稿位置(首页设计稿略有偏差),切 Tab 不再移动;
首页 AutoSize 基准由 400 改为设计稿画布 390。删除 module_p 中不再引用的旧菜单/Tab 图标副本 19 个。
- 【手机适配分支合入主干 · 2026-10-11】`feature/phone-ui-adaptation` 合并入 `main`,此后平板与 5.5 寸手机只在
`main` 上同时开发。合并以平板业务为准:
① 主干 58 个平板提交(运抵海关放行/运抵状态筛选/海关回执、出库交接装机状态/允许装机、出港移库详情与侧滑编辑、
开始计重托盘信息与运抵重量自动计算、计重明细重量/托盘自重联动、出入港仓库清仓备注与入/出库件数重量、
进港查询出库状态筛选、ULD 来源/入库时间/所在港/位置等)全部同步到手机布局;冲突的平板布局三方合并进
`layout-sw600dp/`,ViewModel/Bean 冲突取平板业务逻辑。
② 新增手机变体:出库/入库/清仓/配运/删除申报弹框、出港移库详情编辑页(`IntExpMoveEditActivity`,替代手机专属
`IntExpMoveDetailActivity`)、共用海关回执页 `CustomsResponseActivity`;`PhoneBottomBar` 增次操作按钮。
③ 手机端对齐平板:移除平板没有的查询入参(`likeNo`、进港移库 fdate/fno/spCode、出港移库 spCode)与改写的默认值
(计重/计重记录/提取出库默认日期),补齐平板表单与筛选项(运抵分单号、进港查询出库状态、计重记录航班号、
进港移库承运人、开始计重运单号/地磅重量/平板车/航班可编辑)与操作(运抵删除申报、计重记录运抵申报、计重新增);
ULD 表单移除平板没有的进港航班号与额外必填校验。
- 【后端反馈联调 · 2026-10-09】后端在《问题清单》补「解决方案/备注」后,逐项(#1~#34)api-doc 核对 + - 【后端反馈联调 · 2026-10-09】后端在《问题清单》补「解决方案/备注」后,逐项(#1~#34)api-doc 核对 +
内网直调 A/B + AeroLogic_Phone 模拟器端到端验证,并按后端意见调整前端: 内网直调 A/B + AeroLogic_Phone 模拟器端到端验证,并按后端意见调整前端:
① 后端已补字段/接口 → 前端对接:出港移库 flight/businessName/opUserName;出库交接 uldNumber 角标; ① 后端已补字段/接口 → 前端对接:出港移库 flight/businessName/opUserName;出库交接 uldNumber 角标;

146
CLAUDE.md
View File

@@ -5,8 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概况 ## 项目概况
**项目名称**: AirLogistics - 航空物流信息管理系统 **项目名称**: AirLogistics - 航空物流信息管理系统
**架构模式**: MVVM + 组件化 | **语言**: Kotlin 1.6.21 + Java | **版本**: 1.8.4 (versionCode 84) **架构模式**: MVVM + 组件化 | **语言**: Kotlin 1.6.21 + Java | **版本**: 1.8.7 (versionCode 87)
**SDK**: minSdk 24 / targetSdk 30 / compileSdk 31 | **Gradle**: 7.3.3 | **JDK**: 1.8 **SDK**: minSdk 24 / targetSdk 30 / compileSdk 31 | **Gradle**: 7.3.3 | **JDK**: 17(`mise.toml`,运行 Gradle 用);编译目标 Java 1.8(`sourceCompatibility` / `jvmTarget`)
### 核心架构 ### 核心架构
@@ -51,6 +51,15 @@ aerologic-app/
└── 其他业务模块... └── 其他业务模块...
``` ```
### ⛔ `air-cargo/` 目录:一律忽略
根目录下的 `air-cargo/` 是同一份源码的另一个独立 git 仓库(远程地址不同,工作分支 `feature/hefei`),**只用于把本项目代码提交到另一个仓库**,不参与本项目任何开发;它已在 `.gitignore` 中忽略。
- 代码扫描、搜索、实现功能、排查问题、查 git 历史时**一律跳过该目录**,不读取、不修改、不当作参考实现(其中文件是同步过去的副本,可能落后于主干)
- `find` / `grep -r` 这类不认 `.gitignore` 的命令要显式排除(如 `find . -path ./air-cargo -prune -o …`、`grep -r --exclude-dir=air-cargo`);`git grep`、`rg` 默认已跳过
- 唯一允许操作它的场景是 `sync-air-cargo` skill(同步并在其本地提交,不 push)
- 其中不应存在任何 Claude / Agent 相关文件(`CLAUDE.md`、`.claude/`、`.mcp.json` 等),sync 脚本和 air-cargo 本地的 `.git/info/exclude` 都已屏蔽
--- ---
## 6 种典型页面类型 ## 6 种典型页面类型
@@ -383,7 +392,7 @@ fun toggleAllExpand() {
} }
``` ```
4. **子列表项 checkbox 样式**(必须使用 `_style` 系列,禁止使用 `_gray` 系列): 4. **子列表项 checkbox 样式**(必须复用底部“全选”的 `img_check_all_*` 资源,高度 20dp):
```xml ```xml
<!-- 子列表项 item_xxx_sub.xml 中的 iv_checkbox --> <!-- 子列表项 item_xxx_sub.xml 中的 iv_checkbox -->
<ImageView <ImageView
@@ -392,54 +401,61 @@ fun toggleAllExpand() {
android:layout_height="20dp" android:layout_height="20dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_weight="0.5" android:layout_weight="0.5"
loadImage="@{bean.checked.get() ? @drawable/radiobtn_checked_style : @drawable/radiobtn_unchecked_style}" loadImage="@{bean.checked.get() ? @drawable/img_check_all_checked : @drawable/img_check_all_unchecked}"
android:src="@drawable/radiobtn_unchecked_style" /> android:src="@drawable/img_check_all_unchecked" />
``` ```
| 资源 | 含义 | 外观 | | 资源 | 含义 | 外观 |
|------|------|------| |------|------|------|
| `radiobtn_checked_style` | 选中 | colorPrimary 蓝色实心圆 + 白色内环 | | `img_check_all_checked` | 选中 | `#1C8CF5` 蓝色实心圆 + 白色对勾(与底部“全选”相同)|
| `radiobtn_unchecked_style` | 未选中 | 透明 + 黑色边框圆 | | `img_check_all_unchecked` | 未选中 | `#707070` 灰色细圆环(与底部“全选”相同)|
| ~~`radiobtn_checked_style` / `radiobtn_unchecked_style`~~ | ❌ 已废弃 | 旧版分单复选框,不再使用 |
| ~~`radiobtn_checked_gray`~~ | ❌ 禁用 | 灰色实心圆(错误样式) | | ~~`radiobtn_checked_gray`~~ | ❌ 禁用 | 灰色实心圆(错误样式) |
#### 含子列表的列表项 UI 规范(以 `item_int_exp_storage_use.xml` 为基准) #### 列表项 UI 规范(以 `item_int_exp_arrive / assemble / load / tally.xml` 四个基准页为准)
> 应用主题未覆盖 `textViewStyle`,未显式写 `textSize`/`textColor` 的 TextView 会落到系统默认(14sp、偏灰的次级文字色)。因此列表项里**每个** Key/Value TextView 都必须显式写 `textSize` 和 `textColor`,不能依赖“默认继承”。
**主列表项卡片**: **主列表项卡片**:
| 部位 | 属性 | 标准值 | | 部位 | 属性 | 标准值 |
|------|------|--------| |------|------|--------|
| 外层容器 | marginHorizontal / marginTop | 10dp / 10dp | | 外层容器 | marginHorizontal / marginTop | 10dp / 10dp(禁止旧写法 `marginHorizontal=15dp` + `marginVertical=5dp`)|
| 卡片背景 | background | `@drawable/bg_white_radius_8` | | 卡片背景 | background | `@drawable/bg_white_radius_8`(`bg_item` 已废弃,不再引用)|
| 内容区 | padding | **10dp** | | 内容区 | paddingHorizontal / paddingVertical / gravity | **10dp / 20dp** / `center_vertical` |
| 选中图标 | 尺寸 / marginEnd / marginTop | 40×40dp / 10dp / **0.5px**(像素级对齐)| | 选中图标 | 尺寸 / marginTop | 40×40dp / **0.5px**(像素级对齐),不设 marginEnd |
| 内容列(图标右侧的竖向 LinearLayout)| layout_marginLeft | **15dp** |
| 选中图标 | 切换资源 | `img_plane_s`(选中)/ `img_plane`(未选中)| | 选中图标 | 切换资源 | `img_plane_s`(选中)/ `img_plane`(未选中)|
| KV 文字 | textSize | **15sp**(Key 和 Value 均需显式设置)| | KV 文字 | textSize / textColor | **15sp** / **`@color/text_normal`**(Key 和 Value 均需显式设置)|
| 首要字段值(运单号)| textColor | `@color/colorPrimary` | | 首要字段值(运单号/ULD编码)| textColor / textStyle | `@color/colorPrimary` / **`bold`** |
| 其他字段 | textColor | 无需设置(继承默认 text_normal)| | 状态类字段值(带颜色表达式)| textSize | **14sp** + 颜色表达式(如 `text_green / colorPrimary / text_gray`)|
| 两行间距 | layout_marginTop | 10dp | | 两行间距 | layout_marginTop | 10dp |
| 右侧箭头(有详情页时)| `img_pda_right` | 30×30dp,`layout_marginLeft=10dp` |
**展开/折叠按钮(`iv_show`)**: **展开/折叠按钮(`iv_show`)**:
| 属性 | 标准值 | | 属性 | 标准值 |
|------|--------| |------|--------|
| layout_width | `match_parent` | | layout_width | `match_parent` |
| layout_height | **10dp** | | layout_height | **20dp** |
| layout_marginTop | **-10dp**(向上收紧,紧贴卡片底边)| | layout_marginTop | **-20dp**(向上收紧,紧贴卡片底边)|
| scaleType | **centerInside**(保证箭头不被压扁/截断)| | scaleType | **center** |
| src | `@mipmap/img_down` | | src | `@mipmap/img_down` |
| 显示控制 | `visible="@{bean.subList != null && !bean.subList.empty}"` | | 显示控制 | `visible="@{bean.subList != null && !bean.subList.empty}"` |
| 不设 padding,不设 layout_marginBottom | — | | 不设 padding,不设 layout_marginBottom | — |
> 旧版本(18dp + padding=4dp + marginBottom=5dp)已统一替换为上述新标准,参考 `item_int_exp_arrive.xml`。组装类列表若需展开后翻转箭头,附加 `android:rotation="@{bean.showMore.get() ? 180f : 0f}"`(参考 `item_int_exp_assemble.xml`)。 > 旧版本(18dp + padding=4dp + marginBottom=5dp,以及 10dp/-10dp/centerInside)已统一替换为上述新标准,参考 `item_int_exp_arrive.xml`。组装类列表若需展开后翻转箭头,附加 `android:rotation="@{bean.showMore.get() ? 180f : 0f}"`(参考 `item_int_exp_assemble.xml`)。
**子列表区域**: **子列表区域**:
- 容器:`layout_marginTop="5dp"`,`background="#e3f6e0"` - 容器:`layout_marginTop="10dp"`,`background="@drawable/bg_white_radius_8"`(旧的 `#e3f6e0` 绿色容器已废弃)
- 表头行:`layout_marginVertical="10dp"`,`paddingHorizontal="10dp"` - 空提示:`textSize="14sp"`,`textColor="@color/text_gray"`,`paddingVertical="15dp"`
- 表头行:`background="@drawable/bg_arrive_sub_header"`,`paddingHorizontal="10dp"`,`paddingVertical="10dp"`
- 表头文字:`textSize="14sp"`,`textColor="@color/text_normal"`,`textStyle="bold"`,`gravity="center"` - 表头文字:`textSize="14sp"`,`textColor="@color/text_normal"`,`textStyle="bold"`,`gravity="center"`
- 表头下方分隔线:`MaterialDivider` 高度 1px,`background="@color/c999999"` - 表头下方分隔线:`MaterialDivider` 高度 1px,`dividerColor="@color/sub_list_divider"`
- 子列表项 padding:`paddingHorizontal="10dp"`,`paddingVertical="8dp"` - 子列表项:`layout_marginVertical="8dp"`,`paddingHorizontal="10dp"`
- 子列表文字:`textSize="14sp"`,`textColor="@color/text_normal"`,`gravity="center"`,`layout_gravity="center_vertical"` - 子列表文字:`textSize="14sp"`,`textColor="@color/text_normal"`,`gravity="center"`,`layout_gravity="center_vertical"`;首要列(运单号/分单号)`@color/colorPrimary` + `bold`;状态列 `13sp` + 颜色表达式
- 详情页里的表格式列表(如查询详情的库位/仓库 Tab)同样 14sp + `text_normal`,禁止 `@android:color/black`
**子列表复选框(关键)**: **子列表复选框(关键)**:
@@ -449,11 +465,11 @@ fun toggleAllExpand() {
android:layout_height="20dp" android:layout_height="20dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_weight="0.5" android:layout_weight="0.5"
loadImage="@{bean.checked.get() ? @drawable/radiobtn_checked_style : @drawable/radiobtn_unchecked_style}" loadImage="@{bean.checked.get() ? @drawable/img_check_all_checked : @drawable/img_check_all_unchecked}"
android:src="@drawable/radiobtn_unchecked_style" /> android:src="@drawable/img_check_all_unchecked" />
``` ```
> `loadImage` 和 `android:src` **均须**使用 `_style` 系列,**禁止**使用 `_gray` 系列(参见上方复选框样式表) > `loadImage` 和 `android:src` **均须**使用 `img_check_all_checked`/`img_check_all_unchecked`(与底部“全选”同源),高度固定 **20dp**;**禁止**再使用 `radiobtn_*_style`/`radiobtn_*_gray`(参见上方复选框样式表)
**参考文件**: `module_gjc/.../IntExpStorageUseActivity.kt`、`IntExpStorageUseViewModel.kt` **参考文件**: `module_gjc/.../IntExpStorageUseActivity.kt`、`IntExpStorageUseViewModel.kt`
@@ -843,6 +859,11 @@ adb logcat | grep "com.lukouguoji.aerologic" # 日志
项目需同时支持 **10 寸平板横屏** 与 **5.5 寸手机竖屏**。核心原则:**业务逻辑层完全复用,只重写布局**。 项目需同时支持 **10 寸平板横屏** 与 **5.5 寸手机竖屏**。核心原则:**业务逻辑层完全复用,只重写布局**。
> **分支约定(2026-10-11 起)**:手机适配分支 `feature/phone-ui-adaptation` 已合并入 `main`,此后平板与手机**只在 `main` 上同时开发**,不再区分分支。合并时的状态、验证覆盖与待决事项见 `documents/双端合入主干交接文档.md`。
> **业务以平板为准**:主流程交互、表单/筛选项、接口入参一律与平板一致,手机端只做 UI 适配——平板底部批量按钮在手机上可改为卡片级操作或 `PhoneBottomBar` 次按钮(`subAction1Text/subAction2Text`),
> 但不得新增平板没有的查询入参(如 `likeNo`)、不得改写平板的默认查询值(如默认当天日期)、不得少平板的表单项。
> 平板新增/修改功能时,必须同步检查对应手机布局与该页全部弹框的手机变体。
### 核心机制:布局按资源限定符自动切换 ### 核心机制:布局按资源限定符自动切换
``` ```
@@ -991,9 +1012,11 @@ Manifest 写死 `userLandscape` 时,即使 `BaseActivity` 在 `super.onCreate`
| 国内 | `GnViewModel` | `module_p/.../ui/enter/gn/` | | 国内 | `GnViewModel` | `module_p/.../ui/enter/gn/` |
| 国际 | `GjViewModel` | `module_p/.../ui/enter/gj/` | | 国际 | `GjViewModel` | `module_p/.../ui/enter/gj/` |
**菜单项是手写清单,不是接口下发**。权限机制只做过滤:登录返回的权限串存在 **菜单项是手写清单,不是接口下发**。登录接口只下发「分组 → 权限码」(`auths`,形如
`SharedPreferenceUtil.getString(Constant.Share.authList)`(一个逗号分隔的长字符串), `{"IntExp":["AppIntExpCheckWeighing",…],"Supervision":[…]}`,存在
菜单项按 `authList.contains(bean.key)` 决定显不显示。 `SharedPreferenceUtil.getString(Constant.Share.authList)`),菜单名称/图标/跳转在平板和手机上都是前端写死的映射。
手机端菜单项按 `authList.contains(bean.key)` 决定显不显示(`PdaMenuGroup.kt` 的 `filterByAuth()`,
**2026-10-21 首页改版时刻意保持与改版前逐字一致**,保证同一账号的入口集合不变)。
所以「适配完页面但手机首页看不到入口」的原因通常**不是没权限,而是清单里没加**。 所以「适配完页面但手机首页看不到入口」的原因通常**不是没权限,而是清单里没加**。
排查顺序:先确认权限串里有对应 key,再去看清单。 排查顺序:先确认权限串里有对应 key,再去看清单。
@@ -1004,25 +1027,43 @@ adb -s <serial> shell "run-as com.lukouguoji.aerologic \
cat /data/data/com.lukouguoji.aerologic/shared_prefs/data.xml" | tr ',' '\n' | grep -oE "App[A-Za-z]+" | sort -u cat /data/data/com.lukouguoji.aerologic/shared_prefs/data.xml" | tr ',' '\n' | grep -oE "App[A-Za-z]+" | sort -u
``` ```
加一项就是加一行 —— `ActionBean` 的第 4 个参数填 ARouter 路由即可,无需写 when 分支 **2026-10-21 起首页按 `documents/5.5寸首页新样式/` 设计稿改版**:清单按设计稿分组
(`PdaMenuGroup(标题, 入口列表)`,如国际 Tab =「国际出港 / 国际进港 / 综合管理」),
由 `PdaMenuRenderer` 渲染成「分组标题栏 + 三列宫格」,过滤后为空的分组整组隐藏。
**分组口径以设计稿为准,不是接口的 auths 分组**(例如单证交接/转运记录在接口里属 Supervision,
设计稿放在「国内出港」)。
加一项就是在对应分组里加一行 —— `ActionBean` 的第 4 个参数填 ARouter 路由即可,无需写 when 分支
(`module_p` 只依赖 `module_base`,看不到 `module_gjc` 等业务模块,跨模块只能走路由): (`module_p` 只依赖 `module_base`,看不到 `module_gjc` 等业务模块,跨模块只能走路由):
```kotlin ```kotlin
ActionBean( PdaMenuGroup(
"出港移库", "国际出港", listOf(
R.drawable.gjc_yi_ku_icon, // 图标复用 Pad 端同款 ActionBean(
Constant.AuthName.GjcYiKuListActivity, // 权限串,与 Pad 端菜单保持一致 "出港移库",
ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 有 route 就走通用跳转 R.drawable.img_pda_menu_gjc_move, // 设计稿新版宫格图标
Constant.AuthName.GjcYiKuListActivity, // 权限串,与 Pad 端菜单保持一致
ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 有 route 就走通用跳转
),
…
)
), ),
``` ```
两点注意: 注意:
- **权限串与路由都要和 Pad 端菜单对齐**:以 `app/.../HomeFragment.kt` 里该权限对应的 - **权限串与路由都要和 Pad 端菜单对齐**:以 `app/.../HomeFragment.kt` 里该权限对应的
`ARouter.build(...)` 为准。同一业务常有新旧两个 Activity(如移库的 `GjcYiKuListActivity` `ARouter.build(...)` 为准。同一业务常有新旧两个 Activity(如移库的 `GjcYiKuListActivity`
与 `IntExpMoveActivity`),Pad 菜单实际跳的才是要适配的那个 与 `IntExpMoveActivity`),Pad 菜单实际跳的才是要适配的那个
- **图标**:`module_p` 看不到 `module_gjc` 的资源,把 Pad 图标 PNG 复制一份到 - **图标**:设计稿图标源文件在 `documents/5.5寸首页新样式/icons/`(约 167px 方图,自带「国际/国内」角标),
`module_p/src/main/res/drawable-xxhdpi/`(该模块既有 4 个图标就是这么放的,同名不同密度桶可共存) 统一转 PNG(源文件多为单帧 GIF,GIF 只有 256 色 + 1 位透明,不进资源)后放
`module_p/src/main/res/drawable-nodpi/img_pda_menu_*.png`,按 59.7dp 宫格居中裁剪 + 圆角显示。
**必须放 nodpi,不能放 xxhdpi**:167px 放 xxhdpi 会在 ~295dpi 设备上解码时先缩到 ≈103px,再被拉伸到
宫格的 110px,实测明显发糊;nodpi 原尺寸解码、由固定尺寸 ImageView 一次缩小,与浏览器渲染设计稿一致
**设计稿没有的入口追加到所属分组末尾、沿用旧图标**(如「板箱过磅」`gjc_fu_bang_icon`)
- **改动清单后核对入口集合**:登录后分别在「国内 / 国际」Tab 用 `ui_probe.sh texts`(需滚动)导出入口名,
与改动前 `LC_ALL=C sort` 后 diff(CJK 文本不加 `LC_ALL=C` 排序不稳定,会 diff 出假差异)。
ADMIN 测试账号基线:国内 9 个、国际 12 个
**示例:状态 Tab 零 ViewModel 改动**(`IntExpMoveActivity` 已实现) **示例:状态 Tab 零 ViewModel 改动**(`IntExpMoveActivity` 已实现)
@@ -1111,7 +1152,8 @@ override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) =
- ✅ **已完成**:适配框架 + 手机端公共资源 + 7 个手机版公共组件 - ✅ **已完成**:适配框架 + 手机端公共资源 + 7 个手机版公共组件
- ✅ **已完成页面**: - ✅ **已完成页面**:
- 「国际出港移库」`IntExpMoveActivity` + 「运单详情」`IntExpMoveDetailActivity`(框架参考页) - 「国际出港移库」`IntExpMoveActivity`(框架参考页);详情/编辑与平板共用 `IntExpMoveEditActivity`
(手机布局为设计稿「运单详情」样式,卡片「查看详情」/「编辑」分别对应平板点击卡片/侧滑编辑)
- 「国际出港出库交接」`IntExpOutHandoverActivity` + 「板箱详情」`IntExpOutHandoverDetailActivity` - 「国际出港出库交接」`IntExpOutHandoverActivity` + 「板箱详情」`IntExpOutHandoverDetailActivity`
(公共组件首个完整落地页,双端实机验证通过) (公共组件首个完整落地页,双端实机验证通过)
- 「ULD管理」`UldListActivity` + `UldEditActivity`(app 模块首个适配页;一套编辑布局覆盖 - 「ULD管理」`UldListActivity` + `UldEditActivity`(app 模块首个适配页;一套编辑布局覆盖
@@ -1120,14 +1162,14 @@ override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) =
FAB 进记录)、`GjcWeighingStartActivity`(开始计重,托盘数量/自重按设计稿手输)、 FAB 进记录)、`GjcWeighingStartActivity`(开始计重,托盘数量/自重按设计稿手输)、
`GjcWeighingRecordListActivity`(计重记录)、`GjcWeighingRecordDetailsActivity`(计重明细, `GjcWeighingRecordListActivity`(计重记录)、`GjcWeighingRecordDetailsActivity`(计重明细,
isEditMode 复用为手机全屏「修改记录」覆层)+ 手机专属新页 `GjcWeighingWbDetailActivity`(运单详情)。 isEditMode 复用为手机全屏「修改记录」覆层)+ 手机专属新页 `GjcWeighingWbDetailActivity`(运单详情)。
搜索分流规则(实测):含字母→fno、11 位数字→wbNo、其余→likeNo(likeNo 按 8 位 no 匹配并非模糊); 合并搜索框只分流到平板同名入参:含字母→fno、其余→wbNo;
checkIn 过滤等 7 项后端缺口见问题清单 #12~#18 checkIn 过滤等 7 项后端缺口见问题清单 #12~#18
- 「出港运抵」主列表 + 3 个手机专属新页:`IntExpArriveActivity`(待运抵/已运抵 Tab,客户端按 - 「出港运抵」主列表 + 3 个手机专属新页:`IntExpArriveActivity`(待运抵/已运抵 Tab,客户端按
`declareStatus` 是否为空拆分数据集,卡片长按浮层:待运抵→补充、已运抵→重置+回执)、 `declareStatus` 是否为空拆分数据集,卡片长按浮层仅已运抵:重置 / 删除申报 / 回执)、
`IntExpArriveSubOrderActivity`(分单明细,数据直接复用主列表已加载的 `haWbList`,零新增请求)、 `IntExpArriveSubOrderActivity`(分单明细,数据直接复用主列表已加载的 `haWbList`,零新增请求)、
`IntExpArriveReceiptActivity`(回执内容只读页,复用 `haWbList[].response`)、 `IntExpArriveReceiptActivity`(分单回执只读页,复用 `haWbList[].response`;主单「回执」与平板侧滑同走
`IntExpArriveSupplementActivity`(补充信息,参照 Pad 端国际进港「收发货人信息」批量应用模式, `getResponse` → 共用 `CustomsResponseActivity`)。
后端接口不存在,实机 404 已确认,见问题清单 #19)。 (原手机专属「补充信息」页 `IntExpArriveSupplementActivity` 因后端无接口(#19)已于 2026-10-09 删除。)
Tab 过滤两个坑:① **RecyclerView 里 `View.GONE` 不会让 item 塌陷为 0 高度**(不同于普通 Tab 过滤两个坑:① **RecyclerView 里 `View.GONE` 不会让 item 塌陷为 0 高度**(不同于普通
ViewGroup),必须在数据层整体 `refresh()` 过滤后的子集给 adapter,不能给 item 根节点加 ViewGroup),必须在数据层整体 `refresh()` 过滤后的子集给 adapter,不能给 item 根节点加
visibility 绑定(实机截图验证过,见「常见编译错误速查」);② 通过 `Intent.putExtra(Serializable)` visibility 绑定(实机截图验证过,见「常见编译错误速查」);② 通过 `Intent.putExtra(Serializable)`
@@ -1291,6 +1333,8 @@ adb shell am start -n com.lukouguoji.aerologic/com.lukouguoji.gjc.activity.IntEx
| 布局构建报 `Error: The string "--" is not permitted within comments` | XML 注释中出现连续双横线(如写"兜底 \"--\""),XML 规范禁止 | 注释里改用文字描述(如「兜底双横线」),代码绑定表达式中的 `"--"` 不受影响 | | 布局构建报 `Error: The string "--" is not permitted within comments` | XML 注释中出现连续双横线(如写"兜底 \"--\""),XML 规范禁止 | 注释里改用文字描述(如「兜底双横线」),代码绑定表达式中的 `"--"` 不受影响 |
| 多选页手机版点「全选」后底部「已选 N 项」不更新(单卡勾选正常) | 批量勾选在 `checkAllClick` 里直接 set,不走 FlowBus 的单卡 EVENT_CHECK_CHANGED 事件,`selectedCountText` 只在事件回调里更新 | `checkAllClick` 末尾自行同步 `selectedCountText`(平板布局不绑定该字段,无影响) | | 多选页手机版点「全选」后底部「已选 N 项」不更新(单卡勾选正常) | 批量勾选在 `checkAllClick` 里直接 set,不走 FlowBus 的单卡 EVENT_CHECK_CHANGED 事件,`selectedCountText` 只在事件回调里更新 | `checkAllClick` 末尾自行同步 `selectedCountText`(平板布局不绑定该字段,无影响) |
| 手机版数字输入框(`PhoneFormRow` 双向绑 Double 的 `xxxStr`)输入 "18" 变成 "81" | getter 由 Double 反推字符串("1" → "1.0"),与输入框文本不等触发 `setText`,光标回到开头 | Bean 里保留输入原文(`@Transient` 私有 String),getter 优先返回原文(参考 `GjcCheckInRecord.trayTotalWeightStr`) | | 手机版数字输入框(`PhoneFormRow` 双向绑 Double 的 `xxxStr`)输入 "18" 变成 "81" | getter 由 Double 反推字符串("1" → "1.0"),与输入框文本不等触发 `setText`,光标回到开头 | Bean 里保留输入原文(`@Transient` 私有 String),getter 优先返回原文(参考 `GjcCheckInRecord.trayTotalWeightStr`) |
| 固定 dp 尺寸显示的位图图标在手机上发糊(设计稿里很清晰) | 位图放进了比「源像素 ÷ 显示 dp」更高的密度桶(如 167px 的宫格图放 xxhdpi,在 ~295dpi 设备上解码先缩到 103px,再拉伸到 110px 显示) | 只按固定 dp 尺寸显示的设计稿位图放 `drawable-nodpi`(原尺寸解码,View 一次缩小);排查:截图放大对比设计稿,算 `源px × 设备dpi / 桶dpi` 是否小于显示 px |
| 分段/胶囊控件里选中块上下不居中(上窄下宽) | 设计稿 CSS 的 `border` 计入盒子尺寸,而 Android shape 的 `stroke` 画在 background 上、不占 padding——照抄 CSS 的 `padding: 2px` 少了 1dp 描边 | 容器 padding = CSS padding + border 宽度(如 2 + 1 = 3dp),且容器高度用 `wrap_content`——写死 28dp 时 28/3/22dp 各自取整成 px,内框比选中块少 1px,选中块底边被截;用像素列取色(`magick … txt:-`)量上下间隙验证 |
| XML 里 `bean.a ?? bean.b` 兜底不生效,字段明明有值却显示占位符 | 网络层转换器把 JSON null 归一成空串 `""`,DataBinding 的 `??` 只判 null 不判空串,永远取到左侧的空串 | 在 Bean 上加计算属性做 `isNullOrEmpty` 双兜底(如 `agentDisplay`),XML 绑计算属性(进港查询/出港查询代理人已踩过) | | XML 里 `bean.a ?? bean.b` 兜底不生效,字段明明有值却显示占位符 | 网络层转换器把 JSON null 归一成空串 `""`,DataBinding 的 `??` 只判 null 不判空串,永远取到左侧的空串 | 在 Bean 上加计算属性做 `isNullOrEmpty` 双兜底(如 `agentDisplay`),XML 绑计算属性(进港查询/出港查询代理人已踩过) |
--- ---
@@ -1536,14 +1580,16 @@ Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
### 双端验证命令 ### 双端验证命令
```bash ```bash
~/Library/Android/sdk/emulator/emulator -list-avds # 可用:Aerologic_Phone_5.5_API31(手机,首选)、Aerologic_Tablet(平板)、Medium_Phone_API_36.1 /opt/homebrew/share/android-commandlinetools/emulator/emulator -list-avds # 可用:AeroLogic_Phone(手机)、AeroLogic_Tablet(平板)
adb -s <设备> shell wm size && adb -s <设备> shell wm density # 确认屏幕参数 adb -s <设备> shell wm size && adb -s <设备> shell wm density # 确认屏幕参数
adb -s <设备> logcat -d -s BaseActivity:D # 查看形态识别结果 PHONE(sw=384dp) / TABLET(sw=800dp) adb -s <设备> logcat -d -s BaseActivity:D # 查看形态识别结果 PHONE(sw=394dp) / TABLET(sw=800dp)
adb -s <设备> exec-out screencap -p > /tmp/shot.png # 截图比对 adb -s <设备> exec-out screencap -p > /tmp/shot.png # 截图比对
``` ```
> 手机端验证**首选 `Aerologic_Phone_5.5_API31`**:按真实交付手持机 1:1 定制——720×1440、5.51 寸(300dpi,sw=384dp)、 > 手机端用 **`AeroLogic_Phone`**:按真实交付手持机 1:1 定制——720×1440、5.51 寸(292dpi,sw=394dp)、
> Android 12 / API 31(与 targetSdk 一致)、arm64-v8a,与实机的差异最小。`Medium_Phone_API_36.1`(1080×2400、API 36) > Android 12 / API 31、arm64-v8a,与实机的差异最小。
> 仅作兼容性参考,分辨率与系统版本都和交付机不符。 > 平板端用 **`AeroLogic_Tablet`**:1280×800、160dpi(sw=800dp)、横屏、API 31。平板模拟器没有物理按键,
> `adb shell monkey` 拉起会失败,改用 `am start -n com.lukouguoji.aerologic/com.lukouguoji.module_base.LoginActivity`。
> 模拟器可执行文件在 Homebrew 的 android-commandlinetools 下(见上方命令),不在 `~/Library/Android/sdk`。
> 改动 `BaseActivity` / `BaseBindingActivity` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变。 > 改动 `BaseActivity` / `BaseBindingActivity` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变。

View File

@@ -20,11 +20,6 @@
android:exported="true" android:exported="true"
tools:replace="android:exported" /> tools:replace="android:exported" />
<!-- 出港移库-运单详情(手机版适配验证页) -->
<activity
android:name="com.lukouguoji.gjc.page.move.IntExpMoveDetailActivity"
android:exported="true"
tools:replace="android:exported" />
<!-- 出库交接(手机版适配验证页) --> <!-- 出库交接(手机版适配验证页) -->
<activity <activity

View File

@@ -246,9 +246,9 @@
android:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
android:exported="false" android:exported="false"
android:screenOrientation="unspecified" /> android:screenOrientation="unspecified" />
<!-- 国际出港-出港移库-运单详情(手机版专属) --> <!-- 国际出港-出港移库编辑/详情 -->
<activity <activity
android:name="com.lukouguoji.gjc.page.move.IntExpMoveDetailActivity" android:name="com.lukouguoji.gjc.page.move.IntExpMoveEditActivity"
android:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
android:exported="false" android:exported="false"
android:screenOrientation="unspecified" /> android:screenOrientation="unspecified" />

View File

@@ -7,6 +7,8 @@ import com.lukouguoji.module_base.bean.FlightBean
import com.lukouguoji.module_base.common.Constant import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.http.net.NetApply import com.lukouguoji.module_base.http.net.NetApply
import com.lukouguoji.module_base.ktx.launchLoadingCollect import com.lukouguoji.module_base.ktx.launchLoadingCollect
import com.lukouguoji.module_base.util.DictUtils
import dev.utils.app.info.KeyValue
class FlightQueryDetailsViewModel : BaseViewModel() { class FlightQueryDetailsViewModel : BaseViewModel() {
@@ -14,9 +16,24 @@ class FlightQueryDetailsViewModel : BaseViewModel() {
var dataBean = MutableLiveData<FlightBean>() var dataBean = MutableLiveData<FlightBean>()
///////////////////////////////////////////////////////////////////////////
// 枚举字段展示文案:接口已返回名称时原样显示,只返回值(code)时按字典反查名称
///////////////////////////////////////////////////////////////////////////
val countryTypeText = MutableLiveData("")
val serviceTypeText = MutableLiveData("")
val portCodeText = MutableLiveData("")
val flightStatusText = MutableLiveData("")
private var countryTypeDict = emptyList<KeyValue>()
private var serviceTypeDict = emptyList<KeyValue>()
private var portCodeDict = emptyList<KeyValue>()
private var flightStatusDict = emptyList<KeyValue>()
fun initOnCreated(intent: Intent) { fun initOnCreated(intent: Intent) {
id = intent.getStringExtra(Constant.Key.ID) ?: "" id = intent.getStringExtra(Constant.Key.ID) ?: ""
loadDictLists()
getData() getData()
} }
@@ -27,8 +44,52 @@ class FlightQueryDetailsViewModel : BaseViewModel() {
onSuccess = { onSuccess = {
it.data?.let { data -> it.data?.let { data ->
dataBean.value = data dataBean.value = data
refreshEnumText()
} }
} }
} }
} }
}
/**
* 加载四个枚举字典(与新增/编辑页同源),用于把 code 反查成 label
*/
private fun loadDictLists() {
DictUtils.getCountryTypeList(addAll = false) {
countryTypeDict = it
refreshEnumText()
}
DictUtils.getFlightServiceTypeList(addAll = false) {
serviceTypeDict = it
refreshEnumText()
}
DictUtils.getFlightPortCodeList(addAll = false) {
portCodeDict = it
refreshEnumText()
}
DictUtils.getFlightStatusList(addAll = false) {
flightStatusDict = it
refreshEnumText()
}
}
/**
* 数据或字典任一就绪都重算一次,避免两者返回先后顺序影响展示
*/
private fun refreshEnumText() {
val bean = dataBean.value ?: return
countryTypeText.value = toLabel(countryTypeDict, bean.countryType)
serviceTypeText.value = toLabel(serviceTypeDict, bean.serviceType)
portCodeText.value = toLabel(portCodeDict, bean.portCode)
flightStatusText.value = toLabel(flightStatusDict, bean.flightStatus)
}
/**
* 值转 label:先按 code 反查,命中不了再判断是否本身就是 label,仍不命中则原样返回
*/
private fun toLabel(dict: List<KeyValue>, raw: String?): String {
if (raw.isNullOrEmpty()) return ""
dict.firstOrNull { it.value == raw }?.let { return it.key }
dict.firstOrNull { it.key == raw }?.let { return it.key }
return raw
}
}

View File

@@ -63,9 +63,7 @@ class GnjMoveStashListActivity :
/** 筛选:重置(只清空弹层内条件,不影响 Tab 与顶部搜索框) */ /** 筛选:重置(只清空弹层内条件,不影响 Tab 与顶部搜索框) */
fun resetFilter() { fun resetFilter() {
viewModel.flightDate.value = "" viewModel.carrier.value = ""
viewModel.flightNo.value = ""
viewModel.spCode.value = ""
viewModel.businessType.value = "" viewModel.businessType.value = ""
} }

View File

@@ -54,12 +54,6 @@ class GnjMoveStashListViewModel : BasePageViewModel() {
/** 移库状态 Tab:"0" 待移库(DomImpMove/search)/ "1" 已移库(DomImpMove/searchMoved) */ /** 移库状态 Tab:"0" 待移库(DomImpMove/search)/ "1" 已移库(DomImpMove/searchMoved) */
val moveState = MutableLiveData("0") val moveState = MutableLiveData("0")
/** 手机筛选弹层:航班日期 / 航班号 / 特码(默认空 → 不进请求) */
val flightDate = MutableLiveData("")
val flightNo = MutableLiveData("")
val spCode = MutableLiveData("")
val spCodeList = MutableLiveData<List<KeyValue>>(emptyList())
val moveStateTabs = MutableLiveData( val moveStateTabs = MutableLiveData(
listOf(KeyValue("待移库", "0"), KeyValue("已移库", "1")) listOf(KeyValue("待移库", "0"), KeyValue("已移库", "1"))
) )
@@ -73,15 +67,11 @@ class GnjMoveStashListViewModel : BasePageViewModel() {
/** 全选状态(手机 PhoneBottomBar 勾选图标) */ /** 全选状态(手机 PhoneBottomBar 勾选图标) */
val isAllChecked = MutableLiveData(false) val isAllChecked = MutableLiveData(false)
/** 手机专属的额外请求(Tab 角标、特码字典)开关,仅手机形态在 Activity 中开启 */ /** 手机专属的额外请求(Tab 角标)开关,仅手机形态在 Activity 中开启 */
private var phoneExtrasEnabled = false private var phoneExtrasEnabled = false
fun initPhoneExtras() { fun initPhoneExtras() {
phoneExtrasEnabled = true phoneExtrasEnabled = true
// 特码字典:flag=0 国内、ieFlag=I 进港(是否生效待实测,见问题清单)
DictUtils.getSpecialCodeList(addAll = false, flag = 0, ieFlag = "I") { list ->
spCodeList.postValue(listOf(KeyValue("全部", "")) + list)
}
} }
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
@@ -135,10 +125,6 @@ class GnjMoveStashListViewModel : BasePageViewModel() {
"awbType" to businessType.value!!.ifEmpty { null }, "awbType" to businessType.value!!.ifEmpty { null },
"wbNo" to waybill.value!!.ifEmpty { null }, "wbNo" to waybill.value!!.ifEmpty { null },
"carrier" to carrier.value!!.ifEmpty { null }, "carrier" to carrier.value!!.ifEmpty { null },
// 以下为手机筛选弹层字段,平板恒为空串 → 恒传 null,行为不变
"fdate" to flightDate.value?.ifEmpty { null },
"fno" to flightNo.value?.ifEmpty { null },
"spCode" to spCode.value?.ifEmpty { null },
) )
override fun getData() { override fun getData() {

View File

@@ -19,7 +19,7 @@ class GnjUnloadAddActivity :
override fun viewModelClass() = GnjUnloadAddViewModel::class.java override fun viewModelClass() = GnjUnloadAddViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) { override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("国内进港开始卸机") setBackArrow("进港开始卸机")
binding.viewModel = viewModel binding.viewModel = viewModel
viewModel.rv = binding.rv viewModel.rv = binding.rv

View File

@@ -24,7 +24,7 @@ class GnjUnloadListActivity :
} }
override fun initOnCreate(savedInstanceState: Bundle?) { override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("国内进港卸机") setBackArrow("进港卸机")
binding.viewModel = viewModel binding.viewModel = viewModel
viewModel.pageModel.bindSmartRefreshLayout( viewModel.pageModel.bindSmartRefreshLayout(
binding.srl, binding.srl,

View File

@@ -31,19 +31,19 @@ class UldEditViewModel : BaseViewModel() {
) )
) )
// 所属航司下拉列表(数据源:POST /typeCode/intCarrier) // 来源下拉列表
val carrierList = MutableLiveData<List<KeyValue>>(emptyList()) val sourceList = MutableLiveData<List<KeyValue>>(
// ========== 手机版(5.5 寸手持端)专用 ==========
/** 来源选项(与平板端一致:空运 / 陆运) */
val sourceEditList = MutableLiveData<List<KeyValue>>(
listOf( listOf(
KeyValue("空运", "空运"), KeyValue("空运", "空运"),
KeyValue("陆运", "陆运"), KeyValue("陆运", "陆运"),
) )
) )
// 所属航司下拉列表(数据源:POST /typeCode/intCarrier)
val carrierList = MutableLiveData<List<KeyValue>>(emptyList())
// ========== 手机版(5.5 寸手持端)专用 ==========
/** /**
* 手机版状态选项。后端枚举维持两态(0 正常 / 1 故障,不新增「占用」), * 手机版状态选项。后端枚举维持两态(0 正常 / 1 故障,不新增「占用」),
* 按设计稿文案映射 可用=0 / 维修=1。 * 按设计稿文案映射 可用=0 / 维修=1。
@@ -60,12 +60,9 @@ class UldEditViewModel : BaseViewModel() {
else if (modify) DetailsPageType.Modify else DetailsPageType.Details else if (modify) DetailsPageType.Modify else DetailsPageType.Details
if (id.isEmpty()) { if (id.isEmpty()) {
// 新增:直接加载航司列表 // 新增:所在港默认 HFE,直接加载航司列表
uldBean.value = ULDBean(nowPort = "HFE")
loadCarrierList(null) loadCarrierList(null)
// 手机版新增默认所在港 HFE(设计稿约定);平板无此预填,行为不变
if (DeviceUtil.isPhone()) {
uldBean.value = uldBean.value?.copy(nowPort = "HFE")
}
} else { } else {
getData() getData()
} }
@@ -101,26 +98,6 @@ class UldEditViewModel : BaseViewModel() {
showToast("ULD编号不能为空") showToast("ULD编号不能为空")
return return
} }
// 手机版按设计稿校验必填项(自重 / 入库时间);平板表单无入库时间项,保持原校验行为
if (DeviceUtil.isPhone()) {
if (bean.uldWeight.isBlank()) {
showToast("自重不能为空")
return
}
// saveUld 缺最大装载容积/最大载重时后端直接 500(内网实测),提前拦截
if (bean.maxVolume.isBlank()) {
showToast("最大装载容积不能为空")
return
}
if (bean.maxWeight.isBlank()) {
showToast("最大载重不能为空")
return
}
if (bean.inStoreTime.isBlank()) {
showToast("入库时间不能为空")
return
}
}
// 手机版日期行只产出 yyyy-MM-dd,而 inStoreTime 为 date-time(仅日期后端返回 400),补齐时分秒 // 手机版日期行只产出 yyyy-MM-dd,而 inStoreTime 为 date-time(仅日期后端返回 400),补齐时分秒
if (DeviceUtil.isPhone() && bean.inStoreTime.length == 10) { if (DeviceUtil.isPhone() && bean.inStoreTime.length == 10) {
bean.inStoreTime = "${bean.inStoreTime} 00:00:00" bean.inStoreTime = "${bean.inStoreTime} 00:00:00"
@@ -132,12 +109,7 @@ class UldEditViewModel : BaseViewModel() {
}) { }) {
onSuccess = { onSuccess = {
showToast(it.msg.noNull(if (isAdd) "新增成功" else "修改成功")) showToast(it.msg.noNull(if (isAdd) "新增成功" else "修改成功"))
if (isAdd) { if (isAdd) uldBean.value = ULDBean(nowPort = "HFE") else getData()
// 清空表单继续录入;手机版保持所在港默认 HFE(与进入页面时一致)
uldBean.value = if (DeviceUtil.isPhone()) ULDBean(nowPort = "HFE") else ULDBean()
} else {
getData()
}
} }
} }
} }

View File

@@ -456,7 +456,7 @@ class HomeFragment : Fragment() {
.navigation() .navigation()
} }
// 理货报告 // 进港理货
Constant.AuthName.IntImpTally -> { Constant.AuthName.IntImpTally -> {
ARouter.getInstance().build(ARouterConstants.ACTIVITY_URL_INT_IMP_TALLY) ARouter.getInstance().build(ARouterConstants.ACTIVITY_URL_INT_IMP_TALLY)
.navigation() .navigation()
@@ -847,7 +847,7 @@ class HomeFragment : Fragment() {
RightMenu( RightMenu(
Constant.AuthName.IntImpTally, Constant.AuthName.IntImpTally,
R.mipmap.gjj_li_huo_bao_gao_icon, R.mipmap.gjj_li_huo_bao_gao_icon,
"理货报告" "进港理货"
) )
) )
list.add( list.add(

View File

@@ -125,6 +125,49 @@
</LinearLayout> </LinearLayout>
<!-- Row 3: 来源 / 入库时间 / 所在港 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请选择来源"}'
list="@{viewModel.sourceList}"
title='@{"来源"}'
titleLength="@{7}"
type="@{DataLayoutType.SPINNER}"
value='@={viewModel.uldBean.source}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请选择入库时间"}'
title='@{"入库时间"}'
titleLength="@{5}"
type="@{DataLayoutType.DATETIME}"
value='@={viewModel.uldBean.inStoreTime}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请输入所在港"}'
title='@{"所在港"}'
titleLength="@{5}"
type="@{DataLayoutType.INPUT}"
value='@={viewModel.uldBean.nowPort}' />
</LinearLayout>
<!-- 备注(全行,多行输入) --> <!-- 备注(全行,多行输入) -->
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -104,7 +104,7 @@
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="0.7" android:layout_weight="0.8"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView

View File

@@ -55,7 +55,7 @@
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1.5" android:layout_weight="1.0"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
@@ -109,7 +109,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="自重:" android:text="自重:"
android:textSize="15sp" android:textSize="15sp"
completeSpace="@{3}" /> completeSpace="@{4}" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -165,7 +165,7 @@
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<!-- 第二行:最大载重 | 备注 --> <!-- 第二行:最大载重 | 来源 | 所在港 | 位置 | 入库时间 -->
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -175,7 +175,7 @@
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1.5" android:layout_weight="1.0"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
@@ -193,24 +193,90 @@
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<!-- 备注 --> <!-- 来源 -->
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="3.6" android:layout_weight="0.8"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="备注:" android:text="来源:"
android:textSize="15sp" android:textSize="15sp"
completeSpace="@{3}" /> completeSpace="@{3}" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{bean.remark}" android:text="@{bean.source}"
android:textSize="15sp" />
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- 所在港 -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="0.8"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="所在港:"
android:textSize="15sp"
completeSpace="@{4}" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{bean.nowPort}"
android:textSize="15sp" />
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- 位置 -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="0.8"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="位置:"
android:textSize="15sp"
completeSpace="@{5}" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{bean.nowPosition}"
android:textSize="15sp" />
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- 入库时间 -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.2"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="入库时间:"
android:textSize="15sp"
completeSpace="@{7}" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{bean.inStoreTime}"
android:textSize="15sp" /> android:textSize="15sp" />
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>

View File

@@ -37,7 +37,7 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="15dp"> android:padding="15dp">
<!-- 第1行:航班日期、航班号、航程 --> <!-- 第1行:航班日期、航班号、机型 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -68,14 +68,53 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
enable="@{false}" enable="@{false}"
title='@{"航程"}' title='@{"机型"}'
titleLength="@{4}" titleLength="@{4}"
type="@{DataLayoutType.INPUT}" type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.range}' /> value='@{viewModel.dataBean.aircraftCode}' />
</LinearLayout> </LinearLayout>
<!-- 第2行:地区类型、服务类型、经停站 --> <!-- 第2行:始发港、经停港、目的港 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"始发港"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.fdep}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"经停港"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.jtz}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"目的港"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.fdest}' />
</LinearLayout>
<!-- 第3行:地区类型、服务类型 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -90,7 +129,7 @@
title='@{"地区类型"}' title='@{"地区类型"}'
titleLength="@{4}" titleLength="@{4}"
type="@{DataLayoutType.INPUT}" type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.countryType}' /> value='@{viewModel.countryTypeText}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp" android:layout_width="0dp"
@@ -100,21 +139,55 @@
title='@{"服务类型"}' title='@{"服务类型"}'
titleLength="@{4}" titleLength="@{4}"
type="@{DataLayoutType.INPUT}" type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.serviceType}' /> value='@{viewModel.serviceTypeText}' />
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<!-- 第4行:飞行状态、航班状态、延误原因 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
enable="@{false}" enable="@{false}"
title='@{"经停站"}' title='@{"飞行状态"}'
titleLength="@{4}" titleLength="@{4}"
type="@{DataLayoutType.INPUT}" type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.jtz}' /> value='@{viewModel.portCodeText}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"航班状态"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.flightStatusText}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"延误原因"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.delayFreeText}' />
</LinearLayout> </LinearLayout>
<!-- 第3行:计划起飞、预计起飞、实际起飞 --> <!-- 第5行:计划起飞、预计起飞、实际起飞 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -153,7 +226,7 @@
</LinearLayout> </LinearLayout>
<!-- 第4行:计划降落、预计降落、实际降落 --> <!-- 第6行:计划降落、预计降落、实际降落 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -192,72 +265,13 @@
</LinearLayout> </LinearLayout>
<!-- 第5行:飞行状态、航班状态、延误原因 --> <!-- 第7行:机位、机号 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:orientation="horizontal"> android:orientation="horizontal">
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"飞行状态"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.portCode}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"航班状态"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.flightStatus}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"延误原因"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.delayFreeText}' />
</LinearLayout>
<!-- 第6行:机号、机型、机位 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"机号"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.registration}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"机型"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.aircraftCode}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew <com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -268,6 +282,21 @@
type="@{DataLayoutType.INPUT}" type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.standId}' /> value='@{viewModel.dataBean.standId}' />
<com.lukouguoji.module_base.ui.weight.data.layout.PadDataLayoutNew
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
enable="@{false}"
title='@{"机号"}'
titleLength="@{4}"
type="@{DataLayoutType.INPUT}"
value='@{viewModel.dataBean.registration}' />
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -121,33 +121,14 @@
setOnResetClick="@{(v)-> activity.resetFilter()}" setOnResetClick="@{(v)-> activity.resetFilter()}"
title='@{"筛选条件"}'> title='@{"筛选条件"}'>
<!-- 航班日期(入参 fdate) --> <!-- 承运人(入参 carrier,与平板搜索栏一致) -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout <com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
hint='@{"请选择航班日期"}' hint='@{"请输入承运人"}'
title='@{"航班日期"}' title='@{"承运人"}'
type="@{PhoneDataLayoutType.DATE}"
value="@={viewModel.flightDate}" />
<!-- 航班号(入参 fno) -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
hint='@{"请输入航班号"}'
title='@{"航班号"}'
type="@{PhoneDataLayoutType.INPUT}" type="@{PhoneDataLayoutType.INPUT}"
value="@={viewModel.flightNo}" /> value="@={viewModel.carrier}" />
<!-- 特码(入参 spCode) -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
hint='@{"请选择特码"}'
list="@{viewModel.spCodeList}"
title='@{"特码"}'
type="@{PhoneDataLayoutType.SPINNER}"
value="@={viewModel.spCode}" />
<!-- 运单类型(入参 awbType,复用平板既有 businessType 字段与字典) --> <!-- 运单类型(入参 awbType,复用平板既有 businessType 字段与字典) -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout <com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout

View File

@@ -167,46 +167,34 @@
enable="@{viewModel.pageType != DetailsPageType.Details}" enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请输入自重"}' hint='@{"请输入自重"}'
numeric="@{true}" numeric="@{true}"
required="@{viewModel.pageType == DetailsPageType.Add}"
title='@{"自重(kg)"}' title='@{"自重(kg)"}'
type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}" type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}"
value="@={viewModel.uldBean.uldWeight}" /> value="@={viewModel.uldBean.uldWeight}" />
<!-- 最大装载容积:新增/修改可编辑(与平板一致;saveUld 缺该字段后端 500,内网实测) --> <!-- 最大装载容积 -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow <com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
enable="@{viewModel.pageType != DetailsPageType.Details}" enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请输入最大装载容积"}' hint='@{"请输入最大装载容积"}'
numeric="@{true}" numeric="@{true}"
required="@{viewModel.pageType == DetailsPageType.Add}"
title='@{"最大装载容积(m³)"}' title='@{"最大装载容积(m³)"}'
type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}" type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}"
value="@={viewModel.uldBean.maxVolume}" /> value="@={viewModel.uldBean.maxVolume}" />
<!-- 最大载重:新增/修改可编辑(与平板一致;saveUld 缺该字段后端 500,内网实测) --> <!-- 最大载重 -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow <com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
enable="@{viewModel.pageType != DetailsPageType.Details}" enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请输入最大载重"}' hint='@{"请输入最大载重"}'
numeric="@{true}" numeric="@{true}"
required="@{viewModel.pageType == DetailsPageType.Add}"
title='@{"最大载重(kg)"}' title='@{"最大载重(kg)"}'
type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}" type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}"
value="@={viewModel.uldBean.maxWeight}" /> value="@={viewModel.uldBean.maxWeight}" />
<!-- 进港航班号(ifNo) -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow
android:layout_width="match_parent"
android:layout_height="wrap_content"
enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请输入进港航班号"}'
title='@{"进港航班号"}'
type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.INPUT}"
value="@={viewModel.uldBean.ifNo}" />
<!-- 所在港(nowPort,与平板端一致;新增默认 HFE,由 ViewModel 手机开关预填) --> <!-- 所在港(nowPort,新增默认 HFE) -->
<com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow <com.lukouguoji.module_base.ui.weight.phone.PhoneFormRow
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -222,7 +210,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
enable="@{viewModel.pageType != DetailsPageType.Details}" enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请选择来源"}' hint='@{"请选择来源"}'
list="@{viewModel.sourceEditList}" list="@{viewModel.sourceList}"
title='@{"来源"}' title='@{"来源"}'
type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.SELECT}" type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.SELECT}"
value="@={viewModel.uldBean.source}" /> value="@={viewModel.uldBean.source}" />
@@ -233,7 +221,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
enable="@{viewModel.pageType != DetailsPageType.Details}" enable="@{viewModel.pageType != DetailsPageType.Details}"
hint='@{"请输入入库时间"}' hint='@{"请输入入库时间"}'
required="@{viewModel.pageType == DetailsPageType.Add}"
title='@{"入库时间"}' title='@{"入库时间"}'
type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.DATE}" type="@{viewModel.pageType == DetailsPageType.Details ? PhoneFormRowType.TEXT : PhoneFormRowType.DATE}"
value="@={viewModel.uldBean.inStoreTime}" /> value="@={viewModel.uldBean.inStoreTime}" />

View File

@@ -76,7 +76,8 @@
android:layout_width="16dp" android:layout_width="16dp"
android:layout_height="16dp" android:layout_height="16dp"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:src="@drawable/ic_phone_chevron_right" /> android:src="@drawable/ic_phone_chevron_right"
android:tint="@color/phone_text_body" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,881 @@
<!DOCTYPE html>
<html class="w-full h-full overflow-hidden" lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport"/>
<title>首页</title>
<script src="https://modao.cc/agent-py/static/source/js/tailwindcss.js"></script>
<script src="https://modao.cc/agent-py/static/source/js/iconify-icon.min.js"></script>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
background: #F0F3F7;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"HarmonyOS Sans SC", "MiSans", "Noto Sans SC", "Source Han Sans SC",
"Microsoft YaHei", ui-sans-serif, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button {
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.app-frame,
.app-frame *,
.app-frame *::before,
.app-frame *::after {
font-family: inherit;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
.app-frame {
width: 100%;
height: 100%;
max-width: min(393px, 100vw);
max-height: min(852px, 100vh);
display: flex;
flex-direction: column;
overflow: hidden;
background: #F5F7FA;
position: relative;
--nav-height: 52px;
--tab-height: 68px;
}
.nav-bar {
height: var(--nav-height);
}
.nav-title {
font-size: clamp(16px, 4.8vw, 18px);
line-height: 1.3;
font-weight: 600;
letter-spacing: clamp(0px, 0.16vw, 0.3px);
}
.nav-bell {
width: clamp(30px, 9.4vw, 34px);
height: clamp(30px, 9.4vw, 34px);
}
.nav-bell iconify-icon {
width: clamp(18px, 5.6vw, 20px);
height: clamp(18px, 5.6vw, 20px);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12));
}
.nav-badge {
min-width: clamp(21px, 6.7vw, 24px);
font-size: clamp(10px, 2.9vw, 11px);
line-height: clamp(14px, 4.2vw, 15px);
letter-spacing: 0;
}
.tab-bar {
height: var(--tab-height);
}
.tab-bar iconify-icon {
width: clamp(22px, 6.7vw, 24px);
height: clamp(22px, 6.7vw, 24px);
}
.tab-bar .tab-icon-stack {
width: clamp(25px, 7.5vw, 27px);
height: clamp(22px, 6.7vw, 24px);
}
.tab-bar .tab-icon-stack > iconify-icon:first-child {
width: clamp(20px, 6.1vw, 22px);
height: clamp(20px, 6.1vw, 22px);
}
.tab-bar .tab-icon-stack > iconify-icon:last-child {
width: clamp(15px, 4.7vw, 17px);
height: clamp(15px, 4.7vw, 17px);
}
.tab-bar .tab-label {
font-size: 11.5px;
line-height: 1.4;
font-weight: 400;
}
.tab-bar .tab-label.is-active {
font-weight: 500;
}
.status-bar {
height: clamp(26px, 8.3vw, 30px);
}
.main-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior-y: contain;
/* 为右下角悬浮的设置按钮留出避让空间,滚到底时列表不被遮挡 */
padding-bottom: calc(52px + var(--tab-height) + 16px + 20px);
}
.main-scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
/* 航班动态设置:右下角悬浮按钮,抬升到 tab 栏之上 */
.settings-fab {
position: fixed;
z-index: 40;
right: 16px;
bottom: calc(var(--tab-height) + 16px);
width: 52px;
height: 52px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
background: #E8EFFD;
color: #3B71E8;
transform: translateZ(0);
box-shadow: 0 4px 14px rgba(31, 42, 51, 0.14);
cursor: pointer;
touch-action: manipulation;
transition: transform 140ms ease, box-shadow 140ms ease;
}
.settings-fab:active {
transform: translateZ(0) scale(0.96);
box-shadow: 0 2px 8px rgba(31, 42, 51, 0.18);
}
.settings-fab:focus-visible {
outline: 2px solid rgba(59, 113, 232, 0.55);
outline-offset: 2px;
}
.settings-fab .settings-fab-icon {
width: 23px;
height: 23px;
display: block;
}
/* 主卡片:白卡 + 1px 描边 + 轻阴影,与两页统一 */
.flight-card {
margin: clamp(10px, 3vw, 14px);
background: #FFFFFF;
border: 1px solid #EDF0F4;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.card-head {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0;
padding: 10px clamp(10px, 3.4vw, 14px);
border-bottom: 1px solid #EEF1F5;
}
.card-title {
flex: none;
font-size: clamp(13px, 3.7vw, 14px);
line-height: 1.4;
font-weight: 400;
letter-spacing: 0.2px;
color: #16202A;
white-space: nowrap;
}
.scope-group {
display: flex;
align-items: center;
gap: 2px;
flex: none;
padding: 2px;
border-radius: 999px;
background: #F4F6F9;
border: 1px solid #EDF0F4;
/* 作为筛选行中的一个平级项,宽度按内容自适应,不拉伸 */
width: max-content;
max-width: 100%;
white-space: nowrap;
box-sizing: border-box;
}
.scope-option {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0 clamp(5px, 1.6vw, 8px);
height: 24px;
border-radius: 999px;
background: transparent;
color: #6B7A8D;
font-size: 12px;
line-height: 1.4;
font-weight: 400;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.scope-option.is-active {
background: #3B71E8;
color: #FFFFFF;
font-weight: 500;
}
/* 筛选行:国内/国际、进港/出港、日期控件为三个平级项。
控件本体按内容自适应,不拉伸;两段间隙为 flex 弹性间隔,
各占剩余空间的一半,因此首项贴左内边距、末项贴右内边距 */
.card-head > .scope-group,
.card-head > .date-group {
flex: 0 0 auto;
max-width: none;
white-space: nowrap;
}
.card-head > .gap-flex {
flex: 1 1 0%;
min-width: 0;
height: 1px;
}
.date-group {
display: flex;
align-items: center;
gap: 0;
white-space: nowrap;
}
.date-picker {
flex: none;
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
width: 30px;
height: 34px;
padding: 0;
border: 1px solid #EDF0F4;
border-radius: 7px;
background: #FFFFFF;
color: #3B71E8;
font-size: 8px;
line-height: 1.1;
letter-spacing: 0.2px;
white-space: nowrap;
cursor: pointer;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.date-picker:focus-visible,
.date-picker.is-open {
border-color: #3B71E8;
box-shadow: 0 0 0 2px rgba(59, 113, 232, 0.14), 0 1px 2px rgba(16, 24, 40, 0.04);
outline: none;
}
.date-picker .date-month {
font-size: 8px;
font-weight: 500;
line-height: 1.1;
color: #98A4B3;
}
.date-picker .date-day {
font-size: 13px;
font-weight: 600;
line-height: 1.1;
color: #3B71E8;
}
.date-step {
flex: none;
width: 22px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
background: transparent;
color: #16202A;
border-radius: 6px;
cursor: pointer;
}
.date-picker + .date-step,
.date-step + .date-picker {
margin-left: 3px;
}
.date-step.is-next {
margin-left: 3px;
}
.date-step iconify-icon {
display: inline-flex;
align-items: center;
}
.date-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: 0;
padding: 0;
cursor: pointer;
}
.dir-tab {
border: 0;
background: transparent;
padding: 0 clamp(5px, 1.6vw, 8px);
height: 24px;
margin: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #6B7A8D;
font-size: 12px;
line-height: 1.4;
font-weight: 400;
cursor: pointer;
white-space: nowrap;
transition: background-color 140ms ease, color 140ms ease;
}
.dir-tab.is-active {
background: #3B71E8;
color: #FFFFFF;
font-weight: 500;
}
.dir-tab + .dir-tab {
margin-left: 2px;
}
/* 导航栏右侧图标组:仅铃铛 */
.nav-actions {
display: flex;
align-items: center;
gap: 0;
}
.nav-icon-btn {
position: relative;
flex: none;
width: clamp(32px, 10vw, 34px);
height: clamp(32px, 10vw, 34px);
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #FFFFFF;
cursor: pointer;
transition: background-color 140ms ease, opacity 140ms ease;
}
.nav-icon-btn:active {
background: rgba(255, 255, 255, 0.14);
opacity: 0.8;
}
.nav-icon-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.7);
outline-offset: 1px;
}
.nav-icon-btn iconify-icon {
width: clamp(17px, 5.4vw, 19px);
height: clamp(17px, 5.4vw, 19px);
}
.table-head {
display: grid;
grid-template-columns: 0.95fr 1fr 1.05fr 1fr;
align-items: center;
background: #F4F6F9;
border-bottom: 1px solid #EEF1F5;
padding: 0 4px;
}
.table-head span {
padding: 9px 4px;
text-align: center;
color: #6B7A8D;
font-size: 12.5px;
line-height: 1.4;
font-weight: 400;
letter-spacing: 0;
white-space: nowrap;
}
.table-body {
flex: 1;
min-height: 120px;
background: #FFFFFF;
}
.flight-row {
display: grid;
grid-template-columns: 0.95fr 1fr 1.05fr 1fr;
align-items: center;
padding: 0 4px;
border-bottom: 1px solid #EEF1F5;
}
.flight-row span {
padding: 9px 4px;
text-align: center;
color: #16202A;
font-size: 12.5px;
line-height: 1.4;
font-weight: 400;
letter-spacing: 0;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.tab-button {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 0;
background: transparent;
color: #98A4B3;
cursor: pointer;
transition: color 140ms ease, transform 140ms ease;
}
.tab-button:active {
transform: scale(0.94);
}
.tab-button.is-active {
color: #3B71E8;
}
.toast {
position: absolute;
left: 50%;
bottom: 80px;
z-index: 60;
max-width: calc(100% - 44px);
width: max-content;
padding: 9px 16px;
border-radius: 999px;
background: rgba(29, 41, 57, 0.9);
color: #ffffff;
font-size: 12px;
line-height: 1.5;
text-align: center;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 8px);
transition: opacity 180ms ease, transform 180ms ease;
box-shadow: 0 8px 24px rgba(18, 33, 49, 0.18);
backdrop-filter: blur(8px);
}
.toast.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
/* 5.51 寸小屏(窄屏或矮屏)收紧纵向节奏 */
@media (max-width: 374px), (max-height: 760px) {
.app-frame {
--nav-height: 46px;
--tab-height: 58px;
}
.nav-title {
font-size: 16px;
}
.tab-bar .tab-label {
font-size: 11px;
}
.flight-card {
margin: 8px;
}
.card-head {
gap: 0;
padding: 8px 9px;
}
.card-title {
font-size: 13px;
}
.scope-group {
gap: 2px;
padding: 2px;
}
.scope-option,
.dir-tab {
font-size: 11.5px;
height: 22px;
padding: 0 clamp(4px, 1.3vw, 7px);
}
.date-picker {
width: 27px;
height: 31px;
border-radius: 6px;
}
.settings-fab {
right: 12px;
bottom: calc(var(--tab-height) + 12px);
width: 48px;
height: 48px;
}
.settings-fab .settings-fab-icon {
width: 21px;
height: 21px;
}
.date-picker .date-month {
font-size: 7px;
}
.date-picker .date-day {
font-size: 12px;
}
.date-step {
width: 19px;
height: 31px;
}
.date-step iconify-icon {
width: 15px;
height: 15px;
}
.dir-tab {
height: 22px;
margin: 0;
}
.table-head span {
padding: 8px 2px;
font-size: 11.5px;
}
.flight-row span {
padding: 8px 2px;
font-size: 11.5px;
}
.toast {
bottom: 72px;
font-size: 11px;
line-height: 1.5;
padding: 8px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.tab-button,
.nav-icon-btn,
.settings-fab,
.toast {
transition: none;
}
}
@media (max-width: 359px) {
.table-head,
.flight-row {
padding: 0 2px;
}
.table-head span,
.flight-row span {
padding: 8px 1px;
font-size: 10.5px;
letter-spacing: -0.2px;
}
}
</style>
</head>
<body>
<div class="app-frame">
<!-- 顶部系统状态栏 -->
<header class="flex-none bg-[#3B71E8] text-white">
<div class="status-bar flex items-center justify-between px-3">
<div class="flex items-center gap-[7px]">
<span class="text-[11.5px] font-semibold tracking-[0.2px]">16:48</span>
</div>
<div aria-label="Wi-Fi" class="flex min-w-0 items-center gap-[5px]">
<iconify-icon class="opacity-95" height="14" icon="mdi:wifi" width="14"></iconify-icon>
</div>
</div>
</header>
<!-- 蓝色导航栏 -->
<nav aria-label="首页导航栏" class="nav-bar relative z-20 flex flex-none items-center justify-center bg-[#3B71E8] text-white">
<h1 class="nav-title">首页</h1>
<div class="nav-actions absolute right-[8px] top-1/2 -translate-y-1/2">
<button aria-label="通知,507 条消息" class="nav-icon-btn text-white" onclick="showNotificationToast()" type="button">
<iconify-icon class="drop-shadow-[0_1px_1px_rgba(0,0,0,0.12)]" icon="mdi:bell-outline"></iconify-icon>
<span class="nav-badge absolute -right-[2px] top-[0px] rounded-full bg-[#e1192d] px-[4px] text-center font-semibold text-white shadow-[0_1px_2px_rgba(145,16,31,0.35)]">507</span>
</button>
</div>
</nav>
<!-- 主内容滚动区 -->
<main class="main-scroll min-h-0 flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-[#F5F7FA]" id="mainContent">
<section aria-label="航班动态" class="flight-card">
<!-- 首行:国内/国际 + 进港/出港分段 + 图标式日期 -->
<div class="card-head">
<div aria-label="国内国际" class="scope-group" role="radiogroup">
<button aria-checked="true" class="scope-option is-active" data-scope="国内" onclick="selectScope(this)" role="radio" type="button">国内</button>
<button aria-checked="false" class="scope-option" data-scope="国际" onclick="selectScope(this)" role="radio" type="button">国际</button>
</div>
<span aria-hidden="true" class="gap-flex"></span>
<div aria-label="进港出港方向" class="scope-group" role="radiogroup">
<button aria-checked="true" class="dir-tab is-active" data-dir="进港" onclick="selectDirection(this)" role="radio" type="button">进港</button>
<button aria-checked="false" class="dir-tab" data-dir="出港" onclick="selectDirection(this)" role="radio" type="button">出港</button>
</div>
<span aria-hidden="true" class="gap-flex"></span>
<div class="date-group">
<button aria-label="前一天" class="date-step date-step-prev" data-date-step="-1" onclick="stepDate(-1)" type="button">
<iconify-icon height="18" icon="mdi:chevron-left" width="18"></iconify-icon>
</button>
<button aria-label="选择日期" class="date-picker" id="datePicker" type="button">
<span class="date-month" id="dateMonth">10月</span>
<span class="date-day" id="dateDay">20</span>
<input aria-label="日期" class="date-input" id="dateInput" type="date" value="2026-10-20"/>
</button>
<button aria-label="后一天" class="date-step is-next" data-date-step="1" onclick="stepDate(1)" type="button">
<iconify-icon height="18" icon="mdi:chevron-right" width="18"></iconify-icon>
</button>
</div>
</div>
<!-- 表头 -->
<div class="table-head" role="row">
<span role="columnheader">航班号</span>
<span role="columnheader">航程</span>
<span role="columnheader">地区类型</span>
<span role="columnheader">计划起飞</span>
</div>
<!-- 数据区 -->
<div aria-label="航班动态列表" class="table-body">
<div class="flight-row" role="row">
<span role="cell">MU2222</span>
<span role="cell">HFE-PEK</span>
<span role="cell">国内</span>
<span role="cell">12:20</span>
</div>
<div class="flight-row" role="row">
<span role="cell">MU6895</span>
<span role="cell">HFE-LAX</span>
<span role="cell">国际</span>
<span role="cell">02:26</span>
</div>
</div>
</section>
</main>
<!-- 右下角悬浮:航班动态设置 -->
<button aria-label="航班动态设置" class="settings-fab" onclick="showToast('航班动态设置')" type="button">
<svg aria-hidden="true" class="settings-fab-icon" focusable="false" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z" fill="#3B71E8"></path>
</svg>
</button>
<!-- 底部 Tab 栏 -->
<nav aria-label="底部导航" class="tab-bar relative z-30 flex flex-none items-stretch border-t border-[#EDF0F4] bg-white pb-[4px] shadow-[0_-1px_2px_rgba(16,24,40,0.04)]">
<button aria-current="page" aria-label="首页,当前页面" class="tab-button is-active" onclick="switchTab('首页')" type="button">
<iconify-icon icon="mdi:home"></iconify-icon>
<span class="tab-label is-active">首页</span>
</button>
<button aria-label="国内" class="tab-button" onclick="switchTab('国内')" type="button">
<iconify-icon icon="mdi:earth"></iconify-icon>
<span class="tab-label">国内</span>
</button>
<button aria-label="国际" class="tab-button" onclick="switchTab('国际')" type="button">
<span class="tab-icon-stack relative inline-flex items-center justify-center">
<iconify-icon class="absolute bottom-0 left-0" icon="mdi:earth"></iconify-icon>
<iconify-icon class="absolute right-0 top-0 -rotate-[14deg]" icon="mdi:airplane"></iconify-icon>
</span>
<span class="tab-label">国际</span>
</button>
<button aria-label="我的" class="tab-button" onclick="switchTab('我的')" type="button">
<iconify-icon icon="mdi:account-circle-outline"></iconify-icon>
<span class="tab-label">我的</span>
</button>
</nav>
<div aria-live="polite" class="toast" id="toast" role="status"></div>
</div>
<script>
let toastTimer = null;
function showToast(message) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(function () {
toast.classList.remove("is-visible");
}, 1800);
}
function showNotificationToast() {
showToast("您有 507 条待处理消息");
}
function selectScope(target) {
const options = document.querySelectorAll(".scope-option");
for (let i = 0; i < options.length; i++) {
const isTarget = options[i] === target;
options[i].classList.toggle("is-active", isTarget);
options[i].setAttribute("aria-checked", isTarget ? "true" : "false");
}
}
function selectDirection(target) {
const tabs = document.querySelectorAll(".dir-tab");
for (let i = 0; i < tabs.length; i++) {
const isTarget = tabs[i] === target;
tabs[i].classList.toggle("is-active", isTarget);
tabs[i].setAttribute("aria-checked", isTarget ? "true" : "false");
}
}
function switchTab(name) {
if (name === "国内") {
window.location.href = "domestic.html";
return;
}
if (name === "国际") {
window.location.href = "home.html";
return;
}
if (name === "首页") {
const main = document.getElementById("mainContent");
if (main) {
main.scrollTo({ top: 0, behavior: "smooth" });
}
showToast("当前已在首页");
return;
}
showToast(name + "模块暂未接入");
}
(function () {
const datePicker = document.getElementById("datePicker");
const dateInput = document.getElementById("dateInput");
const dateMonth = document.getElementById("dateMonth");
const dateDay = document.getElementById("dateDay");
if (!datePicker || !dateInput || !dateMonth || !dateDay) return;
function renderDate(value) {
if (!value) return;
const parts = value.split("-");
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
if (!month || !day) return;
dateMonth.textContent = month + "月";
dateDay.textContent = String(day);
datePicker.setAttribute("aria-label", "选择日期,当前 " + month + "月" + day + "日");
}
window.stepDate = function (offset) {
const parts = dateInput.value.split("-");
const current = new Date(
parseInt(parts[0], 10) || 2026,
(parseInt(parts[1], 10) || 1) - 1,
parseInt(parts[2], 10) || 1
);
current.setDate(current.getDate() + offset);
const y = current.getFullYear();
const m = String(current.getMonth() + 1).padStart(2, "0");
const d = String(current.getDate()).padStart(2, "0");
dateInput.value = y + "-" + m + "-" + d;
renderDate(dateInput.value);
};
renderDate(dateInput.value);
datePicker.addEventListener("click", function (event) {
if (event.target === dateInput) return;
if (typeof dateInput.showPicker === "function") {
try {
dateInput.showPicker();
} catch (error) {
dateInput.focus();
}
}
});
dateInput.addEventListener("focus", function () {
datePicker.classList.add("is-open");
});
dateInput.addEventListener("blur", function () {
datePicker.classList.remove("is-open");
});
dateInput.addEventListener("change", function () {
if (dateInput.value) {
renderDate(dateInput.value);
}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,535 @@
<!DOCTYPE html>
<html class="w-full h-full overflow-hidden" lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport"/>
<title>国内业务</title>
<script src="https://modao.cc/agent-py/static/source/js/tailwindcss.js"></script>
<script src="https://modao.cc/agent-py/static/source/js/iconify-icon.min.js"></script>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
background: #F0F3F7;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"HarmonyOS Sans SC", "MiSans", "Noto Sans SC", "Source Han Sans SC",
"Microsoft YaHei", ui-sans-serif, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button {
font: inherit;
-webkit-tap-highlight-color: transparent;
}
/* 全页字体统一:覆盖 Tailwind 默认字体族,数字统一等宽 */
.app-frame,
.app-frame *,
.app-frame *::before,
.app-frame *::after {
font-family: inherit;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
.app-frame {
width: 100%;
height: 100%;
max-width: min(393px, 100vw);
max-height: min(852px, 100vh);
display: flex;
flex-direction: column;
overflow: hidden;
background: #F5F7FA;
position: relative;
/* 以 360×640(5.51 寸 16:9)为小屏基准,宫格尺寸随视口宽度等比缩放 */
--tile-size: clamp(48px, 15.3vw, 60px);
--icon-size: calc(var(--tile-size) * 0.55);
--nav-height: 52px;
--tab-height: 68px;
}
.nav-bar {
height: var(--nav-height);
}
.nav-title {
font-size: clamp(16px, 4.8vw, 18px);
line-height: 1.3;
font-weight: 600;
letter-spacing: clamp(0px, 0.16vw, 0.3px);
}
.nav-bell {
width: clamp(30px, 9.4vw, 34px);
height: clamp(30px, 9.4vw, 34px);
}
.nav-bell iconify-icon {
width: clamp(22px, 6.9vw, 25px);
height: clamp(22px, 6.9vw, 25px);
}
.nav-badge {
min-width: clamp(21px, 6.7vw, 24px);
font-size: clamp(10px, 2.9vw, 11px);
line-height: clamp(14px, 4.2vw, 15px);
letter-spacing: 0;
}
.tab-bar {
height: var(--tab-height);
}
.tab-bar iconify-icon {
width: clamp(22px, 6.7vw, 24px);
height: clamp(22px, 6.7vw, 24px);
}
.tab-bar .tab-icon-stack {
width: clamp(25px, 7.5vw, 27px);
height: clamp(22px, 6.7vw, 24px);
}
.tab-bar .tab-icon-stack > iconify-icon:first-child {
width: clamp(20px, 6.1vw, 22px);
height: clamp(20px, 6.1vw, 22px);
}
.tab-bar .tab-icon-stack > iconify-icon:last-child {
width: clamp(15px, 4.7vw, 17px);
height: clamp(15px, 4.7vw, 17px);
}
.tab-bar .tab-label {
font-size: 11.5px;
line-height: 1.4;
font-weight: 400;
}
.tab-bar .tab-label.is-active {
font-weight: 500;
}
.status-bar {
height: clamp(26px, 8.3vw, 30px);
}
.main-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior-y: contain;
}
.main-scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.group-bar {
height: 41px;
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding: 0 24px;
background: #F4F6F9;
border-top: 1px solid #FFFFFF;
}
.group-title {
font-size: clamp(13px, 3.7vw, 14px);
line-height: 1.4;
font-weight: 400;
letter-spacing: 0.2px;
color: #16202A;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 0;
row-gap: clamp(12px, 4.6vw, 18px);
background: #ffffff;
padding: clamp(14px, 4.6vw, 18px) clamp(12px, 5vw, 18px) clamp(18px, 6vw, 24px);
}
.group-arrow {
display: block;
flex: none;
width: auto;
height: clamp(12px, 3.6vw, 15px);
object-fit: contain;
filter: drop-shadow(0 1px 1px rgba(37, 99, 235, 0.28));
}
.feature-card {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
transition: transform 140ms ease;
}
.feature-card:active {
transform: scale(0.94);
}
.icon-tile {
width: var(--tile-size);
height: var(--tile-size);
flex: none;
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: calc(var(--tile-size) * 0.267);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(255, 255, 255, 0.45),
0 6px 12px -7px rgba(54, 80, 99, 0.34);
}
.icon-tile.is-image {
color: transparent;
background: transparent;
box-shadow: 0 6px 12px -7px rgba(54, 80, 99, 0.26);
}
.icon-tile.is-image::before,
.icon-tile.is-image::after {
content: none;
}
.icon-tile.is-image > .tile-img {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
object-position: center;
-webkit-user-drag: none;
user-select: none;
}
.feature-label {
margin-top: clamp(6px, 2.2vw, 10px);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
color: #2d3640;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1.3;
font-weight: 400;
letter-spacing: 0;
white-space: nowrap;
text-align: center;
}
.tab-button {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 0;
background: transparent;
color: #98A4B3;
cursor: pointer;
transition: color 140ms ease, transform 140ms ease;
}
.tab-button:active {
transform: scale(0.94);
}
.tab-button.is-active {
color: #3B71E8;
}
.toast {
position: absolute;
left: 50%;
bottom: 80px;
z-index: 60;
max-width: calc(100% - 44px);
width: max-content;
padding: 9px 16px;
border-radius: 999px;
background: rgba(29, 41, 57, 0.9);
color: #ffffff;
font-size: 12px;
line-height: 1.5;
text-align: center;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 8px);
transition: opacity 180ms ease, transform 180ms ease;
box-shadow: 0 8px 24px rgba(18, 33, 49, 0.18);
backdrop-filter: blur(8px);
}
.toast.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
/* 5.51 寸小屏(窄屏或矮屏)收紧纵向节奏,保证首屏完整 */
@media (max-width: 374px), (max-height: 760px) {
.app-frame {
--nav-height: 46px;
--tab-height: 58px;
}
.group-bar {
height: 35px;
gap: 8px;
padding: 0 16px;
}
.nav-title {
font-size: 16px;
}
.group-title {
font-size: 13px;
}
.feature-label {
font-size: 12px;
}
.tab-bar .tab-label {
font-size: 11px;
}
.group-arrow {
height: 12px;
}
.feature-grid {
row-gap: 12px;
padding: 14px 12px 18px;
}
.toast {
bottom: 72px;
font-size: 11px;
line-height: 1.5;
padding: 8px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.feature-card,
.tab-button,
.toast {
transition: none;
}
}
</style>
</head>
<body>
<div class="app-frame">
<!-- 顶部系统状态栏 -->
<header class="flex-none bg-[#3B71E8] text-white">
<div class="status-bar flex items-center justify-between px-3">
<div class="flex items-center gap-[7px]">
<span class="text-[11.5px] font-semibold tracking-[0.2px]">16:48</span>
</div>
<div aria-label="Wi-Fi" class="flex min-w-0 items-center gap-[5px]">
<iconify-icon class="opacity-95" height="14" icon="mdi:wifi" width="14"></iconify-icon>
</div>
</div>
</header>
<!-- 蓝色导航栏 -->
<nav aria-label="国内业务导航栏" class="nav-bar relative z-20 flex flex-none items-center justify-center bg-[#3B71E8] text-white">
<h1 class="nav-title">国内业务</h1>
<button aria-label="通知,341 条消息" class="nav-bell absolute right-[12px] top-1/2 flex -translate-y-1/2 items-center justify-center rounded-full active:bg-white/10" onclick="showNotificationToast()" type="button">
<iconify-icon class="drop-shadow-[0_1px_1px_rgba(0,0,0,0.12)]" icon="mdi:bell-outline"></iconify-icon>
<span class="nav-badge absolute -right-[2px] top-[0px] rounded-full bg-[#e1192d] px-[4px] text-center font-semibold text-white shadow-[0_1px_2px_rgba(145,16,31,0.35)]">341</span>
</button>
</nav>
<!-- 主内容滚动区 -->
<main class="main-scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-[#F5F7FA]" id="mainContent">
<!-- 国内出港 -->
<section aria-labelledby="dom-outbound-title">
<div class="group-bar">
<img alt="" aria-hidden="true" class="group-arrow" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/f48cf8269922458abee299bf1f4bdfae.png"/>
<h2 class="group-title" id="dom-outbound-title">国内出港</h2>
</div>
<div class="feature-grid">
<button aria-label="出港收运" class="feature-card" onclick="openFeature('出港收运')" type="button">
<span class="icon-tile is-image">
<img alt="出港收运图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/4dfe2842e40947c9916d27c6da2829a9.png"/>
</span>
<span class="feature-label">出港收运</span>
</button>
<button aria-label="出港复磅" class="feature-card" onclick="openFeature('出港复磅')" type="button">
<span class="icon-tile is-image">
<img alt="出港复磅图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/d209b04e82874625ae8977733789486a.png"/>
</span>
<span class="feature-label">出港复磅</span>
</button>
<button aria-label="出港存放" class="feature-card" onclick="openFeature('出港存放')" type="button">
<span class="icon-tile is-image">
<img alt="出港存放图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/3655ce17a68b4090870f929d7a510c64.png"/>
</span>
<span class="feature-label">出港存放</span>
</button>
<button aria-label="单证交接" class="feature-card" onclick="openFeature('单证交接')" type="button">
<span class="icon-tile is-image">
<img alt="单证交接图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/934528d010e74d1690dce7b20cee545c.png"/>
</span>
<span class="feature-label">单证交接</span>
</button>
<button aria-label="货物转运" class="feature-card" onclick="openFeature('货物转运')" type="button">
<span class="icon-tile is-image">
<img alt="货物转运图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/9c2d255f57d6416ab18713b02431023e.png"/>
</span>
<span class="feature-label">货物转运</span>
</button>
<button aria-label="转运记录" class="feature-card" onclick="openFeature('转运记录')" type="button">
<span class="icon-tile is-image">
<img alt="转运记录图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/50e01da375de4c1bb4574401ba60500c.png"/>
</span>
<span class="feature-label">转运记录</span>
</button>
</div>
</section>
<!-- 国内进港 -->
<section aria-labelledby="dom-inbound-title">
<div class="group-bar">
<img alt="" aria-hidden="true" class="group-arrow" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/f48cf8269922458abee299bf1f4bdfae.png"/>
<h2 class="group-title" id="dom-inbound-title">国内进港</h2>
</div>
<div class="feature-grid">
<button aria-label="进港卸机" class="feature-card" onclick="openFeature('进港卸机')" type="button">
<span class="icon-tile is-image">
<img alt="进港卸机图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/153ca414db114dd487852b22727fb38f.png"/>
</span>
<span class="feature-label">进港卸机</span>
</button>
<button aria-label="进港移库" class="feature-card" onclick="openFeature('进港移库')" type="button">
<span class="icon-tile is-image">
<img alt="进港移库图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/2dc4ad4cc1884f4692da574db217e91e.png"/>
</span>
<span class="feature-label">进港移库</span>
</button>
</div>
</section>
<!-- 综合管理 -->
<section aria-labelledby="dom-manage-title" class="pb-2">
<div class="group-bar">
<img alt="" aria-hidden="true" class="group-arrow" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/f48cf8269922458abee299bf1f4bdfae.png"/>
<h2 class="group-title" id="dom-manage-title">综合管理</h2>
</div>
<div class="feature-grid" style="padding-bottom: clamp(16px, 5.6vw, 22px);">
<button aria-label="事故签证" class="feature-card" onclick="openFeature('事故签证')" type="button">
<span class="icon-tile is-image">
<img alt="事故签证图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/2d2e97049ef44cfabbf1ed051702d567.png"/>
</span>
<span class="feature-label">事故签证</span>
</button>
</div>
</section>
</main>
<!-- 底部 Tab 栏 -->
<nav aria-label="底部导航" class="tab-bar relative z-30 flex flex-none items-stretch border-t border-[#EDF0F4] bg-white pb-[4px] shadow-[0_-1px_2px_rgba(16,24,40,0.04)]">
<button aria-label="首页" class="tab-button" onclick="switchTab('首页')" type="button">
<iconify-icon icon="mdi:home-outline"></iconify-icon>
<span class="tab-label">首页</span>
</button>
<button aria-current="page" aria-label="国内,当前页面" class="tab-button is-active" onclick="switchTab('国内')" type="button">
<iconify-icon icon="mdi:earth"></iconify-icon>
<span class="tab-label is-active">国内</span>
</button>
<button aria-label="国际" class="tab-button" onclick="switchTab('国际')" type="button">
<span class="tab-icon-stack relative inline-flex items-center justify-center">
<iconify-icon class="absolute bottom-0 left-0" icon="mdi:earth"></iconify-icon>
<iconify-icon class="absolute right-0 top-0 -rotate-[14deg]" icon="mdi:airplane"></iconify-icon>
</span>
<span class="tab-label">国际</span>
</button>
<button aria-label="我的" class="tab-button" onclick="switchTab('我的')" type="button">
<iconify-icon icon="mdi:account-circle-outline"></iconify-icon>
<span class="tab-label">我的</span>
</button>
</nav>
<div aria-live="polite" class="toast" id="toast" role="status"></div>
</div>
<script>
let toastTimer = null;
function showToast(message) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(function () {
toast.classList.remove("is-visible");
}, 1800);
}
function openFeature(name) {
showToast(name + " · 功能即将开放");
}
function showNotificationToast() {
showToast("您有 341 条待处理消息");
}
function switchTab(name) {
if (name === "首页") {
window.location.href = "dashboard.html";
return;
}
if (name === "国际") {
window.location.href = "home.html";
return;
}
if (name === "国内") {
const main = document.getElementById("mainContent");
if (main) {
main.scrollTo({ top: 0, behavior: "smooth" });
}
showToast("当前已在国内业务");
return;
}
showToast(name + "模块暂未接入");
}
</script>
</body>
</html>

View File

@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html class="w-full h-full overflow-hidden" lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport"/>
<title>国际业务</title>
<script src="https://modao.cc/agent-py/static/source/js/tailwindcss.js"></script>
<script src="https://modao.cc/agent-py/static/source/js/iconify-icon.min.js"></script>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
background: #F0F3F7;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"HarmonyOS Sans SC", "MiSans", "Noto Sans SC", "Source Han Sans SC",
"Microsoft YaHei", ui-sans-serif, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button {
font: inherit;
-webkit-tap-highlight-color: transparent;
}
/* 全页字体统一:覆盖 Tailwind 默认字体族,数字统一等宽 */
.app-frame,
.app-frame *,
.app-frame *::before,
.app-frame *::after {
font-family: inherit;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
.app-frame {
width: 100%;
height: 100%;
max-width: min(393px, 100vw);
max-height: min(852px, 100vh);
display: flex;
flex-direction: column;
overflow: hidden;
background: #F5F7FA;
position: relative;
/* 以 360×640(5.51 寸 16:9)为小屏基准,宫格尺寸随视口宽度等比缩放 */
--tile-size: clamp(48px, 15.3vw, 60px);
--icon-size: calc(var(--tile-size) * 0.55);
--tag-size: clamp(28px, 8.9vw, 35px);
--tag-offset: calc(var(--tag-size) * 0.2);
--tag-line: calc(var(--tag-size) * 0.4);
--nav-height: 52px;
--tab-height: 68px;
}
.nav-bar {
height: var(--nav-height);
}
.nav-title {
font-size: clamp(16px, 4.8vw, 18px);
line-height: 1.3;
font-weight: 600;
letter-spacing: clamp(0px, 0.16vw, 0.3px);
}
.nav-bell {
width: clamp(30px, 9.4vw, 34px);
height: clamp(30px, 9.4vw, 34px);
}
.nav-bell iconify-icon {
width: clamp(22px, 6.9vw, 25px);
height: clamp(22px, 6.9vw, 25px);
}
.nav-badge {
min-width: clamp(21px, 6.7vw, 24px);
font-size: clamp(10px, 2.9vw, 11px);
line-height: clamp(14px, 4.2vw, 15px);
letter-spacing: 0;
}
.tab-bar {
height: var(--tab-height);
}
.tab-bar iconify-icon {
width: clamp(22px, 6.7vw, 24px);
height: clamp(22px, 6.7vw, 24px);
}
.tab-bar .tab-icon-stack {
width: clamp(25px, 7.5vw, 27px);
height: clamp(22px, 6.7vw, 24px);
}
.tab-bar .tab-icon-stack > iconify-icon:first-child {
width: clamp(20px, 6.1vw, 22px);
height: clamp(20px, 6.1vw, 22px);
}
.tab-bar .tab-icon-stack > iconify-icon:last-child {
width: clamp(15px, 4.7vw, 17px);
height: clamp(15px, 4.7vw, 17px);
}
.tab-bar .tab-label {
font-size: 11.5px;
line-height: 1.4;
font-weight: 400;
}
.tab-bar .tab-label.is-active {
font-weight: 500;
}
.status-bar {
height: clamp(26px, 8.3vw, 30px);
}
.main-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior-y: contain;
}
.main-scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.group-bar {
height: 41px;
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding: 0 24px;
background: #F4F6F9;
border-top: 1px solid #FFFFFF;
}
.group-title {
font-size: clamp(13px, 3.7vw, 14px);
line-height: 1.4;
font-weight: 400;
letter-spacing: 0.2px;
color: #16202A;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 0;
row-gap: clamp(12px, 4.6vw, 18px);
background: #ffffff;
padding: clamp(14px, 4.6vw, 18px) clamp(12px, 5vw, 18px) clamp(18px, 6vw, 24px);
}
.group-arrow {
display: block;
flex: none;
width: auto;
height: clamp(12px, 3.6vw, 15px);
object-fit: contain;
filter: drop-shadow(0 1px 1px rgba(37, 99, 235, 0.28));
}
.feature-card {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
transition: transform 140ms ease;
}
.feature-card:active {
transform: scale(0.94);
}
.icon-tile {
width: var(--tile-size);
height: var(--tile-size);
flex: none;
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: calc(var(--tile-size) * 0.267);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(255, 255, 255, 0.45),
0 6px 12px -7px rgba(54, 80, 99, 0.34);
}
.icon-tile::before {
content: "";
position: absolute;
top: calc(var(--tile-size) * 0.1);
left: calc(var(--tile-size) * 0.167);
width: calc(var(--tile-size) * 0.3);
height: calc(var(--tile-size) * 0.133);
border-radius: 999px;
background: rgba(255, 255, 255, 0.46);
filter: blur(2px);
pointer-events: none;
}
.icon-tile::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.5);
pointer-events: none;
}
.corner-tag {
position: absolute;
left: 0;
top: 0;
width: var(--tag-size);
height: var(--tag-size);
overflow: hidden;
border-top-left-radius: inherit;
pointer-events: none;
z-index: 3;
}
.corner-tag span {
position: absolute;
left: calc(var(--tag-size) * -0.429);
top: var(--tag-offset);
width: calc(var(--tag-size) * 1.714);
transform: rotate(-45deg);
background: linear-gradient(90deg, #4e97ff 0%, #2f7df6 53%, #2a73eb 100%);
color: #ffffff;
font-size: clamp(7px, 2.1vw, 8px);
line-height: var(--tag-line);
letter-spacing: 0.5px;
text-align: center;
font-weight: 500;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.32);
}
.icon-main {
filter: drop-shadow(0 2px 0 rgba(255, 255, 255, 0.22))
drop-shadow(0 3px 3px rgba(37, 56, 76, 0.18));
}
.icon-stage {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--icon-size);
height: var(--icon-size);
}
.icon-stage > iconify-icon {
width: calc(var(--icon-size) * 0.94);
height: calc(var(--icon-size) * 0.94);
}
.icon-stage > .icon-sub {
width: calc(var(--icon-size) * 0.62);
height: calc(var(--icon-size) * 0.62);
}
.icon-stage > .icon-mini {
width: calc(var(--icon-size) * 0.46);
height: calc(var(--icon-size) * 0.46);
}
.icon-tile.is-image {
color: transparent;
background: transparent;
box-shadow: 0 6px 12px -7px rgba(54, 80, 99, 0.26);
}
.icon-tile.is-image::before,
.icon-tile.is-image::after {
content: none;
}
.icon-tile.is-image > .tile-img {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
object-position: center;
-webkit-user-drag: none;
user-select: none;
}
.mini-accent {
position: absolute;
right: calc(var(--tile-size) * -0.13);
bottom: calc(var(--tile-size) * -0.115);
width: calc(var(--tile-size) * 0.283);
height: calc(var(--tile-size) * 0.283);
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 5px rgba(31, 48, 67, 0.12);
z-index: 4;
}
.mini-accent iconify-icon {
width: calc(var(--tile-size) * 0.19);
height: calc(var(--tile-size) * 0.19);
}
.feature-label {
margin-top: clamp(6px, 2.2vw, 10px);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
color: #2d3640;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1.3;
font-weight: 400;
letter-spacing: 0;
white-space: nowrap;
text-align: center;
}
.tile-purple {
color: #8b66f4;
background:
radial-gradient(circle at 29% 22%, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, #f7f2ff 0%, #ece3ff 60%, #e4d8ff 100%);
}
.tile-mint {
color: #67c8bb;
background:
radial-gradient(circle at 29% 22%, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, #effcf9 0%, #ddf8f2 58%, #d4f4ed 100%);
}
.tile-blue {
color: #4b8df5;
background:
radial-gradient(circle at 29% 22%, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, #f1f6ff 0%, #e6efff 60%, #dce8ff 100%);
}
.tile-pink {
color: #ee6f7d;
background:
radial-gradient(circle at 29% 22%, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, #fff3f4 0%, #ffe6e9 58%, #ffdde2 100%);
}
.tile-peach {
color: #f2a34b;
background:
radial-gradient(circle at 29% 22%, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, #fff8ee 0%, #ffefdd 60%, #ffe8cf 100%);
}
.tile-green {
color: #35ae8b;
background:
radial-gradient(circle at 29% 22%, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, #f0fcf8 0%, #e0f8f1 58%, #d7f5ec 100%);
}
.tab-button {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 0;
background: transparent;
color: #98A4B3;
cursor: pointer;
transition: color 140ms ease, transform 140ms ease;
}
.tab-button:active {
transform: scale(0.94);
}
.tab-button.is-active {
color: #3B71E8;
}
.toast {
position: absolute;
left: 50%;
bottom: 80px;
z-index: 60;
max-width: calc(100% - 44px);
width: max-content;
padding: 9px 16px;
border-radius: 999px;
background: rgba(29, 41, 57, 0.9);
color: #ffffff;
font-size: 12px;
line-height: 1.5;
text-align: center;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 8px);
transition: opacity 180ms ease, transform 180ms ease;
box-shadow: 0 8px 24px rgba(18, 33, 49, 0.18);
backdrop-filter: blur(8px);
}
.toast.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
/* 5.51 寸小屏(窄屏或矮屏)收紧纵向节奏,保证首屏完整 */
@media (max-width: 374px), (max-height: 760px) {
.app-frame {
--nav-height: 46px;
--tab-height: 58px;
--tag-offset: 5.4px;
--tag-line: 13px;
}
.group-bar {
height: 35px;
gap: 8px;
padding: 0 16px;
}
.nav-title {
font-size: 16px;
}
.group-title {
font-size: 13px;
}
.feature-label {
font-size: 12px;
}
.tab-bar .tab-label {
font-size: 11px;
}
.group-arrow {
height: 12px;
}
.feature-grid {
row-gap: 12px;
padding: 14px 12px 18px;
}
.toast {
bottom: 72px;
font-size: 11px;
line-height: 1.5;
padding: 8px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.feature-card,
.tab-button,
.toast {
transition: none;
}
}
</style>
</head>
<body>
<div class="app-frame">
<!-- 顶部系统状态栏 -->
<header class="flex-none bg-[#3B71E8] text-white">
<div class="status-bar flex items-center justify-between px-3">
<div class="flex items-center gap-[7px]">
<span class="text-[11.5px] font-semibold tracking-[0.2px]">16:48</span>
</div>
<div aria-label="Wi-Fi" class="flex min-w-0 items-center gap-[5px]">
<iconify-icon class="opacity-95" height="14" icon="mdi:wifi" width="14"></iconify-icon>
</div>
</div>
</header>
<!-- 蓝色导航栏 -->
<nav aria-label="国际业务导航栏" class="nav-bar relative z-20 flex flex-none items-center justify-center bg-[#3B71E8] text-white">
<h1 class="nav-title">国际业务</h1>
<button aria-label="通知,341 条消息" class="nav-bell absolute right-[12px] top-1/2 flex -translate-y-1/2 items-center justify-center rounded-full active:bg-white/10" onclick="showNotificationToast()" type="button">
<iconify-icon class="drop-shadow-[0_1px_1px_rgba(0,0,0,0.12)]" icon="mdi:bell-outline"></iconify-icon>
<span class="nav-badge absolute -right-[2px] top-[0px] rounded-full bg-[#e1192d] px-[4px] text-center font-semibold text-white shadow-[0_1px_2px_rgba(145,16,31,0.35)]">341</span>
</button>
</nav>
<!-- 主内容滚动区 -->
<main class="main-scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-[#F5F7FA]" id="mainContent">
<!-- 国际出港 -->
<section aria-labelledby="outbound-title">
<div class="group-bar">
<img alt="" aria-hidden="true" class="group-arrow" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/f48cf8269922458abee299bf1f4bdfae.png"/>
<h2 class="group-title" id="outbound-title">国际出港</h2>
</div>
<div class="feature-grid">
<button aria-label="出港计重" class="feature-card" onclick="openFeature('出港计重')" type="button">
<span class="icon-tile is-image">
<img alt="出港计重图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/1e9507a32e7c45dea0362c3cc87b98c2.gif"/>
</span>
<span class="feature-label">出港计重</span>
</button>
<button aria-label="出港抵运" class="feature-card" onclick="openFeature('出港抵运')" type="button">
<span class="icon-tile is-image">
<img alt="出港抵运图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/1c4971c9a2d9459da4ee19b5b5431bf1.gif"/>
</span>
<span class="feature-label">出港抵运</span>
</button>
<button aria-label="出港交接" class="feature-card" onclick="openFeature('出港交接')" type="button">
<span class="icon-tile is-image">
<img alt="出港交接图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/460f6760462b4690823a98613894ce6e.gif"/>
</span>
<span class="feature-label">出港交接</span>
</button>
<button aria-label="出港移库" class="feature-card" onclick="openFeature('出港移库')" type="button">
<span class="icon-tile is-image">
<img alt="出港移库图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/0bc7fe8fff684d748b0b27f2a120c6ed.gif"/>
</span>
<span class="feature-label">出港移库</span>
</button>
<button aria-label="出港仓库" class="feature-card" onclick="openFeature('出港仓库')" type="button">
<span class="icon-tile is-image">
<img alt="出港仓库图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/18258a4775944c13b120d486ea13e925.gif"/>
</span>
<span class="feature-label">出港仓库</span>
</button>
<button aria-label="出港查询" class="feature-card" onclick="openFeature('出港查询')" type="button">
<span class="icon-tile is-image">
<img alt="出港查询图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/6c6fa3ed9a1449f1a704dbb6415814a1.png"/>
</span>
<span class="feature-label">出港查询</span>
</button>
</div>
</section>
<!-- 国际进港 -->
<section aria-labelledby="inbound-title">
<div class="group-bar">
<img alt="" aria-hidden="true" class="group-arrow" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/f48cf8269922458abee299bf1f4bdfae.png"/>
<h2 class="group-title" id="inbound-title">国际进港</h2>
</div>
<div class="feature-grid">
<button aria-label="进港理货" class="feature-card" onclick="openFeature('进港理货')" type="button">
<span class="icon-tile is-image">
<img alt="进港理货图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/cc9965b937344aca99bdf329fea23e91.gif"/>
</span>
<span class="feature-label">进港理货</span>
</button>
<button aria-label="提取出库" class="feature-card" onclick="openFeature('提取出库')" type="button">
<span class="icon-tile is-image">
<img alt="提取出库图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/7bcf76ae718e4da9a784a7003c3884a7.gif"/>
</span>
<span class="feature-label">提取出库</span>
</button>
<button aria-label="进港仓库" class="feature-card" onclick="openFeature('进港仓库')" type="button">
<span class="icon-tile is-image">
<img alt="进港仓库图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/2d325743d7004623a87c3982190fe1d9.gif"/>
</span>
<span class="feature-label">进港仓库</span>
</button>
<button aria-label="进港查询" class="feature-card" onclick="openFeature('进港查询')" type="button">
<span class="icon-tile is-image">
<img alt="进港查询图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/0fd94c77b8c644b8bb37c1b5d471fcaf.gif"/>
</span>
<span class="feature-label">进港查询</span>
</button>
</div>
</section>
<!-- 综合管理 -->
<section aria-labelledby="manage-title" class="pb-2">
<div class="group-bar">
<img alt="" aria-hidden="true" class="group-arrow" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/f48cf8269922458abee299bf1f4bdfae.png"/>
<h2 class="group-title" id="manage-title">综合管理</h2>
</div>
<div class="feature-grid" style="padding-bottom: clamp(16px, 5.6vw, 22px);">
<button aria-label="ULD管理" class="feature-card" onclick="openFeature('ULD管理')" type="button">
<span class="icon-tile is-image">
<img alt="ULD管理图标" class="tile-img" draggable="false" src="https://modao.cc/agent-py/media/generated_images/2026-10-20/7b2321b0f277416abad0b61252c5730a.gif"/>
</span>
<span class="feature-label">ULD管理</span>
</button>
</div>
</section>
</main>
<!-- 底部 Tab 栏 -->
<nav aria-label="底部导航" class="tab-bar relative z-30 flex flex-none items-stretch border-t border-[#EDF0F4] bg-white pb-[4px] shadow-[0_-1px_2px_rgba(16,24,40,0.04)]">
<button aria-label="首页" class="tab-button" onclick="switchTab('首页')" type="button">
<iconify-icon icon="mdi:home-outline"></iconify-icon>
<span class="tab-label">首页</span>
</button>
<button aria-label="国内" class="tab-button" onclick="switchTab('国内')" type="button">
<iconify-icon icon="mdi:earth"></iconify-icon>
<span class="tab-label">国内</span>
</button>
<button aria-current="page" aria-label="国际,当前页面" class="tab-button is-active" onclick="switchTab('国际')" type="button">
<span class="tab-icon-stack relative inline-flex items-center justify-center">
<iconify-icon class="absolute bottom-0 left-0" icon="mdi:earth"></iconify-icon>
<iconify-icon class="absolute right-0 top-0 -rotate-[14deg]" icon="mdi:airplane"></iconify-icon>
</span>
<span class="tab-label is-active">国际</span>
</button>
<button aria-label="我的" class="tab-button" onclick="switchTab('我的')" type="button">
<iconify-icon icon="mdi:account-circle-outline"></iconify-icon>
<span class="tab-label">我的</span>
</button>
</nav>
<div aria-live="polite" class="toast" id="toast" role="status"></div>
</div>
<script>
let toastTimer = null;
function showToast(message) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(function () {
toast.classList.remove("is-visible");
}, 1800);
}
function openFeature(name) {
showToast(name + " · 功能即将开放");
}
function showNotificationToast() {
showToast("您有 341 条待处理消息");
}
function switchTab(name) {
if (name === "首页") {
window.location.href = "dashboard.html";
return;
}
if (name === "国内") {
window.location.href = "domestic.html";
return;
}
if (name === "国际") {
const main = document.getElementById("mainContent");
if (main) {
main.scrollTo({ top: 0, behavior: "smooth" });
}
showToast("当前已在国际业务");
return;
}
showToast(name + "模块暂未接入");
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,219 @@
# 平板 + 5.5 寸手机合入主干后的交接
> 日期:2026-10-11 | 基线提交:`origin/main @ 131eb03` | 在线版:https://claude.ai/artifact/N8cWdTcMAzk9N86NSPevTC
>
> 本文描述合并当天的状态。分支、版本号等信息如有变化,以仓库为准。
## 接手前先知道这几件事
1. **只有一条开发分支:`main`。** 手机适配分支 `feature/phone-ui-adaptation` 已于 2026-10-11 合入,平板和 5.5 寸手机以后都在 `main` 上一起开发。
2. **业务以平板为准,手机只做界面。** 两端共用 ViewModel、接口、表单项、筛选入参和默认值;差异只能出现在布局(View)层。
3. **同一个页面有两份同名布局**:`res/layout-sw600dp/` 是平板,`res/layout/` 是手机。改平板页面时要同时检查手机布局和这个页面的所有弹框。
4. **有两件事等人拍板**:ULD 保存可能报 500(问题清单 #36),以及手机独有的几个界面功能留不留(见第 6 节)。
---
## 1. 仓库与分支
| 分支 / 位置 | 提交 | 状态 | 建议 |
|---|---|---|---|
| `origin/main` | 131eb03 | 唯一开发分支 | 以后所有开发从这里拉分支、合回这里。 |
| 本地 `main`(主 checkout `~/Development/Projects/aerologic-app`) | 871fa4c | 落后 30 个提交 | 合并是在另一个 worktree 里推送的,主 checkout 还没同步。到那个目录执行 `git pull --ff-only`。 |
| `feature/phone-ui-adaptation`(本地 + 远端) | 42647c2 | 已完整合入 | 不要再往上提交。确认无人使用后可以删除(删除远端分支前先跟团队确认)。 |
| `worktree/phone-adaptation`(仅本地) | 42647c2 | 已完整合入 | 可删除。 |
| `merge/phone-into-main`(仅本地,worktree `~/.herdr/worktrees/aerologic-app/worktree-phone-adaptation` 当前检出) | 131eb03 | 与 main 相同 | 合并时用的临时分支。可以 `git worktree remove` 这个 worktree 后删除分支。 |
合并提交 `131eb03` 的第一父提交是原 main(`871fa4c`),第二父提交是手机分支(`42647c2`),所以 `git log --first-parent main` 仍然是平板的历史。两条线的分叉点是 `9c4588e`:分叉后 main 有 58 个平板提交,手机分支有 29 个提交,合并时共 45 个文件冲突。
## 2. 平板与手机的开发状态
**平板(10 寸横屏)**
- 功能完整,即合并前 main 上的全部功能。
- 合并没有改平板的业务逻辑。冲突的平板布局用三方合并放进了 `layout-sw600dp/`,内容就是 main 最新版。
- 已有手机变体的页面:20 个 Activity 布局 + 11 个弹框布局在 `layout-sw600dp/`。
- 其余平板页面(舱单、理货、组装、航班管理等)只有一份布局,放在 `res/layout/`,没有手机版。
**手机(5.5 寸竖屏)**
- 设计稿 01~11 的 11 个入口全部完成,并已按 main 的新平板功能补齐(见第 3 节)。
- 手机首页是 `module_p` 的 `PDAEnterActivity`,菜单是手写清单(`GjViewModel` / `GnViewModel`),按登录权限串过滤。
- 设计参考:`documents/5.5寸手持端设计文件/`,技术方案 `documents/5.5寸手持端适配技术方案.md`。
### 手机已适配页面
| 首页入口 | 页面(Activity) | 模块 | 说明 |
|---|---|---|---|
| 国际 · 出港移库 | `IntExpMoveActivity`、`IntExpMoveEditActivity` | gjc | 详情 / 编辑与平板共用同一个 Activity,手机布局为「运单详情」样式。 |
| 国际 · 出库交接 | `IntExpOutHandoverActivity`、`IntExpOutHandoverDetailActivity` | gjc | 底部栏带「允许装机」「配运」次按钮。 |
| 国际 · 出港计重 | `GjcWeighingListActivity`、`GjcWeighingStartActivity`、`GjcWeighingRecordListActivity`、`GjcWeighingRecordDetailsActivity` | gjc | 另有手机专属 `GjcWeighingWbDetailActivity`(运单详情)。 |
| 国际 · 出港运抵 | `IntExpArriveActivity` | gjc | 手机专属 `IntExpArriveSubOrderActivity`(分单明细)、`IntExpArriveReceiptActivity`(分单回执);主单回执用两端共用的 `CustomsResponseActivity`。 |
| 国际 · 出港查询 | `GjcQueryActivity`、`GjcQueryDetailsActivity`、`LogDetailActivity`(运单追踪) | gjc / app | |
| 国际 · 出港仓库 | `IntExpStorageUseActivity` | gjc | 平板的「勾选 + 底部批量」在手机上改为卡片四按钮:清仓 / 修改库位 / 出库 / 入库。 |
| 国际 · 进港仓库 | `IntImpStorageUseActivity`、`IntImpQueryDetailsActivity` | gjj | 接口前缀是 `IntImpStorage`,不是 `IntImpStorageUse`。 |
| 国际 · 进港查询 | `IntImpQueryActivity` | gjj | |
| 国际 · 提取出库 | `IntImpPickUpDLVActivity` | gjj | 手机专属 `IntImpPickUpDetailActivity`。 |
| 国际 · ULD 管理 | `UldListActivity`、`UldEditActivity` | app | 一套编辑布局覆盖新增 / 修改 / 详情。 |
| 国内 · 进港移库 | `GnjMoveStashListActivity` | app | 业务归属国内进港移库(DomImpMove)。手机专属 `GnjMoveStashDetailActivity`、`GnjMoveStashPhotoActivity`。 |
手机首页还有一批旧的 PDA 竖屏入口(国际:普货收运、板箱组装、板箱过磅、进港理货;国内:出港收运、出港复磅、出港存放、进港卸机、货物转运、转运记录、事故签证、单证交接)。它们是 `module_p` 原有页面,不在这次设计稿范围内,合并没有改动,也没有验证。
## 3. 这次合并做了什么
### 冲突处理原则
- **布局**:平板布局取 main 最新版,三方合并进 `layout-sw600dp/`,没有冲突残留。手机布局保留原样,再逐页补上 main 新增的业务字段和操作。
- **Bean / ViewModel**:一律取平板逻辑。手机只保留它额外需要的展示字段(如 `opUserDisplay`、统计字段)。
- **Manifest**:取手机分支版本(屏幕方向统一为 `unspecified`),再补上 main 新增的 Activity。
### main 的平板功能同步到手机
| 页面 | 同步到手机的内容 |
|---|---|
| 出港运抵 | 卡片显示海关放行、运单类型;筛选加分单号;回执改走平板同一接口和回执页;长按菜单加「删除申报」(手机底部弹框)。 |
| 出库交接 | 卡片显示装机状态;底部栏加「允许装机」「配运」;配运弹框做了手机变体。 |
| 出港移库 | 详情和编辑改用平板的 `IntExpMoveEditActivity`,删除手机专属 `IntExpMoveDetailActivity`;卡片加「编辑」;筛选加卸货站。 |
| 出港 / 进港仓库 | 清仓弹框加备注,卡片显示清仓备注;入库、出库弹框可填件数和重量(出库弹框新做了手机变体);进港卡片件数取 `pcLabel`。 |
| 开始计重 | ViewModel 整体取平板版本:加运单号、地磅重量、平板车、托盘信息,运抵重量按平板公式自动计算;航班日期 / 航班号 / 航程可编辑。 |
| 计重 / 计重记录 | 计重列表加「新增」按钮;计重记录加航班号筛选,卡片加「运抵申报」入口。 |
| 计重明细 | 重量与托盘自重按平板逻辑联动(和恒定),同时保留手机的输入原文处理(避免光标跳动)。 |
| 进港查询 | 筛选加出库状态。 |
| ULD | 采用平板的来源列表与 HFE 默认值;卡片显示所在港 / 位置。 |
### 手机按平板收敛的地方(使用上能感觉到)
- 删除了平板不传的查询入参:`likeNo`(8 位运单号模糊查询)、进港移库的航班日期 / 航班号 / 特码、出港移库的特码。
- 恢复平板默认查询值:计重、计重记录、提取出库等页面默认查当天,首次打开常常是空列表,属于预期行为。
- 补齐平板有而手机缺的筛选项:运抵分单号、进港查询出库状态、计重记录航班号、进港移库承运人、出港移库卸货站。
- ULD 编辑去掉平板没有的「进港航班号」和手机额外加的必填校验(影响见 #36)。
## 4. 以后怎么开发
项目根目录的 `CLAUDE.md`「5.5 寸手持端双形态适配规范」一节是完整规范,`.claude/skills/phone-adapt/` 是手机适配流程。下面是最常用的几条。
### 布局双变体的硬性约束
- 两个变体**文件同名**:平板 `layout-sw600dp/xxx.xml`,手机 `layout/xxx.xml`。禁止用 `xxx_phone.xml` 加代码判断,那样会生成另一个 Binding 类,进页面就 `ClassCastException`。
- 两个变体的 `<variable>` 名称和类型必须一致。
- Kotlin 里引用的控件 id 两边都要有;只有一边有的 id 在 Binding 里是可空的,代码里要写 `binding.xxx?.`。
- 弹框同样做双变体,并在构造参数里切换位置:`BaseDialogModel(if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER)`。
- Manifest 新 Activity 的 `screenOrientation` 写 `unspecified`,由 `BaseActivity` 按设备锁方向。
### 平板改了一个已适配页面,标准步骤
1. 改平板布局(`layout-sw600dp/`)和 ViewModel。
2. 打开同名手机布局(`layout/`),补上同样的字段、筛选项和操作入口。平板的批量按钮在手机上可以改成卡片按钮或 `PhoneBottomBar` 的次按钮(`subAction1Text` / `subAction2Text`)。
3. 检查这个页面用到的每一个 DialogModel,新弹框要做手机变体。
4. 手机 Tab 角标、计数这类只有手机用的额外请求,放在 `phoneExtrasEnabled` 一类的开关后面,不能改变平板的请求。
5. 两台模拟器各走一遍,再编译 `./gradlew assembleDebug`。
### 手机端不能做的事
- 不能新增平板没有的查询入参(后端口径:手机筛选一律与平板一致,后端不认识的参数会被静默忽略)。
- 不能改写平板的默认查询值(如默认当天)。
- 不能少平板的表单项或校验,也不能多加平板没有的校验。
### 新做一个手机页面
按 `CLAUDE.md`「手机版页面改造步骤」:`git mv` 平板布局到 `layout-sw600dp/` → 写同名手机布局 → 优先复用 `module_base/.../ui/weight/phone/` 下的组件(`PhoneSearchBar`、`PhoneFormRow`、`PhoneBottomBar` 等)→ 到 `GjViewModel` / `GnViewModel` 加首页菜单入口(最容易忘,忘了在手机上就进不去)。
## 5. 已验证与未验证
验证环境:手机模拟器 `AeroLogic_Phone`(720×1440,API 31,sw=394dp),平板模拟器 `AeroLogic_Tablet`(1280×800,sw=800dp),连内网测试服务。
| 项目 | 结果 | 说明 |
|---|---|---|
| Debug 编译 | ✅ 通过 | 合并后 `assembleDebug` 成功。 |
| 手机 11 个入口主流程 | ✅ 已验证 | 列表、Tab、筛选、详情、弹框打开,无崩溃。 |
| 平板回归(已适配的页面) | ✅ 已验证 | 主要页面打开、筛选、弹框,无崩溃,布局为 main 最新版。 |
| 开始计重自动计算 | ✅ 已验证 | 地磅 500、托盘 2 个 × 18 → 运抵重量 464,与平板公式一致。新增模式头部不再显示 "null kg"。 |
| 手机新弹框(出库 / 入库 / 清仓 / 配运 / 删除申报) | ⚠️ 只打开过 | 样式和字段都确认了,没有点提交,以免改动测试数据。 |
| 所有写操作(申报、出入库、清仓、保存、移库、拍照上传) | ⚠️ 未提交验证 | 合并后没有真实提交过。建议用测试数据逐个点一次提交,平板和手机都要。 |
| 海关回执页有内容时的显示 | ❌ 未验证 | 测试数据里没有带回执的运单。 |
| ULD 管理、国内进港移库 | ❌ 只编译过 | 合并后的改动没在模拟器上点过。 |
| 手机首页旧 PDA 入口 | ❌ 未验证 | 不在本次范围。 |
| Release 包、真机(交付手持机 / 平板) | ❌ 未验证 | 只在模拟器上跑过 Debug 包。 |
## 6. 已知问题与待决事项
### 需要决定
- **ULD 保存可能报 500(问题清单 #36)。** 后端 `saveUld` 在缺少最大装载容积 / 最大载重时返回 500。之前只有手机做了必填拦截;按「以平板为准」去掉之后,两端都可能报错。可选做法:让后端修,或者在平板加同样的必填校验(再同步到手机)。
- **手机独有的界面功能目前保留。** 它们只在手机上发请求,不影响平板,所以合并时留下了:
- 各列表的「待 / 已」Tab,以及 Tab 角标额外发的统计请求;
- 进港移库「已移库」Tab(调用平板不用的 `searchMoved` 接口);
- 出港运抵分单明细页、分单回执页;ULD 状态显示为「可用 / 维修」;ULD 入库时间保存时补 `00:00:00`。
如果要求两端严格一致,需要逐项删除。
### 对用户的行为变化(属于预期)
- 手机列表默认查当天,首次打开常常为空,要在筛选里清掉日期。
- 手机输入 8 位运单号不再能模糊查到(去掉了 `likeNo`),和平板一致。
### 后端缺口(问题清单 #35~#41,待后端处理)
- ULD 列表按 uld 搜索返回 500;`saveUld` 缺 maxVolume / maxWeight 返回 500(#36)。
- `IntImpStorage/pageQuery` 列表过滤全部不生效。
- 出港仓库列表与统计的口径不一致。
- 已移库数据没有 `mawbId`,无法补传照片。
完整内容和每一项的后端答复在 `documents/后端接口对接问题清单.xlsx`。这份表是和后端对接的正式文档,新发现的缺口要继续记在里面。
### 其他待办
- `PhoneSearchBar` 还没接 AutoQuery 联想(`AutoQueryManager` 只接了平板组件)。
## 7. 容易踩的坑
1. **API 文档的 token 提交进了仓库。** `.mcp.json` 里写着 api-doc MCP 的 `Authorization: Bearer …`,并且已被 git 跟踪。建议轮换这个 token,改为从环境变量或本地未跟踪的配置读取。
2. **换行符混用,没有 `.gitattributes`。** main 原有文件多为 CRLF,手机分支新建的文件是 LF。编辑时保持文件原有换行符,否则会出现整文件 diff,合并和 review 都会很难看。
3. **没有手机变体的平板页面,在手机上会直接显示平板布局。** 给手机首页加入口前,必须先做手机布局。
4. **后端会静默忽略不认识的参数。** 猜错参数名会得出「后端不支持」的误判。入参出参一律用 api-doc MCP 核对,流程见 `.claude/skills/phone-adapt/references/api-doc.md`。
5. **XML 里 `??` 兜底不生效。** 网络层会把 JSON null 转成空串,`??` 只判断 null。要在 Bean 上写计算属性(如 `agentDisplay`、`opUserDisplay`)。
6. **RecyclerView 里给 item 设 `GONE` 不会收起高度。** Tab 过滤要在数据层过滤后整体 `refresh`。
7. **Intent 传递的 Bean 里 `@Transient` 字段会变成 null**(`checked`、`showMore` 等)。接收方要 `.map { it.copy() }`。
8. **同页多个手机复合组件会串状态。** 新写同类组件要覆写 `dispatchSaveInstanceState` / `dispatchRestoreInstanceState`(见 CLAUDE.md)。
第 5~8 条在 CLAUDE.md「常见编译错误速查」里都有详细说明和修复方式。
## 8. 环境与工具
- **工具链**:Gradle 用 Java 17 运行(`mise.toml`),编译目标是 Java 1.8;当前版本 1.8.7(versionCode 87)。
- **内网**:测试服务器在内网,模拟器要在内网环境下才能拉到数据,否则提示「接口访问失败」。测试账号向项目负责人索取(不写在文档里)。
- **登录不保留**:冷启动后要重新登录。
- **Debug 直达页面**:`app/src/debug/AndroidManifest.xml` 把适配页设为 exported,可以直接拉起(只在 Debug 包生效)。
- **平板模拟器没有物理按键**,`adb shell monkey` 拉起会失败,改用 `am start`。
- **手机模拟器**上软键盘弹出时按返回键,有时会被吞掉并退出到桌面,不是崩溃。
- **mobile-mcp** 偶尔识别不到 `emulator-5554`,可以直接用 adb。
```bash
# 启动模拟器(模拟器可执行文件在 Homebrew 的 android-commandlinetools 下)
/opt/homebrew/share/android-commandlinetools/emulator/emulator -avd AeroLogic_Phone &
/opt/homebrew/share/android-commandlinetools/emulator/emulator -avd AeroLogic_Tablet &
# 编译安装
./gradlew assembleDebug
adb -s emulator-5554 install -r app/build/outputs/apk/debug/*.apk
# 拉起登录页(平板没有物理键时用这个)
adb -s emulator-5556 shell am start -n com.lukouguoji.aerologic/com.lukouguoji.module_base.LoginActivity
# 确认设备形态识别
adb -s emulator-5554 logcat -d -s BaseActivity:D # PHONE(sw=394dp) / TABLET(sw=800dp)
```
## 9. 关键文件索引
| 文件 | 用途 |
|---|---|
| `CLAUDE.md` | 项目规范总入口,包括双形态适配规范、分支约定、常见错误速查。 |
| `CHANGELOG.md` | [Unreleased] 下「手机适配分支合入主干 · 2026-10-11」是本次合并的完整记录。 |
| `documents/后端接口对接问题清单.xlsx` | 与后端对接的正式问题清单(#1~#41)。 |
| `documents/5.5寸手持端设计文件/` | 手机设计稿(HTML)。 |
| `.claude/skills/phone-adapt/` | 手机适配流程,含 api-doc、组件、平板安全、验证等参考。 |
| `module_base/.../util/DeviceUtil.kt` | 手机 / 平板判定(sw ≥ 600dp 为平板)。 |
| `module_base/.../ui/weight/phone/` | 手机公共组件,适配器在 `PhoneWidgetKtx.kt`。 |
| `module_base/.../MyApplication.kt` | AutoSize 设计基准(手机竖屏 390×844,平板横屏 1152×720)。 |
| `module_p/.../ui/enter/gj/GjViewModel.kt`、`gn/GnViewModel.kt` | 手机首页国际 / 国内菜单清单。 |
| `app/.../HomeFragment.kt` | 平板首页菜单,手机入口的权限串和路由要以它为准。 |

View File

@@ -149,7 +149,7 @@ dependencies {
api 'com.github.li-xiaojun:XPopup:2.9.19' api 'com.github.li-xiaojun:XPopup:2.9.19'
// 屏幕适配 // 屏幕适配
api 'me.jessyan:autosize:1.2.+' api 'me.jessyan:autosize:1.2.1'
// 佳博打印SDK // 佳博打印SDK
api(name: 'sdk2-2.0.4', ext: 'aar') { api(name: 'sdk2-2.0.4', ext: 'aar') {

View File

@@ -2,6 +2,7 @@ package com.lukouguoji.module_base.adapter
import android.view.View import android.view.View
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout
import androidx.databinding.BindingAdapter import androidx.databinding.BindingAdapter
import com.lukouguoji.module_base.interfaces.IOnFocusChangeListener import com.lukouguoji.module_base.interfaces.IOnFocusChangeListener
@@ -27,6 +28,18 @@ fun visible(view: View, visible: Any?) {
} }
} }
/**
* 动态设置 LinearLayout 子 View 的 layout_weight(如按状态切换列宽)
*/
@BindingAdapter("layoutWeight")
fun setLayoutWeight(view: View, weight: Float) {
val lp = view.layoutParams as? LinearLayout.LayoutParams ?: return
if (lp.weight != weight) {
lp.weight = weight
view.layoutParams = lp
}
}
@BindingAdapter("setFocusChangedListener") @BindingAdapter("setFocusChangedListener")
fun setOnFocusChangeListener(et: EditText, listener: IOnFocusChangeListener) { fun setOnFocusChangeListener(et: EditText, listener: IOnFocusChangeListener) {
et.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> et.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->

View File

@@ -19,6 +19,27 @@ class AssembleWaybillBean {
var originalPieces: String = "" // 原始运单件数 var originalPieces: String = "" // 原始运单件数
var originalWeight: String = "" // 原始运单重量 var originalWeight: String = "" // 原始运单重量
// ========== 剩余/总数显示(开始组装页首次装货成功后切换) ==========
var checkInPieces: Long = 0 // 已组装件数(接口 checkInPc)
var checkInWeight: Double = 0.0 // 已组装重量(接口 checkInWeight)
var showRemaining: Boolean = false // 是否以“剩余/总数”形式显示
/** 件数列显示:首次装货成功前为总件数,之后为“剩余件数/总件数”(剩余 = pc − checkInPc) */
val piecesDisplay: String
get() {
if (!showRemaining) return pieces
val total = pieces.toLongOrNull() ?: return pieces
return "${total - checkInPieces}/$pieces"
}
/** 重量列显示:首次装货成功前为总重量,之后为“剩余重量/总重量”(剩余 = weight − checkInWeight) */
val weightDisplay: String
get() {
if (!showRemaining) return weight
val total = weight.toDoubleOrNull() ?: return weight
return String.format("%.1f", total - checkInWeight) + "/" + weight
}
val fLightInfo: String val fLightInfo: String
get() = "$fno/${fdate.replace("-", "")}" get() = "$fno/${fdate.replace("-", "")}"

View File

@@ -234,7 +234,10 @@ class FlightBean : ICheck {
return when (countryType) { return when (countryType) {
"0" -> "国内" "0" -> "国内"
"1" -> "国际" "1" -> "国际"
else -> "" "2" -> "地区"
"3" -> "混合"
// 部分接口直接返回中文名,原样显示
else -> countryType
} }
} }

View File

@@ -3,6 +3,7 @@ package com.lukouguoji.module_base.bean
import androidx.databinding.BaseObservable import androidx.databinding.BaseObservable
import androidx.databinding.Bindable import androidx.databinding.Bindable
import androidx.databinding.library.baseAdapters.BR import androidx.databinding.library.baseAdapters.BR
import com.lukouguoji.module_base.ktx.showToast
/** /**
* 国际出港计重记录明细Bean * 国际出港计重记录明细Bean
@@ -22,9 +23,9 @@ data class GjcCheckInRecord(
var weight: Double = 0.0, // 运抵重量 var weight: Double = 0.0, // 运抵重量
var volume: Double = 0.0, // 运抵体积 var volume: Double = 0.0, // 运抵体积
var whId: Long = 0, // GJC_WAREHOUSE.ID var whId: Long = 0, // GJC_WAREHOUSE.ID
var carWeight: String = "", // 托盘车重(平板托盘车号反查值) var carWeight: String = "", // 托盘车重
var trayNumber: Int = 0, // 托盘数量 var trayNumber: Int = 0, // 托盘数量
var trayTotalWeight: Double = 0.0, // 托盘重量(托盘数量 × 托盘自重,与平板端一致) var trayTotalWeight: Double = 0.0, // 托盘重量(托盘自重)
var opUserName: String = "" // 计重人姓名(opId 关联查询) var opUserName: String = "" // 计重人姓名(opId 关联查询)
) : BaseObservable() { ) : BaseObservable() {
@@ -48,37 +49,76 @@ data class GjcCheckInRecord(
} }
} }
// 重量的字符串表示(用于双向绑定) // 重量 / 托盘自重编辑过程中的原始输入串(保持回显与输入一致,避免光标跳位)
@get:Bindable // 不作为构造参数,data class copy 备份/恢复时自动丢弃,回退到数值显示
var weightStr: String // @Transient:仅界面内部状态,不参与 Gson 序列化提交
get() = if (weight == 0.0) "" else weight.toString() @Transient
set(value) { private var weightInput: String? = null
val newWeight = value.toDoubleOrNull() ?: 0.0
if (weight != newWeight) {
weight = newWeight
notifyPropertyChanged(BR.weightStr)
onDataChanged?.invoke()
}
}
/**
* 托盘重量输入原文(手机「修改记录」覆层)。保留原文而非由 Double 反推:
* 输入 "1" 时若 getter 返回 "1.0",输入框会被重设、光标回到开头,再输 "8" 就成了 "81"(实机踩过)。
* transient:不参与 Gson 提交序列化。
*/
@Transient @Transient
private var trayTotalWeightInput: String? = null private var trayTotalWeightInput: String? = null
// 托盘重量的字符串表示(用于双向绑定,5.5寸手机版「修改记录」覆层) // 重量的字符串表示(用于双向绑定)
// 业务规则:重量 + 托盘自重 恒定(等于接口返回初始值之和),修改一方另一方反向同步变动
// 校验:任一方不能小于 0,非法输入直接拒绝并回退显示
@get:Bindable
var weightStr: String
get() = weightInput ?: if (weight == 0.0) "" else weight.toString()
set(value) {
val newWeight = value.toDoubleOrNull() ?: 0.0
val newTrayWeight = round2(weight + trayTotalWeight - newWeight)
when {
newWeight < 0 -> {
showToast("重量不能小于 0")
notifyPropertyChanged(BR.weightStr)
}
newTrayWeight < 0 -> {
showToast("重量不能超过 ${round2(weight + trayTotalWeight)},否则托盘自重将小于 0")
notifyPropertyChanged(BR.weightStr)
}
else -> {
weightInput = value
if (weight != newWeight) {
weight = newWeight
trayTotalWeight = newTrayWeight
trayTotalWeightInput = null
notifyPropertyChanged(BR.weightStr)
notifyPropertyChanged(BR.trayTotalWeightStr)
onDataChanged?.invoke()
}
}
}
}
// 托盘自重的字符串表示(用于双向绑定),联动规则同上
@get:Bindable @get:Bindable
var trayTotalWeightStr: String var trayTotalWeightStr: String
get() = trayTotalWeightInput ?: if (trayTotalWeight == 0.0) "" else trayTotalWeight.toString() get() = trayTotalWeightInput ?: if (trayTotalWeight == 0.0) "" else trayTotalWeight.toString()
set(value) { set(value) {
if (trayTotalWeightStr != value) { val newTrayWeight = value.toDoubleOrNull() ?: 0.0
trayTotalWeightInput = value val newWeight = round2(weight + trayTotalWeight - newTrayWeight)
trayTotalWeight = value.toDoubleOrNull() ?: 0.0 when {
notifyPropertyChanged(BR.trayTotalWeightStr) newTrayWeight < 0 -> {
onDataChanged?.invoke() showToast("托盘自重不能小于 0")
notifyPropertyChanged(BR.trayTotalWeightStr)
}
newWeight < 0 -> {
showToast("托盘自重不能超过 ${round2(weight + trayTotalWeight)},否则重量将小于 0")
notifyPropertyChanged(BR.trayTotalWeightStr)
}
else -> {
trayTotalWeightInput = value
if (trayTotalWeight != newTrayWeight) {
trayTotalWeight = newTrayWeight
weight = newWeight
weightInput = null
notifyPropertyChanged(BR.trayTotalWeightStr)
notifyPropertyChanged(BR.weightStr)
onDataChanged?.invoke()
}
}
} }
} }
private fun round2(v: Double): Double = Math.round(v * 100) / 100.0
} }

View File

@@ -1,6 +1,7 @@
package com.lukouguoji.module_base.bean package com.lukouguoji.module_base.bean
import androidx.databinding.ObservableBoolean import androidx.databinding.ObservableBoolean
import com.lukouguoji.module_base.ktx.toPlainText
import java.io.Serializable import java.io.Serializable
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -45,6 +46,7 @@ data class GjcMaWb(
// ==================== 货物信息 ==================== // ==================== 货物信息 ====================
var pc: Long? = null, // 预配件数 var pc: Long? = null, // 预配件数
var pcLabel: String? = null, // 件数展示文本(国际进港仓库分页返回,如 0/8、44)
var weight: Double? = null, // 预配重量(KG) var weight: Double? = null, // 预配重量(KG)
var volume: Double? = null, // 预配体积(CBM) var volume: Double? = null, // 预配体积(CBM)
var goods: String? = null, // 品名(英) var goods: String? = null, // 品名(英)
@@ -58,6 +60,8 @@ data class GjcMaWb(
var arriveWeight: Double? = null, // 运抵重量 var arriveWeight: Double? = null, // 运抵重量
var arriveVolume: Double? = null, // 运抵体积 var arriveVolume: Double? = null, // 运抵体积
var arriveFlag: String? = null, // 运抵状态(0:正常运抵,1:提前运抵) var arriveFlag: String? = null, // 运抵状态(0:正常运抵,1:提前运抵)
var trayNumber: Int? = null, // 托盘数量
var trayTotalWeight: Double? = null, // 托盘重量(托盘数量 × 托盘自重)
// ==================== 特码与收货人 ==================== // ==================== 特码与收货人 ====================
var spCode: String? = null, // 特码 var spCode: String? = null, // 特码
@@ -76,15 +80,16 @@ data class GjcMaWb(
var carNumber: String? = null, // 车牌号 var carNumber: String? = null, // 车牌号
var passageWay: String? = null, // 通道号 var passageWay: String? = null, // 通道号
var passageWayId: String? = null, var passageWayId: String? = null,
var trayNumber: Int? = null, // 托盘数量(splitCheckIn / completeCheckIn 入参,与平板端一致)
var trayTotalWeight: Double? = null, // 托盘重量 = 托盘数量 × 托盘自重
// ==================== 状态信息 ==================== // ==================== 状态信息 ====================
var checkIn: String? = null, // 收运状态。0:待收运,1:已收运,2:收运中 var checkIn: String? = null, // 收运状态。0:待收运,1:已收运,2:收运中
var declareStatus: String? = null, // 申报状态 var declareStatus: String? = null, // 申报状态
var cmdStatus: String? = null, // 海关放行(命令状态)
var reviewStatus: String? = null, // 审核状态(0:未审核;1:通过;2:退回) var reviewStatus: String? = null, // 审核状态(0:未审核;1:通过;2:退回)
var tranFlag: String? = null, // 转运标志 var tranFlag: String? = null, // 转运标志
var clearNormal: String? = null, // 清仓正常(0:否,1:是) var clearNormal: String? = null, // 清仓正常(0:否,1:是)
var clearRemark: String? = null, // 清仓备注
var allTally: String? = null, // 运单理货申报全部通过(0:否;1:是),为 1 时才允许全选该主单下的分单
// ==================== 操作信息 ==================== // ==================== 操作信息 ====================
var opDate: String? = null, // 操作时间(入库时间) var opDate: String? = null, // 操作时间(入库时间)
@@ -94,6 +99,9 @@ data class GjcMaWb(
// ==================== 备注与其他 ==================== // ==================== 备注与其他 ====================
var remark: String? = null, // 备注 var remark: String? = null, // 备注
var pic: String? = null, // 交接图片缩略图路径(逗号分隔)
var originalPic: String? = null, // 交接图片原图路径(逗号分隔)
var picNumber: String? = null, // 交接图片数量
var ffmMemo: String? = null, // ffm备注 var ffmMemo: String? = null, // ffm备注
var mftMemo: String? = null, // mft备注 var mftMemo: String? = null, // mft备注
var unNumber: String? = null, // UN编号 var unNumber: String? = null, // UN编号
@@ -131,6 +139,13 @@ data class GjcMaWb(
get() = checked.get() get() = checked.get()
set(value) = checked.set(value) set(value) = checked.set(value)
/**
* 是否允许全选该主单下的分单
* 主单存在分批申报(未全部申报完毕)时接口返回 allTally=0,此时不允许全选其分单
*/
val canCheckAllHaWb: Boolean
get() = allTally == "1"
/** /**
* 5.5寸手机版:申报状态三色标签的着色分类(01=绿/W=靛蓝/其余非空=琥珀)。 * 5.5寸手机版:申报状态三色标签的着色分类(01=绿/W=靛蓝/其余非空=琥珀)。
*/ */
@@ -162,6 +177,13 @@ data class GjcMaWb(
val agentDisplay: String? val agentDisplay: String?
get() = if (agentName.isNullOrEmpty()) agentCode else agentName get() = if (agentName.isNullOrEmpty()) agentCode else agentName
/**
* 5.5寸手机版-进港仓库:始发站展示值。IntImpStorage/pageQuery 不返回 dep/fdep,
* 始发站在 origin(货源地)中(Bug #635 实测)。
*/
val depDisplay: String?
get() = if (dep.isNullOrEmpty()) origin else dep
/** /**
* 5.5寸手机版-出港仓库:卡片状态标签(已清仓=绿 / 未清仓=橙,clearNormal 为空视作未清仓)。 * 5.5寸手机版-出港仓库:卡片状态标签(已清仓=绿 / 未清仓=橙,clearNormal 为空视作未清仓)。
* 已清仓时卡片「清仓」按钮置灰不可点。 * 已清仓时卡片「清仓」按钮置灰不可点。
@@ -368,12 +390,21 @@ data class GjcStorageUse(
var outDate: String? = null, // 出库时间 var outDate: String? = null, // 出库时间
var outOpId: String? = null, // 出库人 var outOpId: String? = null, // 出库人
var outId: String? = null, // 出库人(兼容字段) var outId: String? = null, // 出库人(兼容字段)
var cargoStatus: String? = null // 货物状态 var cargoStatus: String? = null, // 货物状态
var inPc: Long? = null, // 入库件数
var inWeight: Double? = null, // 入库重量
var outPc: Long? = null, // 出库件数
var outWeight: Double? = null // 出库重量
) { ) {
// ==================== UI扩展字段 ==================== // ==================== UI扩展字段 ====================
@Transient @Transient
val checked: ObservableBoolean = ObservableBoolean(false) // 选中状态 val checked: ObservableBoolean = ObservableBoolean(false) // 选中状态
val inPcText: String get() = inPc?.toString() ?: ""
val inWeightText: String get() = inWeight?.toPlainText() ?: ""
val outPcText: String get() = outPc?.toString() ?: ""
val outWeightText: String get() = outWeight?.toPlainText() ?: ""
// 兼容现有API的isSelected属性 // 兼容现有API的isSelected属性
var isSelected: Boolean var isSelected: Boolean
get() = checked.get() get() = checked.get()

View File

@@ -0,0 +1,29 @@
package com.lukouguoji.module_base.bean
/**
* 国际出港移库详情数据模型
* 对应接口:/IntExpMove/detail 返回的 data,/IntExpMove/update 的请求体
*/
data class GjcMoveDetail(
var maWbId: Long? = null, // GJC_MAWB.MAWBID
var wbNo: String? = null, // 运单号
var pc: Long? = null, // 件数
var weight: Double? = null, // 重量
var by: String? = null, // 承运人1
var awbTypeName: String? = null, // 运单类型(中文)
var range: String? = null, // 出运路径
var spCode: String? = null, // 特码
var agentCode: String? = null, // 代理人
var moveState: Int? = null, // 交接状态:0 未交接,1 已交接
var goods: String? = null, // 品名
var remark: String? = null, // 备注
var pic: String? = null, // 交接图片缩略图路径(逗号分隔)
var originalPic: String? = null, // 交接图片原图路径(逗号分隔)
var picNumber: String? = null // 交接图片数量
) {
/**
* 移交状态中文(与列表项文案一致)
*/
val moveStateText: String
get() = if (moveState == 1) "已移交" else "未移交"
}

View File

@@ -51,6 +51,7 @@ class GjcUldUseBean : Serializable {
var uldFlag: String = "" // uld分类 var uldFlag: String = "" // uld分类
var status: String = "" // uld状态:0:正常,1:故障 var status: String = "" // uld状态:0:正常,1:故障
var dgrCode: String = "" // IMP代码 var dgrCode: String = "" // IMP代码
var bupFlag: String = "" // BUP选项(0:否;1:是)
var height: String = "" // 高度 var height: String = "" // 高度
var passageway: String = "" // 通道号 var passageway: String = "" // 通道号
var passagewayId: String = "" var passagewayId: String = ""
@@ -65,6 +66,19 @@ class GjcUldUseBean : Serializable {
var loadArea: String = "" // 组装区 var loadArea: String = "" // 组装区
var pc: Long = 0 // 组装件数 var pc: Long = 0 // 组装件数
var allowLoad: String = "" // 允许装机(0:不允许;1:允许)
// 是否允许装机
val isAllowLoad: Boolean
get() = allowLoad == "1"
// 装机状态文本
val allowLoadText: String
get() = when (allowLoad) {
"1" -> "允许装机"
"0" -> "不允许装机"
else -> ""
}
var fillWeightFlag: String = "" // 回填状态:0-未回填,1-部分回填,2-已回填 var fillWeightFlag: String = "" // 回填状态:0-未回填,1-部分回填,2-已回填
// 回填状态文本 // 回填状态文本

View File

@@ -54,9 +54,14 @@ class GjjWarehouse(
var splitFlag: String? = "", var splitFlag: String? = "",
var inDate: String? = "", var inDate: String? = "",
var clearNormal: String? = "", var clearNormal: String? = "",
var range: String? = "" var range: String? = "",
var fdep: String? = "" // 始发站(详情接口 warehouseList 返回 fdep,dep 恒为空)
) { ) {
/** 始发站展示:优先 fdep,兜底 dep / origin(Bug #635) */
val depDisplay: String
get() = listOf(fdep, dep, origin).firstOrNull { !it.isNullOrEmpty() } ?: ""
fun getWaybillCode() = "${prefix}${no}" fun getWaybillCode() = "${prefix}${no}"
fun getFlightDesc() = "${fdate}/${fno}" fun getFlightDesc() = "${fdate}/${fno}"

View File

@@ -0,0 +1,36 @@
package com.lukouguoji.module_base.bean
import androidx.databinding.ObservableBoolean
import com.lukouguoji.module_base.interfaces.ICheck
import java.io.Serializable
/**
* 国际进港装机单 ULD 主单 Bean
* 对应接口:/IntImpManifest/pageQueryAir 返回的列表项(按 ULD 分组,manifestList 为关联运单)
*/
data class GjjLoadingUld(
var uld: String? = null, // ULD 编号
var flight: String? = null, // 航班信息
var totalPc: Long? = null, // 总件数
var totalWeight: Double? = null, // 总重量
var manifestList: List<GjjManifest>? = null, // 关联运单列表
// 展开/收起状态
@Transient
val showMore: ObservableBoolean = ObservableBoolean(false),
// 选中状态
@Transient
val checked: ObservableBoolean = ObservableBoolean(false)
) : Serializable, ICheck {
override fun getCheckObservable(): ObservableBoolean = checked
var isSelected: Boolean
get() = checked.get()
set(value) = checked.set(value)
/** 总重量显示(取整) */
val totalWeightText: String
get() = ((totalWeight ?: 0.0).toInt()).toString()
}

View File

@@ -12,6 +12,8 @@ data class GjjManifest(
var no: String = "", // 编号 var no: String = "", // 编号
var prefix: String = "", // 前缀 var prefix: String = "", // 前缀
var wbNo: String = "", // 运单号 var wbNo: String = "", // 运单号
var fdate: String = "", // 航班日期
var fno: String = "", // 航班号
var origin: String = "", // 货源地(始发站) var origin: String = "", // 货源地(始发站)
var dest: String = "", // 目的站 var dest: String = "", // 目的站
var fdep: String = "", // 航班起始站 var fdep: String = "", // 航班起始站
@@ -58,6 +60,7 @@ data class GjjManifest(
var unNumber: String = "", // 危险品编号 var unNumber: String = "", // 危险品编号
var activeId: Long = 0, // 活动ID var activeId: Long = 0, // 活动ID
var locationTally: String = "", // 理货库位号 var locationTally: String = "", // 理货库位号
var locationTallyId: Long = 0, // 理货库位ID
var pic: String = "", // 交接图片缩略图路径 var pic: String = "", // 交接图片缩略图路径
var originalPic: String = "", // 交接图片原图地址 var originalPic: String = "", // 交接图片原图地址
var picNumber: String = "" // 交接图片数量 var picNumber: String = "" // 交接图片数量

View File

@@ -1,5 +1,6 @@
package com.lukouguoji.module_base.bean package com.lukouguoji.module_base.bean
import com.lukouguoji.module_base.ktx.toPlainText
import java.io.Serializable import java.io.Serializable
/** /**
@@ -19,5 +20,14 @@ data class GjjStorageUse(
var outDate: String? = null, // 出库时间 var outDate: String? = null, // 出库时间
var outOpId: String? = null, // 出库人ID var outOpId: String? = null, // 出库人ID
var outOpName: String? = null, // 出库人姓名 var outOpName: String? = null, // 出库人姓名
var cargoStatus: String? = null // 货物状态 var cargoStatus: String? = null, // 货物状态
) : Serializable var inPc: Long? = null, // 入库件数
var inWeight: Double? = null, // 入库重量
var outPc: Long? = null, // 出库件数
var outWeight: Double? = null // 出库重量
) : Serializable {
val inPcText: String get() = inPc?.toString() ?: ""
val inWeightText: String get() = inWeight?.let { "${it.toPlainText()} kg" } ?: ""
val outPcText: String get() = outPc?.toString() ?: ""
val outWeightText: String get() = outWeight?.let { "${it.toPlainText()} kg" } ?: ""
}

View File

@@ -11,7 +11,7 @@ data class ManifestTotalDto(
var cargoWeight: Double = 0.0, // 总货重 var cargoWeight: Double = 0.0, // 总货重
// ===== 后端统一统计 DTO 的分项计数(2026-09 后端补齐,按接口各取所需) ===== // ===== 后端统一统计 DTO 的分项计数(2026-09 后端补齐,按接口各取所需) =====
var flightNumber: Int = 0, // 航班数 var flightNumber: Int = 0, // 航班航次
var uldNumber: Int = 0, // 板箱数(出库交接 Tab 角标) var uldNumber: Int = 0, // 板箱数(出库交接 Tab 角标)
var storedNumber: Int = 0, // 已入库票数(出港/进港查询) var storedNumber: Int = 0, // 已入库票数(出港/进港查询)
var departedNumber: Int = 0, // 已离港(出港)/ 已出库(进港)票数 var departedNumber: Int = 0, // 已离港(出港)/ 已出库(进港)票数

View File

@@ -13,4 +13,7 @@ class UldInfoBean {
var totalWeight: String = "" // 总重量 var totalWeight: String = "" // 总重量
var status: String = "" // 状态(旧字段,保留兼容) var status: String = "" // 状态(旧字段,保留兼容)
var useId: Long? = null // ULD使用ID(来自getUld接口) var useId: Long? = null // ULD使用ID(来自getUld接口)
var dgrCode: String = "" // IMP代码
var boardType: String = "" // 板型(可下拉选择,也可手动输入)
var bupFlag: String = "0" // BUP选项(0:否;1:是),默认否
} }

View File

@@ -348,6 +348,9 @@ interface Constant {
// 数据 // 数据
const val DATA = "data" const val DATA = "data"
// 页面标题
const val TITLE = "title"
const val POSITION = "position" const val POSITION = "position"
// 单号 // 单号

View File

@@ -37,6 +37,7 @@ import com.lukouguoji.module_base.bean.GjcHandoverSheetResponse
import com.lukouguoji.module_base.bean.GjcInspectionBean import com.lukouguoji.module_base.bean.GjcInspectionBean
import com.lukouguoji.module_base.bean.GjcMaWb import com.lukouguoji.module_base.bean.GjcMaWb
import com.lukouguoji.module_base.bean.GjcMove import com.lukouguoji.module_base.bean.GjcMove
import com.lukouguoji.module_base.bean.GjcMoveDetail
import com.lukouguoji.module_base.bean.GjcUldUseBean import com.lukouguoji.module_base.bean.GjcUldUseBean
import com.lukouguoji.module_base.bean.GjcWarehouse import com.lukouguoji.module_base.bean.GjcWarehouse
import com.lukouguoji.module_base.bean.GjcWaybillBean import com.lukouguoji.module_base.bean.GjcWaybillBean
@@ -56,9 +57,11 @@ import com.lukouguoji.module_base.bean.IntImpAccidentVisaBean
import com.lukouguoji.module_base.bean.IntImpPickUpDLVBean import com.lukouguoji.module_base.bean.IntImpPickUpDLVBean
import com.lukouguoji.module_base.bean.IntImpPickUpRecordBean import com.lukouguoji.module_base.bean.IntImpPickUpRecordBean
import com.lukouguoji.module_base.bean.IntImpQueryBean import com.lukouguoji.module_base.bean.IntImpQueryBean
import com.lukouguoji.module_base.bean.GjjLoadingUld
import com.lukouguoji.module_base.bean.GjjManifest import com.lukouguoji.module_base.bean.GjjManifest
import com.lukouguoji.module_base.bean.GjjManifestBean import com.lukouguoji.module_base.bean.GjjManifestBean
import com.lukouguoji.module_base.bean.GjjPackTypeBean import com.lukouguoji.module_base.bean.GjjPackTypeBean
import com.lukouguoji.module_base.bean.GjjStorageUse
import com.lukouguoji.module_base.bean.GjjTallyBean import com.lukouguoji.module_base.bean.GjjTallyBean
import com.lukouguoji.module_base.bean.GjjTallyDetailsBean import com.lukouguoji.module_base.bean.GjjTallyDetailsBean
import com.lukouguoji.module_base.bean.GjjTallyRecordBean import com.lukouguoji.module_base.bean.GjjTallyRecordBean
@@ -147,6 +150,14 @@ interface Api {
@POST @POST
suspend fun singleStringPost(@Url url: String): BaseResultBean<String> suspend fun singleStringPost(@Url url: String): BaseResultBean<String>
/**
* 获取海关回执(国际进港原始舱单/进港理货/出港运抵/出港装载/出港理货 共用)
* url:XXX/getResponse,body 传 prefix、no 与拼接的 wbNo(后端实测按 prefix + no 匹配),
* 回执文本实测在 msg 字段、data 为 null,取值见 CustomsResponseKtx.showCustomsResponse
*/
@POST
suspend fun getCustomsResponse(@Url url: String, @Body data: RequestBody): BaseResultBean<String>
@GET @GET
suspend fun anyGetForId(@Url url: String, @Query("id") id: String): BaseResultBean<Any> suspend fun anyGetForId(@Url url: String, @Query("id") id: String): BaseResultBean<Any>
@@ -302,6 +313,24 @@ interface Api {
@POST("typeCode/countryType") @POST("typeCode/countryType")
suspend fun getCountryTypeList(): DictListBean suspend fun getCountryTypeList(): DictListBean
/**
* 获取飞行状态
*/
@POST("typeCode/flightPortCode")
suspend fun getFlightPortCodeList(): DictListBean
/**
* 获取航班服务种类
*/
@POST("typeCode/flightServiceType")
suspend fun getFlightServiceTypeList(): DictListBean
/**
* 获取航班状态
*/
@POST("typeCode/flightStatus")
suspend fun getFlightStatusList(): DictListBean
/** /**
* 获取国家代码 * 获取国家代码
*/ */
@@ -564,6 +593,14 @@ interface Api {
@POST("typeCode/assembleCompany") @POST("typeCode/assembleCompany")
suspend fun getAssembleCompanyList(): BaseResultBean<List<AssembleCompanyBean>> suspend fun getAssembleCompanyList(): BaseResultBean<List<AssembleCompanyBean>>
/**
* 获取板型下拉列表(国际出港组装-开始组装)
* 接口路径: /typeCode/bordType(后端路径即为 bordType,非 boardType,2026-09-11 实测 boardType 返回 404)
* @param carrier 承运人二字码(取待组装运单航班号前两位);该参数后端必填(缺省返回 400),进入页面时传空串
*/
@POST("typeCode/bordType")
suspend fun getBoardTypeList(@Query("carrier") carrier: String): DictListBean
/** /**
* 国际出港板箱过磅-分页搜索 * 国际出港板箱过磅-分页搜索
* 接口路径: /IntExpWeighting/pageQuery * 接口路径: /IntExpWeighting/pageQuery
@@ -657,7 +694,8 @@ interface Api {
* 国际出港组装 - 根据全ULD查询ULD状态和耗材重量 * 国际出港组装 - 根据全ULD查询ULD状态和耗材重量
* 接口路径: /IntExpAssemble/getUld * 接口路径: /IntExpAssemble/getUld
* @param uld ULD编号 * @param uld ULD编号
* @return ULD信息,包含status(0:正常,1:故障)和consumeWeight(耗材重量) * @return ULD信息,包含status(0:正常,1:故障)、consumeWeight(耗材重量)、
* dgrCode(IMP代码)、boardType(板型)、bupFlag(BUP选项 0:否;1:是)
*/ */
@GET("IntExpAssemble/getUld") @GET("IntExpAssemble/getUld")
suspend fun getUldWithConsumeWeight(@Query("uld") uld: String): BaseResultBean<GjcUldUseBean> suspend fun getUldWithConsumeWeight(@Query("uld") uld: String): BaseResultBean<GjcUldUseBean>
@@ -666,7 +704,8 @@ interface Api {
* 国际出港组装 - 卸货 * 国际出港组装 - 卸货
* 接口路径: /IntExpAssemble/drop * 接口路径: /IntExpAssemble/drop
* @param data 请求参数:abPc(组装件数)、abWeight(组装重量)、consumeWeight(耗材重量)、 * @param data 请求参数:abPc(组装件数)、abWeight(组装重量)、consumeWeight(耗材重量)、
* ldId(组装人)、loadArea(组装区)、useInfo(ULD信息)、wbInfo(运单信息)、userId(用户ID) * ldId(组装人)、loadArea(组装区)、useInfo(ULD信息)、wbInfo(运单信息)、userId(用户ID)、
* dgrCode(IMP代码)、boardType(板型)、bupFlag(BUP选项 0:否;1:是)
*/ */
@POST("IntExpAssemble/drop") @POST("IntExpAssemble/drop")
suspend fun assembleDropCargo(@Body data: RequestBody): BaseResultBean<GjcWarehouse> suspend fun assembleDropCargo(@Body data: RequestBody): BaseResultBean<GjcWarehouse>
@@ -675,7 +714,8 @@ interface Api {
* 国际出港组装 - 装货 * 国际出港组装 - 装货
* 接口路径: /IntExpAssemble/assemble * 接口路径: /IntExpAssemble/assemble
* @param data 请求参数:abPc(组装件数)、abWeight(组装重量)、consumeWeight(耗材重量)、 * @param data 请求参数:abPc(组装件数)、abWeight(组装重量)、consumeWeight(耗材重量)、
* ldId(组装人)、loadArea(组装区)、useInfo(ULD信息)、wbInfo(运单信息)、userId(用户ID) * ldId(组装人)、loadArea(组装区)、useInfo(ULD信息)、wbInfo(运单信息)、userId(用户ID)、
* dgrCode(IMP代码)、boardType(板型)、bupFlag(BUP选项 0:否;1:是)
*/ */
@POST("IntExpAssemble/assemble") @POST("IntExpAssemble/assemble")
suspend fun assembleLoadCargo(@Body data: RequestBody): BaseResultBean<GjcWarehouse> suspend fun assembleLoadCargo(@Body data: RequestBody): BaseResultBean<GjcWarehouse>
@@ -737,6 +777,14 @@ interface Api {
@POST("IntExpOutHandover/waitingTrans") @POST("IntExpOutHandover/waitingTrans")
suspend fun waitingTrans(@Body data: RequestBody): BaseResultBean<String> suspend fun waitingTrans(@Body data: RequestBody): BaseResultBean<String>
/**
* 国际出港出库交接-允许装机
* 接口路径: /IntExpOutHandover/allow
* @param data 请求参数:选中的ULD列表(GjcUldUseBean 数组)
*/
@POST("IntExpOutHandover/allow")
suspend fun allowLoading(@Body data: RequestBody): BaseResultBean<Boolean>
/** /**
* 国际出港-出港装载 分页查询 * 国际出港-出港装载 分页查询
* 接口路径: /IntExpLoad/pageQuery * 接口路径: /IntExpLoad/pageQuery
@@ -882,6 +930,20 @@ interface Api {
@POST("IntExpMove/move") @POST("IntExpMove/move")
suspend fun submitIntExpMove(@Body data: RequestBody): BaseResultBean<SimpleResultBean> suspend fun submitIntExpMove(@Body data: RequestBody): BaseResultBean<SimpleResultBean>
/**
* 国际出港移库-详情
* 接口路径: /IntExpMove/detail
*/
@POST("IntExpMove/detail")
suspend fun getIntExpMoveDetail(@Query("maWbId") maWbId: Long): BaseResultBean<GjcMoveDetail>
/**
* 国际出港移库-编辑(仅更新备注与交接图片)
* 接口路径: /IntExpMove/update
*/
@POST("IntExpMove/update")
suspend fun updateIntExpMove(@Body data: RequestBody): BaseResultBean<String>
/** /**
* 国际出港库位操作-清仓 * 国际出港库位操作-清仓
* 接口路径: /IntExpStorageUse/updateClear * 接口路径: /IntExpStorageUse/updateClear
@@ -958,7 +1020,7 @@ interface Api {
* 接口路径: /IntImpStorage/updateClear * 接口路径: /IntImpStorage/updateClear
*/ */
@POST("IntImpStorage/updateClear") @POST("IntImpStorage/updateClear")
suspend fun clearIntImpStorage(@Query("clearNormal") clearNormal: String, @Body data: RequestBody): BaseResultBean<Boolean> suspend fun clearIntImpStorage(@Body data: RequestBody): BaseResultBean<Boolean>
/** /**
* 国际进港库位操作-修改库位 * 国际进港库位操作-修改库位
@@ -1401,6 +1463,24 @@ interface Api {
@POST("flt/listFlightDest") @POST("flt/listFlightDest")
suspend fun listFlightDest(@Body data: RequestBody): BaseResultBean<List<String>> suspend fun listFlightDest(@Body data: RequestBody): BaseResultBean<List<String>>
/**
* 航班管理-新增航班
*/
@POST("flt/saveFlight")
suspend fun saveFlight(@Body data: RequestBody): BaseResultBean<String>
/**
* 航班管理-修改航班
*/
@POST("flt/updateFlight")
suspend fun updateFlight(@Body data: RequestBody): BaseResultBean<String>
/**
* 航班管理-删除航班(请求体为航班对象数组)
*/
@POST("flt/delete")
suspend fun deleteFlightList(@Body data: RequestBody): BaseResultBean<String>
/** /**
* 新增或修航司屏蔽 * 新增或修航司屏蔽
*/ */
@@ -1914,6 +1994,14 @@ interface Api {
@POST("IntImpManifest/pageQueryTotal") @POST("IntImpManifest/pageQueryTotal")
suspend fun getIntImpManifestTotal(@Body data: RequestBody): BaseResultBean<ManifestTotalDto> suspend fun getIntImpManifestTotal(@Body data: RequestBody): BaseResultBean<ManifestTotalDto>
/**
* 国际进港舱单-根据11位具体运单号查询运单信息
* 接口路径: /IntImpManifest/queryWbByNo
* @param wbNo 11位运单号
*/
@POST("IntImpManifest/queryWbByNo")
suspend fun getIntImpManifestByWbNo(@Query("wbNo") wbNo: String): BaseResultBean<GjjManifest>
/** /**
* 国际进港舱单-获取主单下分单 * 国际进港舱单-获取主单下分单
*/ */
@@ -1939,10 +2027,10 @@ interface Api {
suspend fun getMaWbMinusHaWb(@Body data: RequestBody): BaseResultBean<GjjHaWb> suspend fun getMaWbMinusHaWb(@Body data: RequestBody): BaseResultBean<GjjHaWb>
/** /**
* 国际进港舱单-分拣理货(装机单)-分页查询 * 国际进港舱单-分拣理货(装机单)-分页查询(按 ULD 分组,manifestList 为关联运单)
*/ */
@POST("IntImpManifest/pageQueryAir") @POST("IntImpManifest/pageQueryAir")
suspend fun getIntImpLoadingList(@Body data: RequestBody): PageInfo<GjjManifest> suspend fun getIntImpLoadingList(@Body data: RequestBody): PageInfo<GjjLoadingUld>
/** /**
* 国际进港舱单-分拣理货(装机单)-分页合计 * 国际进港舱单-分拣理货(装机单)-分页合计
@@ -1970,6 +2058,16 @@ interface Api {
@POST("IntImpManifest/inStorage") @POST("IntImpManifest/inStorage")
suspend fun inIntImpLoadingStorage(@Body data: RequestBody): BaseResultBean<Boolean> suspend fun inIntImpLoadingStorage(@Body data: RequestBody): BaseResultBean<Boolean>
/**
* 国际进港舱单-获取运单理货库位
* 接口路径: /IntImpManifest/getSortingStorage
*/
@POST("IntImpManifest/getSortingStorage")
suspend fun getIntImpSortingStorage(
@Query("fid") fid: Long,
@Query("wbNo") wbNo: String
): BaseResultBean<GjjStorageUse>
/** /**
* 国际进港理货报告-分页查询 * 国际进港理货报告-分页查询
*/ */
@@ -2070,6 +2168,12 @@ interface Api {
@POST("typeCode/unusualType") @POST("typeCode/unusualType")
suspend fun getUnusualTypeList(): DictListBean suspend fun getUnusualTypeList(): DictListBean
/**
* 字典-发现时间类型(接收时 / 分拣理货时 / 提货时 …)
*/
@POST("typeCode/searchDateType")
suspend fun getSearchDateTypeList(): DictListBean
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// ULD管理 // ULD管理
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////

View File

@@ -0,0 +1,53 @@
package com.lukouguoji.module_base.ktx
import com.lukouguoji.module_base.base.BaseViewModel
import com.lukouguoji.module_base.http.net.NetApply
import com.lukouguoji.module_base.ui.page.customs.CustomsResponseActivity
/**
* 海关回执接口地址(各页面对应各自模块的 getResponse)
*/
object CustomsResponseUrl {
const val INT_IMP_AIR_MANIFEST = "IntImpAirManifest/getResponse" // 国际进港原始舱单
const val INT_IMP_TALLY = "IntImpTally/getResponse" // 国际进港理货
const val INT_EXP_ARRIVE = "IntExpArrive/getResponse" // 国际出港运抵
const val INT_EXP_LOAD = "IntExpLoad/getResponse" // 国际出港装载
const val INT_EXP_TALLY = "IntExpTally/getResponse" // 国际出港理货
}
/**
* 列表项侧滑「回执」按钮的 onItemClick type
* 现有取值为 0/1/2、101~103、1000~3000 及 view id,9001 与之均不冲突
*/
const val ITEM_CLICK_TYPE_CUSTOMS_RESPONSE = 9001
/**
* 查询海关回执并跳转只读详情页
* - 带 Loading 调用 [url],本期只查主单,不传 hno
* - 请求体同时传 prefix、no 与拼接后的 wbNo:测试环境实测后端按 prefix + no 匹配,
* 只传 wbNo 会查不到(与文档「运单号必填」描述不一致)
* - 回执文本实测放在 msg 字段(data 恒为 null),这里优先取 data、为空再取 msg,兼容两种返回
* - 回执为空时提示「暂无海关回执」,不跳转
* - 接口失败由 launchLoadingCollect 默认 Toast 错误信息
*/
fun BaseViewModel.showCustomsResponse(url: String, prefix: String?, no: String?, title: String) {
if (prefix.isNullOrEmpty() || no.isNullOrEmpty()) {
showToast("运单号为空")
return
}
val params = mapOf(
"wbNo" to prefix + no,
"prefix" to prefix,
"no" to no
).toRequestBody()
launchLoadingCollect({ NetApply.api.getCustomsResponse(url, params) }) {
onSuccess = { result ->
val content = result.data?.ifEmpty { null } ?: result.msg ?: ""
if (content.isEmpty()) {
showToast("暂无海关回执")
} else {
CustomsResponseActivity.start(getTopActivity(), title, content)
}
}
}
}

View File

@@ -14,3 +14,9 @@ fun Number.limit(count: Int = 2): String {
} }
return DecimalFormat(pattern.toString()).format(this) return DecimalFormat(pattern.toString()).format(this)
} }
/**
* 去掉无意义的小数位:12.0 -> "12",12.50 -> "12.5"
*/
fun Double.toPlainText(): String =
if (this % 1.0 == 0.0) toLong().toString() else toBigDecimal().stripTrailingZeros().toPlainString()

View File

@@ -143,6 +143,7 @@ object ARouterConstants {
const val ACTIVITY_URL_INT_EXP_ASSEMBLE = "/gjc/IntExpAssembleActivity" //国际出港 出港组装 const val ACTIVITY_URL_INT_EXP_ASSEMBLE = "/gjc/IntExpAssembleActivity" //国际出港 出港组装
const val ACTIVITY_URL_INT_EXP_ASSEMBLE_START = "/gjc/IntExpAssembleStartActivity" //国际出港 开始组装 const val ACTIVITY_URL_INT_EXP_ASSEMBLE_START = "/gjc/IntExpAssembleStartActivity" //国际出港 开始组装
const val ACTIVITY_URL_INT_EXP_MOVE = "/gjc/IntExpMoveActivity" //国际出港 出港移库 const val ACTIVITY_URL_INT_EXP_MOVE = "/gjc/IntExpMoveActivity" //国际出港 出港移库
const val ACTIVITY_URL_INT_EXP_MOVE_EDIT = "/gjc/IntExpMoveEditActivity" //国际出港 出港移库编辑/详情
const val ACTIVITY_URL_GJC_ASSEMBLE_ALLOCATE = "/gjc/GjcAssembleAllocateActivity" //国际出港 组装分配 const val ACTIVITY_URL_GJC_ASSEMBLE_ALLOCATE = "/gjc/GjcAssembleAllocateActivity" //国际出港 组装分配
const val ACTIVITY_URL_INT_EXP_OUT_HANDOVER = "/gjc/IntExpOutHandoverActivity" //国际出港 出库交接 const val ACTIVITY_URL_INT_EXP_OUT_HANDOVER = "/gjc/IntExpOutHandoverActivity" //国际出港 出库交接

View File

@@ -0,0 +1,46 @@
package com.lukouguoji.module_base.ui.page.customs
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.lukouguoji.module_base.R
import com.lukouguoji.module_base.base.BaseBindingActivity
import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.databinding.ActivityCustomsResponseBinding
import com.lukouguoji.module_base.impl.EmptyViewModel
/**
* 海关回执详情页(只读)
* 回执文本由 Intent 传入,页面内不调接口;UI 参照国际进港电报详情页
*/
class CustomsResponseActivity :
BaseBindingActivity<ActivityCustomsResponseBinding, EmptyViewModel>() {
override fun layoutId() = R.layout.activity_customs_response
override fun viewModelClass() = EmptyViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow(intent.getStringExtra(Constant.Key.TITLE) ?: "海关回执")
binding.etContent.apply {
setText(intent.getStringExtra(Constant.Key.DATA) ?: "")
// 只读:不可输入、无光标,但仍可长按选择复制
keyListener = null
isFocusable = false
isFocusableInTouchMode = false
isCursorVisible = false
}
}
companion object {
@JvmStatic
fun start(context: Context, title: String, content: String) {
context.startActivity(
Intent(context, CustomsResponseActivity::class.java)
.putExtra(Constant.Key.TITLE, title)
.putExtra(Constant.Key.DATA, content)
)
}
}
}

View File

@@ -211,7 +211,7 @@ class PadDataLayout : FrameLayout {
setOnClickListener(null) setOnClickListener(null)
} }
DataLayoutType.DATE -> { DataLayoutType.DATE, DataLayoutType.DATETIME -> {
tv.visibility = VISIBLE tv.visibility = VISIBLE
et.visibility = GONE et.visibility = GONE
spinner.visibility = GONE spinner.visibility = GONE
@@ -258,4 +258,5 @@ enum class DataLayoutType {
INPUT, // 文本 INPUT, // 文本
SPINNER, // 下拉选择 SPINNER, // 下拉选择
DATE, // 日期 DATE, // 日期
DATETIME, // 日期时间(yyyy-MM-dd HH:mm:ss)
} }

View File

@@ -25,6 +25,7 @@ import com.lukouguoji.module_base.adapter.visible
import com.lukouguoji.module_base.interfaces.IOnFocusChangeListener import com.lukouguoji.module_base.interfaces.IOnFocusChangeListener
import com.lukouguoji.module_base.interfaces.IOnSpinnerSelected import com.lukouguoji.module_base.interfaces.IOnSpinnerSelected
import com.lukouguoji.module_base.ktx.formatDate import com.lukouguoji.module_base.ktx.formatDate
import com.lukouguoji.module_base.ktx.formatDateTime
import com.lukouguoji.module_base.ktx.getActivity import com.lukouguoji.module_base.ktx.getActivity
import com.lukouguoji.module_base.ktx.loge import com.lukouguoji.module_base.ktx.loge
import com.lukouguoji.module_base.util.Common import com.lukouguoji.module_base.util.Common
@@ -182,6 +183,18 @@ class PadDataLayoutNew : FrameLayout {
} }
} }
// 选择日期时间(yyyy-MM-dd HH:mm:ss)
private val dateTimeClick: (v: View) -> Unit = {
if (enable) {
Common.onYearMonthDayTime(context.getActivity(), value) { year, month, day, hour, minute, second ->
val calendar = Calendar.getInstance()
calendar.set(year, month - 1, day, hour, minute, second)
value = calendar.time.formatDateTime()
refreshCallBack?.invoke()
}
}
}
init { init {
initView() initView()
} }
@@ -252,7 +265,7 @@ class PadDataLayoutNew : FrameLayout {
setOnClickListener(null) setOnClickListener(null)
} }
DataLayoutType.DATE -> { DataLayoutType.DATE, DataLayoutType.DATETIME -> {
tv.visibility = VISIBLE tv.visibility = VISIBLE
et.visibility = GONE et.visibility = GONE
spinner.visibility = GONE spinner.visibility = GONE
@@ -263,7 +276,7 @@ class PadDataLayoutNew : FrameLayout {
iv.layoutParams.width = dp20 iv.layoutParams.width = dp20
iv.layoutParams.height = dp20 iv.layoutParams.height = dp20
setOnClickListener(dateClick) setOnClickListener(if (type == DataLayoutType.DATETIME) dateTimeClick else dateClick)
} }
} }
} }

View File

@@ -14,7 +14,8 @@ import com.lukouguoji.module_base.R
/** /**
* 手机版底部操作条(5.5 寸手持端公共组件) * 手机版底部操作条(5.5 寸手持端公共组件)
* *
* 结构:全选(圆形勾选框 + 文字)+ 已选统计 + 主操作按钮。 * 结构:全选(圆形勾选框 + 文字)+ 已选统计 + 次操作按钮(可选,最多 2 个)+ 主操作按钮。
* 平板底部有多个批量操作按钮的页面,用 subAction1Text/subAction2Text 承载其余操作(如出库交接「允许装机」「配运」)。
* 对应设计稿各多选列表页底部的白色操作条。 * 对应设计稿各多选列表页底部的白色操作条。
* *
* XML 用法: * XML 用法:
@@ -39,6 +40,8 @@ class PhoneBottomBar @JvmOverloads constructor(
val ivCheckAll: ImageView val ivCheckAll: ImageView
val tvCount: TextView val tvCount: TextView
val tvAction: TextView val tvAction: TextView
val tvSubAction1: TextView
val tvSubAction2: TextView
/** 全选状态(驱动勾选框图标切换) */ /** 全选状态(驱动勾选框图标切换) */
var allChecked: Boolean = false var allChecked: Boolean = false
@@ -64,6 +67,22 @@ class PhoneBottomBar @JvmOverloads constructor(
tvAction.text = v tvAction.text = v
} }
/** 次操作按钮 1 文字(空则隐藏) */
var subAction1Text: String = ""
set(v) {
field = v
tvSubAction1.text = v
tvSubAction1.visibility = if (v.isEmpty()) GONE else VISIBLE
}
/** 次操作按钮 2 文字(空则隐藏) */
var subAction2Text: String = ""
set(v) {
field = v
tvSubAction2.text = v
tvSubAction2.visibility = if (v.isEmpty()) GONE else VISIBLE
}
/** /**
* 危险操作样式:浅红底 + 红字 + 垃圾桶图标(如 ULD 管理的批量删除)。 * 危险操作样式:浅红底 + 红字 + 垃圾桶图标(如 ULD 管理的批量删除)。
* 默认 false 为主色实底按钮,既有页面不受影响。 * 默认 false 为主色实底按钮,既有页面不受影响。
@@ -102,6 +121,8 @@ class PhoneBottomBar @JvmOverloads constructor(
ivCheckAll = findViewById(R.id.iv_check_all) ivCheckAll = findViewById(R.id.iv_check_all)
tvCount = findViewById(R.id.tv_count) tvCount = findViewById(R.id.tv_count)
tvAction = findViewById(R.id.tv_action) tvAction = findViewById(R.id.tv_action)
tvSubAction1 = findViewById(R.id.tv_sub_action1)
tvSubAction2 = findViewById(R.id.tv_sub_action2)
} }
/** 只保存/恢复自身状态,避免内部子控件 id 与其他复合组件实例串档 */ /** 只保存/恢复自身状态,避免内部子控件 id 与其他复合组件实例串档 */

View File

@@ -13,12 +13,10 @@ import android.widget.TextView
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.databinding.InverseBindingListener import androidx.databinding.InverseBindingListener
import com.lukouguoji.module_base.R import com.lukouguoji.module_base.R
import com.lukouguoji.module_base.adapter.bindAdapter
import com.lukouguoji.module_base.adapter.bindOnSelected
import com.lukouguoji.module_base.interfaces.IOnSpinnerSelected
import com.lukouguoji.module_base.ktx.formatDate import com.lukouguoji.module_base.ktx.formatDate
import com.lukouguoji.module_base.ktx.getActivity import com.lukouguoji.module_base.ktx.getActivity
import com.lukouguoji.module_base.util.Common import com.lukouguoji.module_base.util.Common
import com.lxj.xpopup.XPopup
import dev.utils.app.info.KeyValue import dev.utils.app.info.KeyValue
import java.util.Calendar import java.util.Calendar
@@ -32,7 +30,7 @@ import java.util.Calendar
* |-----------|-----------|----------| * |-----------|-----------|----------|
* | `INPUT` | EditText | 无 | * | `INPUT` | EditText | 无 |
* | `DATE` | TextView(点击弹日历)| 日历 | * | `DATE` | TextView(点击弹日历)| 日历 |
* | `SPINNER` | Spinner | 下箭头 | * | `SPINNER` | TextView(点击弹底部列表,与 PhoneFormRow SELECT 同款)| 下箭头 |
* *
* XML 用法: * XML 用法:
* ```xml * ```xml
@@ -58,6 +56,7 @@ class PhoneDataLayout @JvmOverloads constructor(
val llContent: LinearLayout val llContent: LinearLayout
val et: EditText val et: EditText
val tv: TextView val tv: TextView
/** 保留以兼容布局 id;Bug #623 起 SPINNER 形态改为底部弹出列表,原生 Spinner 恒隐藏 */
val spinner: Spinner val spinner: Spinner
val iv: ImageView val iv: ImageView
@@ -85,7 +84,7 @@ class PhoneDataLayout @JvmOverloads constructor(
field = v field = v
et.hint = v et.hint = v
tv.hint = v tv.hint = v
bindAdapter(spinner, list, v, R.layout.item_spinner_list_14sp) applyDefaultSelection()
} }
var value: String = "" var value: String = ""
@@ -99,7 +98,7 @@ class PhoneDataLayout @JvmOverloads constructor(
var list: List<KeyValue> = emptyList() var list: List<KeyValue> = emptyList()
set(v) { set(v) {
field = v field = v
bindAdapter(spinner, v, hint, R.layout.item_spinner_list_14sp) applyDefaultSelection()
applyValue() applyValue()
} }
@@ -114,7 +113,6 @@ class PhoneDataLayout @JvmOverloads constructor(
field = v field = v
llContent.isEnabled = v llContent.isEnabled = v
et.isEnabled = v et.isEnabled = v
spinner.isEnabled = v
} }
init { init {
@@ -136,13 +134,6 @@ class PhoneDataLayout @JvmOverloads constructor(
} }
} }
bindOnSelected(spinner, object : IOnSpinnerSelected {
override fun onSelected(position: Int) {
value = list.getOrNull(position)?.value ?: ""
refreshCallBack?.invoke()
}
})
applyType() applyType()
} }
@@ -157,6 +148,28 @@ class PhoneDataLayout @JvmOverloads constructor(
} }
} }
/** SPINNER:底部弹出列表(标题取 hint,无 hint 时取 title),选中后回写 value 并回调 */
private val spinnerClick: (v: View) -> Unit = {
if (enable && list.isNotEmpty()) {
XPopup.Builder(context)
.asBottomList(hint.ifEmpty { title }, list.map { it.key }.toTypedArray()) { position, _ ->
value = list.getOrNull(position)?.value ?: ""
refreshCallBack?.invoke()
}
.show()
}
}
/**
* 保持原生 Spinner 的默认取值语义:无 hint 时自动选中第一项(value 为空才补),
* 有 hint 时保持空值显示占位文字。
*/
private fun applyDefaultSelection() {
if (type == PhoneDataLayoutType.SPINNER && hint.isEmpty() && value.isEmpty() && list.isNotEmpty()) {
value = list[0].value
}
}
private fun applyType() { private fun applyType() {
when (type) { when (type) {
PhoneDataLayoutType.INPUT -> { PhoneDataLayoutType.INPUT -> {
@@ -179,12 +192,12 @@ class PhoneDataLayout @JvmOverloads constructor(
PhoneDataLayoutType.SPINNER -> { PhoneDataLayoutType.SPINNER -> {
et.visibility = GONE et.visibility = GONE
tv.visibility = GONE tv.visibility = VISIBLE
spinner.visibility = VISIBLE spinner.visibility = GONE
iv.visibility = VISIBLE iv.visibility = VISIBLE
iv.setImageResource(R.drawable.ic_phone_chevron_down) iv.setImageResource(R.drawable.ic_phone_chevron_down)
llContent.setOnClickListener(null) llContent.setOnClickListener(spinnerClick)
llContent.isClickable = false applyDefaultSelection()
} }
} }
} }
@@ -200,10 +213,8 @@ class PhoneDataLayout @JvmOverloads constructor(
} }
PhoneDataLayoutType.SPINNER -> { PhoneDataLayoutType.SPINNER -> {
if (value.isNotEmpty()) { // 显示选中项文案;列表里找不到就按原值显示(如后端返回的历史值),空值显示 hint
val position = list.indexOfFirst { it.value == value } tv.text = list.firstOrNull { it.value == value }?.key ?: value
if (position >= 0) spinner.setSelection(position)
}
} }
} }
} }
@@ -223,10 +234,10 @@ class PhoneDataLayout @JvmOverloads constructor(
/** 重置为空值(筛选弹层「重置」按钮使用) */ /** 重置为空值(筛选弹层「重置」按钮使用) */
fun reset() { fun reset() {
value = "" // 与原 Spinner 版本一致:有 hint 回到占位(空值);无 hint 选中最后一项
if (type == PhoneDataLayoutType.SPINNER && list.isNotEmpty()) { value = if (type == PhoneDataLayoutType.SPINNER && hint.isEmpty() && list.isNotEmpty()) {
spinner.setSelection(if (hint.isEmpty()) list.size - 1 else list.size) list.last().value
} } else ""
} }
} }

View File

@@ -7,6 +7,7 @@ import android.util.AttributeSet
import android.util.SparseArray import android.util.SparseArray
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
@@ -136,6 +137,20 @@ class PhoneFormRow @JvmOverloads constructor(
} }
} }
// INPUT 输入完成回调(与平板 PadDataLayoutNew 一致:失焦即视为输入完成;另加键盘「完成/下一项」)。
// 原先 INPUT 形态从不触发 refreshCallBack,平板车号/运单号手输后不会回查(Bug #621)
et.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus && type == PhoneFormRowType.INPUT) refreshCallBack?.invoke()
}
et.setOnEditorActionListener { _, actionId, _ ->
if (type == PhoneFormRowType.INPUT &&
(actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_NEXT)
) {
refreshCallBack?.invoke()
}
false
}
applyType() applyType()
} }

View File

@@ -288,3 +288,23 @@ fun setPhoneFormRowRefreshCallBack(row: PhoneFormRow, callBack: (() -> Unit)?) {
fun setPhoneBottomBarActionDanger(bar: PhoneBottomBar, danger: Boolean?) { fun setPhoneBottomBarActionDanger(bar: PhoneBottomBar, danger: Boolean?) {
danger?.let { bar.actionDanger = it } danger?.let { bar.actionDanger = it }
} }
///////////////////////////////////////////////////////////////////////////
// PhoneBottomBar(追加:次操作按钮,承载平板底部其余批量操作)
///////////////////////////////////////////////////////////////////////////
@BindingAdapter("subAction1Text", "subAction2Text", requireAll = false)
fun setPhoneBottomBarSubActions(bar: PhoneBottomBar, subAction1Text: String?, subAction2Text: String?) {
subAction1Text?.let { bar.subAction1Text = it }
subAction2Text?.let { bar.subAction2Text = it }
}
@BindingAdapter("setOnSubAction1Click", requireAll = false)
fun setPhoneBottomBarSubAction1Click(bar: PhoneBottomBar, listener: View.OnClickListener?) {
bar.tvSubAction1.setOnClickListener(listener)
}
@BindingAdapter("setOnSubAction2Click", requireAll = false)
fun setPhoneBottomBarSubAction2Click(bar: PhoneBottomBar, listener: View.OnClickListener?) {
bar.tvSubAction2.setOnClickListener(listener)
}

View File

@@ -490,6 +490,60 @@ object DictUtils {
} }
} }
/**
* 飞行状态(0:前方起飞 1:过站登机 2:登机结束 6:到达 7:登机 9:正在值机 …)
*/
fun getFlightPortCodeList(
addAll: Boolean = true,
checkedValue: String? = null,
callBack: (List<KeyValue>) -> Unit
) {
launchCollect({
NetApply.api
.getFlightPortCodeList()
}) {
onSuccess = {
handleCallBack(it, checkedValue, addAll, callBack)
}
}
}
/**
* 航班服务种类(0:客机 1:货机 2:卡车)
*/
fun getFlightServiceTypeList(
addAll: Boolean = true,
checkedValue: String? = null,
callBack: (List<KeyValue>) -> Unit
) {
launchCollect({
NetApply.api
.getFlightServiceTypeList()
}) {
onSuccess = {
handleCallBack(it, checkedValue, addAll, callBack)
}
}
}
/**
* 航班状态(3:返航 4:地面滑回 5:备降 8:取消 12:延误 15:删除 16:未知)
*/
fun getFlightStatusList(
addAll: Boolean = true,
checkedValue: String? = null,
callBack: (List<KeyValue>) -> Unit
) {
launchCollect({
NetApply.api
.getFlightStatusList()
}) {
onSuccess = {
handleCallBack(it, checkedValue, addAll, callBack)
}
}
}
/** /**
* 获取角色列表 * 获取角色列表
*/ */
@@ -625,6 +679,22 @@ object DictUtils {
} }
} }
/**
* 板型列表(国际出港组装-开始组装)
* 接口 /typeCode/bordType
* @param carrier 承运人二字码(运单航班号前两位);为空时传空串(后端该参数必填,不传返回 400)
*/
fun getBoardTypeList(carrier: String? = null, callBack: (List<KeyValue>) -> Unit) {
launchCollect({
NetApply.api
.getBoardTypeList(carrier?.trim() ?: "")
}) {
onSuccess = {
callBack(it.data?.map { b -> b.toKeyValue() } ?: emptyList())
}
}
}
// 报文类型 // 报文类型
fun getTelegramTypeList(callBack: (List<KeyValue>) -> Unit) { fun getTelegramTypeList(callBack: (List<KeyValue>) -> Unit) {
launchCollect({ launchCollect({

View File

@@ -27,7 +27,12 @@ import com.lukouguoji.module_base.ktx.permission
import com.lukouguoji.module_base.ktx.showToast import com.lukouguoji.module_base.ktx.showToast
import com.lukouguoji.module_base.model.LoadingModel import com.lukouguoji.module_base.model.LoadingModel
import dev.DevUtils import dev.DevUtils
import dev.utils.app.HandlerUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okio.internal.commonToUtf8String import okio.internal.commonToUtf8String
import java.io.IOException
object PrinterUtils { object PrinterUtils {
@@ -315,6 +320,50 @@ object PrinterUtils {
* 打印国际出港板箱过磅挂签(2列布局,支持合并单元格) * 打印国际出港板箱过磅挂签(2列布局,支持合并单元格)
*/ */
fun printGjcBoxWeighing(bean: GjcUldUseBean) { fun printGjcBoxWeighing(bean: GjcUldUseBean) {
printMergedGrid(buildGjcBoxWeighingRows(bean))
}
/**
* 批量打印国际出港板箱过磅挂签
* 逐张串行发送,张间节流,任一张发送失败即中止并提示已完成张数
*/
suspend fun printGjcBoxWeighingBatch(beans: List<GjcUldUseBean>) = withContext(Dispatchers.IO) {
var printed = 0
loading.showLoading()
try {
for ((index, bean) in beans.withIndex()) {
// 连接断开(含上一张写入异常后 SDK 置为断开)则不再继续发送
if (portManager?.connectStatus != true) break
HandlerUtils.postRunnable {
loading.setMessage("正在打印 ${index + 1}/${beans.size}")
}
val bytes = buildMergedGridBytes(buildGjcBoxWeighingRows(bean))
val ok = try {
portManager?.writeDataImmediately(bytes) == true
} catch (e: IOException) {
showLog("批量打印第 ${index + 1} 张写入异常 : ${e.message}")
false
}
if (!ok) break
printed++
// 张间留出出纸时间,避免连续灌入导致打印机缓冲溢出
if (index < beans.lastIndex) delay(800)
}
} finally {
loading.dismissLoading()
}
if (printed == beans.size) {
showToast("${beans.size} 张挂签打印完成")
} else {
val failBean = beans[printed]
showToast("已打印 $printed 张,第 ${printed + 1} 张(ULD ${failBean.uld})发送失败,请检查打印机后重试")
}
}
/**
* 组装板箱过磅挂签的表格行数据
*/
private fun buildGjcBoxWeighingRows(bean: GjcUldUseBean): ArrayList<GridRow> {
val rows = arrayListOf<GridRow>() val rows = arrayListOf<GridRow>()
// 第1行:日期 | 航班号 // 第1行:日期 | 航班号
@@ -345,7 +394,7 @@ object PrinterUtils {
merged = CellData("备注:", "NOTES:", bean.remark) merged = CellData("备注:", "NOTES:", bean.remark)
)) ))
printMergedGrid(rows) return rows
} }
private fun printCommonGrid(rowItem: ArrayList<List<String>>) { private fun printCommonGrid(rowItem: ArrayList<List<String>>) {
@@ -456,6 +505,13 @@ object PrinterUtils {
* 打印支持合并单元格的表格 * 打印支持合并单元格的表格
*/ */
private fun printMergedGrid(rows: ArrayList<GridRow>) { private fun printMergedGrid(rows: ArrayList<GridRow>) {
portManager?.writeDataImmediately(buildMergedGridBytes(rows))
}
/**
* 生成支持合并单元格的表格标签字节流(自包含一张完整标签的 TSPL 指令)
*/
private fun buildMergedGridBytes(rows: ArrayList<GridRow>): ByteArray {
val gridStartX = 30 val gridStartX = 30
val titleTopMargin = 50 // 标题顶部间距 val titleTopMargin = 50 // 标题顶部间距
val titleFontSize = 130 // 标题字体大小 val titleFontSize = 130 // 标题字体大小
@@ -536,7 +592,7 @@ object PrinterUtils {
addPrint(1) addPrint(1)
}.bytes }.bytes
portManager?.writeDataImmediately(bytes) return bytes
} }
/** /**

View File

@@ -34,6 +34,13 @@
android:exported="false" android:exported="false"
android:screenOrientation="unspecified" /> android:screenOrientation="unspecified" />
<!-- 海关回执(只读) -->
<activity
android:name=".ui.page.customs.CustomsResponseActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:screenOrientation="unspecified" />
<!-- 屏幕适配 --> <!-- 屏幕适配 -->
<meta-data <meta-data
android:name="design_width_in_dp" android:name="design_width_in_dp"

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 输入框边框 + 与 Spinner(bg_spinner_pda_new)完全一致的下拉箭头,用于“手动输入 + 下拉选择”组合输入框 -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/bg_data_layout_new" />
<item
android:width="8.7dp"
android:height="6dp"
android:drawable="@drawable/img_sp_down"
android:gravity="center_vertical|right"
android:right="10dp" />
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 手机版详情卡片表头:#F3F4F6 浅灰底 + 顶部 12dp 圆角(出港移库编辑-补充信息卡片) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/phone_bg" />
<corners
android:topLeftRadius="12dp"
android:topRightRadius="12dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 手机版圆形操作按钮:红色(出港运抵卡片长按菜单「删除申报」) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/phone_danger_text" />
</shape>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
手机版三格统计卡背景:白底 12dp 圆角 + 左侧 4dp 色边(随圆角弯曲),
对应设计稿 rounded-lg border-l-[4px](Bug #620,替代原先卡片内悬空的短竖条)
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/phone_primary" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:left="4dp">
<shape android:shape="rectangle">
<solid android:color="@color/white" />
<corners
android:bottomLeftRadius="8dp"
android:bottomRightRadius="12dp"
android:topLeftRadius="8dp"
android:topRightRadius="12dp" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
手机版三格统计卡背景:白底 12dp 圆角 + 左侧 4dp 色边(随圆角弯曲),
对应设计稿 rounded-lg border-l-[4px](Bug #620,替代原先卡片内悬空的短竖条)
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/phone_tag_green_text" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:left="4dp">
<shape android:shape="rectangle">
<solid android:color="@color/white" />
<corners
android:bottomLeftRadius="8dp"
android:bottomRightRadius="12dp"
android:topLeftRadius="8dp"
android:topRightRadius="12dp" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
手机版三格统计卡背景:白底 12dp 圆角 + 左侧 4dp 色边(随圆角弯曲),
对应设计稿 rounded-lg border-l-[4px](Bug #620,替代原先卡片内悬空的短竖条)
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/phone_tag_orange_text" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:left="4dp">
<shape android:shape="rectangle">
<solid android:color="@color/white" />
<corners
android:bottomLeftRadius="8dp"
android:bottomRightRadius="12dp"
android:topLeftRadius="8dp"
android:topRightRadius="12dp" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_f2"
android:orientation="vertical">
<!-- 标题栏 -->
<include layout="@layout/title_tool_bar" />
<!-- 回执内容区域(只读,可滚动、可长按复制) -->
<EditText
android:id="@+id/etContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="20dp"
android:background="@drawable/bg_white_radius_8"
android:gravity="top|start"
android:inputType="textMultiLine"
android:lineSpacingExtra="4dp"
android:overScrollMode="always"
android:padding="20dp"
android:scrollbars="vertical"
android:textColor="@color/black"
android:textSize="16sp" />
</LinearLayout>
</layout>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="bean"
type="com.lukouguoji.module_base.bean.FileBean" />
</data>
<RelativeLayout
android:id="@+id/rl"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="5dp"
android:background="@{bean.path.length() == 0 ? @color/color_f2 : @color/white}">
<ImageView
visible="@{bean.path.length() == 0}"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_centerInParent="true"
android:src="@drawable/img_add" />
<ImageView
android:id="@+id/iv"
loadImage="@{bean.path}"
visible="@{bean.path}"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<View
visible="@{bean.canDelete}"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black_tran10" />
<ImageView
android:id="@+id/iv_delete"
visible="@{bean.canDelete}"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_centerInParent="true"
android:src="@drawable/img_delete_red" />
</RelativeLayout>
</layout>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
海关回执详情页(手机版,5.5 寸手持端):只读回执文本卡片。
平板变体:layout-sw600dp/activity_customs_response.xml,两者共用 CustomsResponseActivity 与 etContent。
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/phone_page_bg"
android:orientation="vertical">
<!-- 标题栏 -->
<include layout="@layout/title_tool_bar" />
<!-- 回执内容区域(只读,可滚动、可长按复制) -->
<EditText
android:id="@+id/etContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="12dp"
android:background="@drawable/bg_phone_card"
android:gravity="top|start"
android:inputType="textMultiLine"
android:lineSpacingExtra="4dp"
android:overScrollMode="always"
android:padding="14dp"
android:scrollbars="vertical"
android:textColor="@color/phone_text_body"
android:textSize="13sp" />
</LinearLayout>
</layout>

View File

@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
手机版选图格子(平板版在 layout-sw600dp/,两者变量与 id 一致,共用 ImageSelectNewViewHolder)。
加号槽位与进港移库「异常照片」一致:灰色虚线框 + 灰色加号;已选图片长按出删除角标。
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <layout xmlns:android="http://schemas.android.com/apk/res/android">
<data> <data>
@@ -12,15 +16,17 @@
android:id="@+id/rl" android:id="@+id/rl"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_margin="5dp" android:layout_margin="6dp"
android:background="@{bean.path.length() == 0 ? @color/color_f2 : @color/white}"> android:background="@drawable/bg_phone_photo_add">
<ImageView <TextView
visible="@{bean.path.length() == 0}" visible="@{bean.path.length() == 0}"
android:layout_width="50dp" android:layout_width="wrap_content"
android:layout_height="50dp" android:layout_height="wrap_content"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:src="@drawable/img_add" /> android:text="+"
android:textColor="@color/phone_text_hint"
android:textSize="24sp" />
<ImageView <ImageView
android:id="@+id/iv" android:id="@+id/iv"

View File

@@ -2,7 +2,7 @@
<!-- <!--
PhoneBottomBar 内部布局(手机版底部操作条) PhoneBottomBar 内部布局(手机版底部操作条)
结构:全选 + 已选统计 + 主操作按钮 结构:全选 + 已选统计 + 次操作按钮(可选,最多 2 个,未设置文字时隐藏)+ 主操作按钮
对应设计稿各多选列表页底部的白色操作条 对应设计稿各多选列表页底部的白色操作条
--> -->
<merge xmlns:android="http://schemas.android.com/apk/res/android" <merge xmlns:android="http://schemas.android.com/apk/res/android"
@@ -48,6 +48,34 @@
android:textColor="@color/phone_text_hint" android:textColor="@color/phone_text_hint"
android:textSize="12sp" /> android:textSize="12sp" />
<!-- 次操作按钮 1(浅蓝弱按钮,subAction1Text 为空时隐藏) -->
<TextView
android:id="@+id/tv_sub_action1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_phone_btn_weak"
android:gravity="center"
android:paddingHorizontal="12dp"
android:paddingVertical="9dp"
android:textColor="@color/phone_primary"
android:textSize="14sp"
android:visibility="gone" />
<!-- 次操作按钮 2(浅蓝弱按钮,subAction2Text 为空时隐藏) -->
<TextView
android:id="@+id/tv_sub_action2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_phone_btn_weak"
android:gravity="center"
android:paddingHorizontal="12dp"
android:paddingVertical="9dp"
android:textColor="@color/phone_primary"
android:textSize="14sp"
android:visibility="gone" />
<!-- 主操作按钮 --> <!-- 主操作按钮 -->
<TextView <TextView
android:id="@+id/tv_action" android:id="@+id/tv_action"

View File

@@ -30,7 +30,7 @@ class GjcAssembleWeightEditActivity :
override fun viewModelClass() = GjcAssembleWeightEditViewModel::class.java override fun viewModelClass() = GjcAssembleWeightEditViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) { override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("国际出港修改组装重量") setBackArrow("国际出港重量回填")
binding.viewModel = viewModel binding.viewModel = viewModel

View File

@@ -57,8 +57,8 @@ class GjcQueryActivity :
if (visible) showFilterPanel() else hideFilterPanel() if (visible) showFilterPanel() else hideFilterPanel()
} }
// 航班号:大写字母+数字 // 航班号(搜索栏):大写字母+数字
padFilterPanel.filterFlightNo.et.setUpperCaseAlphanumericFilter() binding.flightNoSearch?.et?.setUpperCaseAlphanumericFilter()
// 目的港:仅大写字母 // 目的港:仅大写字母
padFilterPanel.filterDest.et.setUpperCaseLetterFilter() padFilterPanel.filterDest.et.setUpperCaseLetterFilter()
} else { } else {
@@ -67,11 +67,6 @@ class GjcQueryActivity :
binding.phoneFilterDest?.et?.setUpperCaseLetterFilter() binding.phoneFilterDest?.et?.setUpperCaseLetterFilter()
} }
// 手机形态专属:特码下拉字典 + 已入库/已离港统计
if (DeviceUtil.isPhone()) {
viewModel.initPhoneExtras()
}
// 初始加载 // 初始加载
viewModel.refresh() viewModel.refresh()
} }

View File

@@ -40,12 +40,6 @@ class GjcWeighingRecordListActivity :
binding.viewModel = viewModel binding.viewModel = viewModel
binding.activity = this binding.activity = this
// 手机版专属数据:放开默认日期、加载计重人下拉
// 平板端不调用,查询链路与请求次数与改造前完全一致
if (DeviceUtil.isPhone()) {
viewModel.initPhoneExtras()
}
// 初始化代理人列表 // 初始化代理人列表
viewModel.initAgentList() viewModel.initAgentList()
@@ -76,6 +70,7 @@ class GjcWeighingRecordListActivity :
/** 手机版筛选:重置(只清弹层内条件,不影响顶部搜索框) */ /** 手机版筛选:重置(只清弹层内条件,不影响顶部搜索框) */
fun resetFilter() { fun resetFilter() {
viewModel.flightDate.value = "" viewModel.flightDate.value = ""
viewModel.flightNo.value = ""
viewModel.agentCode.value = "" viewModel.agentCode.value = ""
viewModel.spCode.value = "" viewModel.spCode.value = ""
} }

View File

@@ -34,11 +34,6 @@ class GjcWeighingStartActivity :
setBackArrow(if (DeviceUtil.isPhone()) "开始计重" else "国际出港开始计重") setBackArrow(if (DeviceUtil.isPhone()) "开始计重" else "国际出港开始计重")
binding.viewModel = viewModel binding.viewModel = viewModel
// 手机版专属:提交体携带托盘数量/托盘自重(平板不调用,请求体与改造前完全一致)
if (DeviceUtil.isPhone()) {
viewModel.initPhoneExtras()
}
viewModel.initOnCreated(this, intent) viewModel.initOnCreated(this, intent)
setupFloatButtons() setupFloatButtons()

View File

@@ -125,6 +125,7 @@ class IntExpArriveActivity :
fun resetFilter() { fun resetFilter() {
viewModel.flightDate.value = "" viewModel.flightDate.value = ""
viewModel.flightNo.value = "" viewModel.flightNo.value = ""
viewModel.hno.value = ""
} }
/** 手机版筛选:确认 */ /** 手机版筛选:确认 */
@@ -142,12 +143,11 @@ class IntExpArriveActivity :
} }
/** /**
* 进入「回执」页(手机专属只读页):复用 haWbList[].response + arrivalOpDate,零新增请求。 * 浮层「回执」:与平板侧滑「回执」同链路(getResponse 查询海关回执 → 共用只读回执页)。
*/ */
fun openReceipt(bean: GjcMaWb) { fun openReceipt(bean: GjcMaWb) {
bean.actionMenuVisible.set(false) bean.actionMenuVisible.set(false)
val list = ArrayList<GjcHaWb>(bean.haWbList.orEmpty()) viewModel.queryCustomsResponse(bean)
IntExpArriveReceiptActivity.start(this, bean.wbNo.orEmpty(), list)
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

View File

@@ -9,6 +9,7 @@ import com.lukouguoji.gjc.databinding.ActivityIntExpStorageUseBinding
import com.lukouguoji.gjc.dialog.IntExpMoveClearDialogModel import com.lukouguoji.gjc.dialog.IntExpMoveClearDialogModel
import com.lukouguoji.gjc.dialog.IntExpModifyStorageDialogModel import com.lukouguoji.gjc.dialog.IntExpModifyStorageDialogModel
import com.lukouguoji.gjc.dialog.IntExpInStorageDialogModel import com.lukouguoji.gjc.dialog.IntExpInStorageDialogModel
import com.lukouguoji.gjc.dialog.IntExpOutStorageDialogModel
import com.lukouguoji.gjc.viewModel.IntExpStorageUseViewModel import com.lukouguoji.gjc.viewModel.IntExpStorageUseViewModel
import com.lukouguoji.module_base.base.BaseBindingActivity import com.lukouguoji.module_base.base.BaseBindingActivity
import com.lukouguoji.module_base.common.Constant import com.lukouguoji.module_base.common.Constant
@@ -17,7 +18,6 @@ import com.lukouguoji.module_base.impl.FlowBus
import com.lukouguoji.module_base.impl.observe import com.lukouguoji.module_base.impl.observe
import com.lukouguoji.module_base.ktx.commonAdapter import com.lukouguoji.module_base.ktx.commonAdapter
import com.lukouguoji.module_base.ktx.showToast import com.lukouguoji.module_base.ktx.showToast
import com.lukouguoji.module_base.model.ConfirmDialogModel
import com.lukouguoji.module_base.router.ARouterConstants import com.lukouguoji.module_base.router.ARouterConstants
import com.lukouguoji.module_base.util.DeviceUtil import com.lukouguoji.module_base.util.DeviceUtil
@@ -83,7 +83,8 @@ class IntExpStorageUseActivity :
IntExpMoveClearDialogModel { dialog -> IntExpMoveClearDialogModel { dialog ->
// 用户点击保存后,执行清仓操作 // 用户点击保存后,执行清仓操作
val clearNormal = dialog.clearNormal.value ?: "" val clearNormal = dialog.clearNormal.value ?: ""
viewModel.performClear(clearNormal, maWbListForClear) val clearRemark = dialog.clearRemark.value ?: ""
viewModel.performClear(clearNormal, clearRemark, maWbListForClear)
}.show(this) }.show(this)
} }
@@ -124,7 +125,7 @@ class IntExpStorageUseActivity :
} }
/** /**
* 显示出库二次确认对话框 * 显示出库操作对话框
*/ */
fun showOutStorageDialog() { fun showOutStorageDialog() {
val list = viewModel.pageModel.rv?.commonAdapter()?.items as? List<*> ?: return val list = viewModel.pageModel.rv?.commonAdapter()?.items as? List<*> ?: return
@@ -142,24 +143,33 @@ class IntExpStorageUseActivity :
return return
} }
// 显示二次确认对话框 // 显示出库对话框,件数/重量默认取选中库位的入库件数/重量
ConfirmDialogModel( IntExpOutStorageDialogModel(
message = "确定要将选中的 ${selectedStorageUseList.size} 个库位执行出库操作吗?", storageCount = selectedStorageUseList.size,
title = "出库确认" defaultPc = selectedStorageUseList.mapNotNull { it.inPc }.takeIf { it.isNotEmpty() }?.sum(),
) { defaultWeight = selectedStorageUseList.mapNotNull { it.inWeight }.takeIf { it.isNotEmpty() }?.sum()
// 用户确认后,执行出库操作 ) { dialog ->
viewModel.performOutStorage(selectedStorageUseList) // 用户点击确定后,执行出库操作
viewModel.performOutStorage(
dialog.pc.value?.toLongOrNull(),
dialog.weight.value?.toDoubleOrNull(),
selectedStorageUseList
)
}.show(this) }.show(this)
} }
// ==================== 5.5寸手机版:卡片级操作 ==================== // ==================== 5.5寸手机版:卡片级操作 ====================
// 语义等价于「勾选该主单 + 其全部库位」再执行平板的批量操作,复用 performXxx 请求链路。 // 语义等价于「勾选该主单 + 其全部库位」再执行平板的批量操作,弹框与请求链路完全复用平板。
/** 卡片「清仓」(已清仓的卡片按钮置灰,ViewHolder 已拦截) */ /** 卡片「清仓」(已清仓的卡片按钮置灰,ViewHolder 已拦截) */
fun cardClear(bean: com.lukouguoji.module_base.bean.GjcMaWb) { fun cardClear(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
val maWbListForClear = listOf(bean.copy(storageUseList = bean.storageUseList ?: emptyList())) val maWbListForClear = listOf(bean.copy(storageUseList = bean.storageUseList ?: emptyList()))
IntExpMoveClearDialogModel { dialog -> IntExpMoveClearDialogModel { dialog ->
viewModel.performClear(dialog.clearNormal.value ?: "", maWbListForClear) viewModel.performClear(
dialog.clearNormal.value ?: "",
dialog.clearRemark.value ?: "",
maWbListForClear
)
}.show(this) }.show(this)
} }
@@ -176,26 +186,40 @@ class IntExpStorageUseActivity :
}.show(this) }.show(this)
} }
/** 卡片「出库」:该运单全部库位出库(与平板勾选主单后出库同语义) */ /** 卡片「出库」:该运单全部库位出库(与平板勾选主单后出库同语义,件数/重量默认取库位入库件数/重量) */
fun cardOutStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) { fun cardOutStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
val storageList = bean.storageUseList ?: emptyList() val storageList = bean.storageUseList ?: emptyList()
if (storageList.isEmpty()) { if (storageList.isEmpty()) {
showToast("该运单暂无库位") showToast("该运单暂无库位")
return return
} }
ConfirmDialogModel( IntExpOutStorageDialogModel(
message = "确定要将运单 ${bean.wbNo} 的 ${storageList.size} 个库位执行出库操作吗?", storageCount = storageList.size,
title = "出库确认" defaultPc = storageList.mapNotNull { it.inPc }.takeIf { it.isNotEmpty() }?.sum(),
) { defaultWeight = storageList.mapNotNull { it.inWeight }.takeIf { it.isNotEmpty() }?.sum()
viewModel.performOutStorage(storageList) ) { dialog ->
viewModel.performOutStorage(
dialog.pc.value?.toLongOrNull(),
dialog.weight.value?.toDoubleOrNull(),
storageList
)
}.show(this) }.show(this)
} }
/** 卡片「入库」:弹层选库位后整票入库 */ /** 卡片「入库」:弹层选库位、填件数重量后整票入库 */
fun cardInStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) { fun cardInStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
val maWbListForInStorage = listOf(bean.copy(storageUseList = bean.storageUseList ?: emptyList())) val maWbListForInStorage = listOf(bean.copy(storageUseList = bean.storageUseList ?: emptyList()))
IntExpInStorageDialogModel { dialog -> IntExpInStorageDialogModel(
viewModel.performInStorage(dialog.locationName, dialog.locationId, maWbListForInStorage) defaultPc = bean.pc,
defaultWeight = bean.weight
) { dialog ->
viewModel.performInStorage(
dialog.locationName,
dialog.locationId,
dialog.pc.value?.toLongOrNull(),
dialog.weight.value?.toDoubleOrNull(),
maWbListForInStorage
)
}.show(this) }.show(this)
} }
@@ -225,12 +249,19 @@ class IntExpStorageUseActivity :
return return
} }
// 显示入库对话框 // 显示入库对话框,件数/重量默认取选中运单的件数/重量
IntExpInStorageDialogModel { dialog -> IntExpInStorageDialogModel(
defaultPc = maWbListForInStorage.mapNotNull { it.pc }.takeIf { it.isNotEmpty() }?.sum(),
defaultWeight = maWbListForInStorage.mapNotNull { it.weight }.takeIf { it.isNotEmpty() }?.sum()
) { dialog ->
// 用户点击保存后,执行入库操作 // 用户点击保存后,执行入库操作
val locationName = dialog.locationName viewModel.performInStorage(
val locationId = dialog.locationId dialog.locationName,
viewModel.performInStorage(locationName, locationId, maWbListForInStorage) dialog.locationId,
dialog.pc.value?.toLongOrNull(),
dialog.weight.value?.toDoubleOrNull(),
maWbListForInStorage
)
}.show(this) }.show(this)
} }

View File

@@ -6,15 +6,19 @@ import com.lukouguoji.gjc.R
import com.lukouguoji.gjc.databinding.DialogIntExpArriveDeleteBinding import com.lukouguoji.gjc.databinding.DialogIntExpArriveDeleteBinding
import com.lukouguoji.module_base.base.BaseDialogModel import com.lukouguoji.module_base.base.BaseDialogModel
import com.lukouguoji.module_base.ktx.verifyNullOrEmpty import com.lukouguoji.module_base.ktx.verifyNullOrEmpty
import com.lukouguoji.module_base.util.DeviceUtil
import dev.utils.app.info.KeyValue import dev.utils.app.info.KeyValue
/** /**
* 国际出港运抵 - 删除申报对话框 * 国际出港运抵 - 删除申报对话框
* 手机为底部弹层(layout/ 变体),平板为居中弹框(layout-sw600dp/ 变体)
*/ */
class IntExpArriveDeleteDialogModel( class IntExpArriveDeleteDialogModel(
val changeReasonList: List<KeyValue>, // 变更原因列表 val changeReasonList: List<KeyValue>, // 变更原因列表
private val callback: (IntExpArriveDeleteDialogModel) -> Unit private val callback: (IntExpArriveDeleteDialogModel) -> Unit
) : BaseDialogModel<DialogIntExpArriveDeleteBinding>(DIALOG_TYPE_CENTER) { ) : BaseDialogModel<DialogIntExpArriveDeleteBinding>(
if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER
) {
// 变更原因代码(存储的是 code) // 变更原因代码(存储的是 code)
val changeReason = MutableLiveData("") val changeReason = MutableLiveData("")

Some files were not shown because too many files have changed in this diff Show More