Parcourir la source

feat(signature): 恢复签名页自动横屏输入 + 低危项加固

- signature.vue 恢复 enterLandscape(wx.setPageOrientation landscape,complete 回调重算画布高度)/ restorePortrait / 窗口尺寸监听(onWindowResize/resize 成对注册注销),退出恢复竖屏,H5 降级直签
- 横屏字号 px 化(标题/副标题/按钮),消除 rpx 横屏放大 ~2.2 倍
- hand-signature.vue 新增 height watch 旋转后重建 2d 位图,并守卫 hasDrawn 避免书写中重建清空笔迹
- 组件路径迁至 pages/signature/components 并同步 import/测试/台账
- 测试 22/22 全绿
asus il y a 2 jours
Parent
commit
7af780da51

+ 192 - 34
cfc-frontend/__tests__/pages/signature/signature.canvas2d.test.js

@@ -1,38 +1,33 @@
 /**
- * 契约级单测 —— 手签名实现方式重写(竖屏直签 + canvas 2d,能力键 signature.canvas2d.rewrite)
+ * 契约级单测 —— 恢复进入签名页自动横屏(用户知情地推翻 11-09 竖屏直签)+ canvas 2d 不回归
  *
  * 溯源:
- *   SRS:  docs/superpowers/specs/2026-11-09-signature-canvas2d-rewrite.md(DOC-2026-11-09-signature-canvas2d-rewrite,待开发)
- *   取代: docs/superpowers/specs/2026-11-08-signature-landscape-fix.md(整份作废,勿按旧实现断言)
+ *   - 本轮恢复: 进入签名页自动强制横屏(wx.setPageOrientation landscape),退出恢复竖屏;
+ *               H5 等不支持的环境降级为竖屏直签(不切横屏)。
+ *   - 保留: 11-09 canvas 2d 重写(type="2d" + getContext('2d'))契约不回退。
  *   台账: tests/test-cases/signature.md(TC-SIG2D-01~08)
  *
- * 覆盖(v2 任务书 §三):
- *   S4-1 竖屏直签(signature.vue)
- *     TC-SIG2D-01  删除 enterLandscape()/restorePortrait() 方法
- *     TC-SIG2D-01b onLoad/onUnload 不再调用横屏方法;模板删 h5DegradeHint/.rotate-hint
- *     TC-SIG2D-02  computeCanvasHeight 保留竖屏计算(174/214 两档 + 160 下限)
- *   S4-2 canvas 2d(hand-signature.vue)
- *     TC-SIG2D-03  模板 :id + type="2d"(非 canvas-id)
- *     TC-SIG2D-03b data 含 canvasId='hsCanvas_'+(_uid||0) 与 _canvasNode:null
- *     TC-SIG2D-03c data 含 canvasWidth/canvasHeight/dpr/_destroyed/_initTimer/_rectLeft/_rectTop(2d 绘制与初始化防御所需)
- *     TC-SIG2D-04  mounted 走 createSelectorQuery().in(this).select().fields({node:true,...})
- *     TC-SIG2D-05  触摸坐标优先 t.x/t.y,兜底 clientX-rectLeft(不依赖旧 getPos 缓存 rect)
- *     TC-SIG2D-06  onTouchMove 同步 ctx.stroke() 绘制(无 ctx.draw)
- *     TC-SIG2D-07  导出 canvasToTempFilePath({canvas:_canvasNode});清空 ctx.clearRect
- *     TC-SIG2D-01-E  2d 初始化失败重试 + _destroyed 防销毁竞态(mounted 重试链、beforeDestroy 清理、initCanvas 守卫)
- *     TC-SIG2D-02-E  导出降级链路保持:H5 直接透传 dataURL、小程序 fs.readFile 转 base64(既有链路不回归)
+ * 覆盖:
+ *   S4-1 恢复自动横屏(signature.vue)
+ *     TC-SIG2D-01  恢复 enterLandscape/restorePortrait 并正确接线(onLoad/onUnload/doBack)
+ *     TC-SIG2D-01b 模板不渲染 h5DegradeHint/.rotate-hint(本轮未重新引入)
+ *     TC-SIG2D-01c 调 wx.setPageOrientation(landscape + portrait)
+ *     TC-SIG2D-01d~01j 行为级:H5 降级不切横屏 / success+complete 重算高度 / 窗口监听成对注册注销 / doBack 恢复竖屏 / onUnload 清理
+ *     TC-SIG2D-02  computeCanvasHeight 保留(174/214 两档 + 160 下限)
+ *   S4-2 canvas 2d(hand-signature.vue,不回归)
+ *     TC-SIG2D-03~07、01-E、02-E 保持不变
+ *     TC-SIG2D-07b height watch 仅在值变化且非 savingLive/销毁时重建(书写中不丢笔迹)
  *   S4-3 不回归
- *     TC-SIG2D-08  组件对外契约 props/emit 不变(value/strokeColor/strokeWidth/height/requireAgree/agreeText + confirm/clear/agree-change)
+ *     TC-SIG2D-08 组件对外契约 props/emit 不变(value/strokeColor/strokeWidth/height/requireAgree/agreeText + confirm/clear/agree-change)
  *
  * 本文件只读源码并求值 script 段,不修改任何业务代码。
- * 当前实现 = v2「待开发」,本文件内新契约断言应为 RED(除 02/08/02-E 保留不变的既有契约)。
  */
 
 const fs = require('fs')
 const path = require('path')
 
 const PAGE = path.join(__dirname, '../../../pages/signature/signature.vue')
-const PAD = path.join(__dirname, '../../../components/hand-signature/hand-signature.vue')
+const PAD = path.join(__dirname, '../../../pages/signature/components/hand-signature/hand-signature.vue')
 
 function readScript(file) {
   const src = fs.readFileSync(file, 'utf8')
@@ -100,6 +95,24 @@ function makePage(windowHeight, requireAgree) {
   return vm
 }
 
+// 行为级测试用的页面实例构造器:可注入 wx/window/uni 桩,便于验证横屏/监听/回退链路
+function makePageWith(sandbox) {
+  const base = {
+    uni: { getWindowInfo: function () { return { windowHeight: 390, windowWidth: 844 } } },
+    wx: undefined,
+    window: undefined,
+  }
+  const merged = Object.assign({}, base, sandbox || {})
+  if (sandbox && sandbox.uni) {
+    merged.uni = Object.assign({}, base.uni, sandbox.uni)
+  }
+  const opts = loadOptions(PAGE, merged)
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.$refs = {}
+  return vm
+}
+
 function styleBlock(selector) {
   const src = fs.readFileSync(PAGE, 'utf8')
   const i = src.indexOf(selector + ' {')
@@ -108,36 +121,149 @@ function styleBlock(selector) {
   return src.slice(open + 1, src.indexOf('}', open))
 }
 
-describe('签名实现重写 v2(竖屏直签 + canvas 2d)· signature.canvas2d.rewrite', function () {
+describe('恢复签名页自动横屏 + canvas 2d 不回归 · signature.canvas2d.rewrite', function () {
   const pageScript = readScript(PAGE)
   const pageTpl = readTemplate(PAGE)
   const padScript = readScript(PAD)
   const padTpl = readTemplate(PAD)
 
-  // ---------- S4-1 竖屏直签(signature.vue) ----------
+  // ---------- S4-1 恢复自动横屏(signature.vue) ----------
 
-  test('TC-SIG2D-01 删除横屏方法 enterLandscape/restorePortrait', function () {
-    expect(pageScript).not.toMatch(/enterLandscape\s*:/)
-    expect(pageScript).not.toMatch(/restorePortrait\s*:/)
-    // 全文件不应再引用该方法名(含 onLoad/onUnload 调用点与注释外的 toString)
-    expect(pageScript).not.toMatch(/enterLandscape\(/)
-    expect(pageScript).not.toMatch(/restorePortrait\(/)
+  test('TC-SIG2D-01 恢复横屏方法 enterLandscape/restorePortrait 并正确接线', function () {
+    expect(pageScript).toMatch(/enterLandscape\s*:/)
+    expect(pageScript).toMatch(/restorePortrait\s*:/)
+    // onLoad 末尾进入横屏并注册尺寸监听
+    expect(pageScript).toMatch(/onLoad\s*:\s*function[\s\S]*?enterLandscape\(\)/)
+    expect(pageScript).toMatch(/onLoad\s*:\s*function[\s\S]*?bindResizeListener\(\)/)
+    // onUnload 注销监听并恢复竖屏
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?unbindResizeListener\(\)/)
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?restorePortrait\(\)/)
+    // doBack 在 navigateBack 前恢复竖屏
+    expect(pageScript).toMatch(/doBack\s*:\s*function[\s\S]*?restorePortrait\(\)[\s\S]*?navigateBack\(\)/)
   })
 
-  test('TC-SIG2D-01b onLoad/onUnload 不再调用横屏依赖;模板删除 h5DegradeHint/.rotate-hint', function () {
-    // 页面模板不再渲染 H5 降级提示块
+  test('TC-SIG2D-01b 模板不渲染 H5 降级提示块 h5DegradeHint/.rotate-hint', function () {
+    // 本轮恢复横屏未重新引入 H5 降级提示块
     expect(pageTpl).not.toMatch(/h5DegradeHint/)
     expect(pageTpl).not.toMatch(/rotate-hint/)
     // 样式块中无 .rotate-hint
     expect(styleBlock('.rotate-hint')).toBe('')
   })
 
-  test('TC-SIG2D-01c 不再调用 wx.setPageOrientation / screen.orientation.lock', function () {
-    // 只匹配「调用形式」(带括号),排除注释中的引用文字(v2 实现注释明确写"不再调用…")
-    expect(pageScript).not.toMatch(/setPageOrientation\s*\(/)
+  test('TC-SIG2D-01c 调用 wx.setPageOrientation 切横屏/竖屏(landscape + portrait)', function () {
+    // 只匹配「调用形式」(带括号),排除注释中的引用文字
+    expect(pageScript).toMatch(/setPageOrientation\s*\(\s*\{/)
+    expect(pageScript).toMatch(/orientation\s*:\s*['"]landscape['"]/)
+    expect(pageScript).toMatch(/orientation\s*:\s*['"]portrait['"]/)
+    // 不使用 screen.orientation.lock(H5 降级为 wx 判断不切横屏)
     expect(pageScript).not.toMatch(/orientation\.lock\s*\(/)
   })
 
+  test('TC-SIG2D-01d enterLandscape 在 H5(无 wx)降级不切横屏且不抛错', function () {
+    const vm = makePageWith({ wx: undefined })
+    expect(function () { vm.enterLandscape() }).not.toThrow()
+  })
+
+  test('TC-SIG2D-01e enterLandscape 调 setPageOrientation(landscape),complete 回调重算高度(success 不重复)', function () {
+    const calls = []
+    const vm = makePageWith({
+      wx: { setPageOrientation: function (cfg) { calls.push(cfg) } }
+    })
+    let refresh = 0
+    vm.refreshCanvasHeight = function () { refresh += 1 }
+    vm.enterLandscape()
+    expect(calls.length).toBe(1)
+    expect(calls[0].orientation).toBe('landscape')
+    // 加固后:仅 complete 重算一次(success 与 complete 不再重复计算)
+    expect(calls[0].success).toBeUndefined()
+    calls[0].complete()
+    expect(refresh).toBe(1)
+  })
+
+  test('TC-SIG2D-01f restorePortrait 调 setPageOrientation(portrait);H5 降级不抛错', function () {
+    const calls = []
+    const vm = makePageWith({
+      wx: { setPageOrientation: function (cfg) { calls.push(cfg) } }
+    })
+    vm.restorePortrait()
+    expect(calls.length).toBe(1)
+    expect(calls[0].orientation).toBe('portrait')
+
+    const h5 = makePageWith({ wx: undefined })
+    expect(function () { h5.restorePortrait() }).not.toThrow()
+  })
+
+  test('TC-SIG2D-01g 窗口监听成对注册/注销(wx 通道,无泄漏)', function () {
+    const on = []
+    const off = []
+    const vm = makePageWith({
+      wx: {
+        onWindowResize: function (h) { on.push(h) },
+        offWindowResize: function (h) { off.push(h) }
+      }
+    })
+    vm.bindResizeListener()
+    expect(on.length).toBe(1)
+    expect(vm._resizeHandler).toBeTruthy()
+    vm.unbindResizeListener()
+    expect(off.length).toBe(1)
+    expect(off[0]).toBe(on[0])
+    expect(vm._resizeHandler).toBeNull()
+  })
+
+  test('TC-SIG2D-01h 窗口监听成对注册/注销(H5 window 通道,无泄漏)', function () {
+    const on = []
+    const off = []
+    const vm = makePageWith({
+      wx: undefined,
+      window: {
+        addEventListener: function (name, h) { on.push([name, h]) },
+        removeEventListener: function (name, h) { off.push([name, h]) }
+      }
+    })
+    vm.bindResizeListener()
+    expect(on.length).toBe(1)
+    expect(on[0][0]).toBe('resize')
+    vm.unbindResizeListener()
+    expect(off.length).toBe(1)
+    expect(off[0][1]).toBe(on[0][1])
+    expect(vm._resizeHandler).toBeNull()
+  })
+
+  test('TC-SIG2D-01i doBack 在 navigateBack 前恢复竖屏,_backed 防重复返回', function () {
+    const seq = []
+    const vm = makePageWith({
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      }
+    })
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+
+  test('TC-SIG2D-01j onUnload 注销监听并恢复竖屏', function () {
+    const seq = []
+    const sandbox = {
+      wx: {
+        setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) },
+        onWindowResize: function () {},
+        offWindowResize: function () { seq.push('offResize') }
+      },
+      uni: { getWindowInfo: function () { return { windowHeight: 390 } } }
+    }
+    const opts = loadOptions(PAGE, sandbox)
+    const data = typeof opts.data === 'function' ? opts.data() : opts.data
+    const vm = Object.assign({}, data, opts.methods)
+    vm.$refs = {}
+    vm.bindResizeListener()
+    opts.onUnload.call(vm)
+    expect(seq).toEqual(['offResize', 'orient:portrait'])
+  })
+
   test('TC-SIG2D-02 computeCanvasHeight 保留竖屏计算(174/214 两档 + 160 下限)', function () {
     expect(makePage(390, false).computeCanvasHeight()).toBe(390 - 174)
     expect(makePage(390, true).computeCanvasHeight()).toBe(390 - 214)
@@ -205,6 +331,38 @@ describe('签名实现重写 v2(竖屏直签 + canvas 2d)· signature.canvas
     expect(padScript).not.toMatch(/clearRect[\s\S]{0,80}ctx\.draw\(\)/)
   })
 
+  test('TC-SIG2D-07b height watch 仅在值变化且非 savingLive/销毁/已书写时重建 2d 位图', function () {
+    const opts = loadOptions(PAD, { uni: {}, wx: undefined, window: undefined })
+    const watchFn = opts.watch && opts.watch.height
+    expect(typeof watchFn).toBe('function')
+
+    let initCalls = 0
+    function makeSelf(overrides) {
+      return Object.assign({
+        savingLive: false,
+        _destroyed: false,
+        hasDrawn: false,
+        $nextTick: function (cb) { cb() },
+        initCanvas: function (cb) { initCalls += 1; if (cb) cb(true) }
+      }, overrides || {})
+    }
+    // 值未变化:不重建
+    watchFn.call(makeSelf(), 200, 200)
+    expect(initCalls).toBe(0)
+    // 导出中(savingLive=true):不重建,避免清空书写中的笔迹
+    watchFn.call(makeSelf({ savingLive: true }), 220, 200)
+    expect(initCalls).toBe(0)
+    // 组件已销毁:不重建
+    watchFn.call(makeSelf({ _destroyed: true }), 220, 200)
+    expect(initCalls).toBe(0)
+    // 加固:已书写(hasDrawn=true)时跳过重建,避免清空笔迹却仍标记 hasContent 导致导出空白
+    watchFn.call(makeSelf({ hasDrawn: true }), 220, 200)
+    expect(initCalls).toBe(0)
+    // 正常值变化:$nextTick 后重建一次
+    watchFn.call(makeSelf(), 220, 200)
+    expect(initCalls).toBe(1)
+  })
+
   // ---------- 异常流契约(台账 TC-SIG2D-01-E / 02-E) ----------
 
   test('TC-SIG2D-01-E 2d 初始化失败重试 + _destroyed 防销毁竞态', function () {

+ 1 - 1
cfc-frontend/__tests__/pages/signature/signature.landscape.superseded.js

@@ -19,7 +19,7 @@ const fs = require('fs')
 const path = require('path')
 
 const PAGE = path.join(__dirname, '../../../pages/signature/signature.vue')
-const PAD = path.join(__dirname, '../../../components/hand-signature/hand-signature.vue')
+const PAD = path.join(__dirname, '../../../pages/signature/components/hand-signature/hand-signature.vue')
 
 // import 语句 -> 同名空桩变量(保留脚本内引用,避免 ReferenceError)
 function stubImports(code) {

+ 26 - 9
cfc-frontend/components/hand-signature/hand-signature.vue → cfc-frontend/pages/signature/components/hand-signature/hand-signature.vue

@@ -139,6 +139,23 @@ export default {
         this.hasContent = !!val
         this.hasDrawn = !!val
       }
+    },
+    // 横竖屏切换时父组件(签名页)会更新 height prop;2d canvas 位图尺寸需与 CSS 高度
+    // 同步重建,否则画布被压扁/拉伸、导出区域错误。入口旋转发生在书写之前,重建不会丢
+    // 笔迹;旋转后方向锁定,书写中不会再触发 resize。$nextTick 确保 DOM 高度已更新后再查询。
+    // 加固:若用户已书写(hasDrawn=true)则跳过重建,避免清空笔迹却仍标记 hasContent
+    // 导致后续导出空白图——宁可保留笔迹、牺牲尺寸同步(该场景理论不触发,属极端兜底)。
+    height: function(newVal, oldVal) {
+      if (newVal === oldVal || this._destroyed || this.savingLive || this.hasDrawn) return
+      var self = this
+      this.$nextTick(function() {
+        if (self._destroyed) return
+        self.initCanvas(function(success) {
+          if (!success) {
+            console.log('[HandSignature] 高度变化后重初始化失败', self.canvasId)
+          }
+        })
+      })
     }
   },
   mounted: function() {
@@ -393,9 +410,9 @@ export default {
   margin-bottom: 16rpx;
 }
 .agree-box {
-  flex: 0 0 32rpx;
-  width: 32rpx;
-  height: 32rpx;
+  flex: 0 0 16px;
+  width: 16px;
+  height: 16px;
   margin-right: 12rpx;
   margin-top: 2rpx;
   border: 2rpx solid #c8c9cc;
@@ -410,13 +427,13 @@ export default {
   border-color: $accent-indigo;
 }
 .agree-tick {
-  font-size: calc(22rpx * var(--font-scale, 1));
+  font-size: calc(11px * var(--font-scale, 1));
   color: $app-surface;
   line-height: 1;
 }
 .agree-text {
   flex: 1;
-  font-size: calc(24rpx * var(--font-scale, 1));
+  font-size: calc(12px * var(--font-scale, 1));
   color: $facade-text-body;
   line-height: 1.5;
 }
@@ -446,11 +463,11 @@ export default {
   margin-top: 16rpx;
 }
 .btn-sign-clear {
-  flex: 0 0 160rpx;
+  flex: 0 0 80px;
   height: 72rpx;
   line-height: 72rpx;
   margin-right: 20rpx;
-  font-size: calc(26rpx * var(--font-scale, 1));
+  font-size: calc(13px * var(--font-scale, 1));
   color: $facade-text-body;
   background: $facade-fill-2;
   border: none;
@@ -460,10 +477,10 @@ export default {
   opacity: 0.5;
 }
 .btn-sign-confirm {
-  flex: 0 0 200rpx;
+  flex: 0 0 100px;
   height: 72rpx;
   line-height: 72rpx;
-  font-size: calc(26rpx * var(--font-scale, 1));
+  font-size: calc(13px * var(--font-scale, 1));
   color: $app-surface;
   background: linear-gradient(135deg, $accent-indigo 0%, $accent-indigo-deep 100%);
   border: none;

+ 74 - 12
cfc-frontend/pages/signature/signature.vue

@@ -6,7 +6,7 @@
       <text class="sig-subtitle">请在下方空白区域书写签名,完成后点击「确认」</text>
     </view>
 
-    <!-- 画布区(竖屏全宽,高度 = 视口高 − 固定栏,签名区域充足) -->
+    <!-- 画布区(横屏全宽,高度 = 短边视口高 − 固定栏,签名区域充足) -->
     <view class="sig-canvas-area">
       <hand-signature
         ref="signaturePad"
@@ -30,16 +30,16 @@
 
 <script>
 /*
- * 独立竖屏直签签名页(不依赖横屏)
- * - 复用通用 HandSignature 组件(只读复用,不改组件本身)
+ * 独立签名页(进入自动横屏、退出恢复竖屏;H5 等不支持的环境降级竖屏直签)
+ * - 复用通用 HandSignature 组件(只读复用,不改组件本身的 canvas 2d 逻辑)
  * - 入参:prefill 经 utils/signature-bus.js 模块级写入(base64 过长不进 URL);
  *         requireAgree / agreeText 走 URL 参数
- * - 竖屏直签:不再调用 wx.setPageOrientation / screen.orientation.lock,
- *   任何机型(含无法自动横屏的)进入即竖屏可签
+ * - 横屏:仅微信小程序调用 wx.setPageOrientation 自动切横屏,旋转后重算画布高度
+ *   (窗口尺寸监听 + setPageOrientation 回调双兜底);H5 降级不切横屏
  * - 回传:onConfirm 经 getOpenerEventChannel().emit('signatureConfirm', dataUrl) 后 navigateBack;
  *         返回按钮直接 navigateBack(不回传)
  */
-import HandSignature from '@/components/hand-signature/hand-signature.vue'
+import HandSignature from '@/pages/signature/components/hand-signature/hand-signature.vue'
 import { consumeSignaturePrefill } from '@/utils/signature-bus.js'
 
 export default {
@@ -52,8 +52,10 @@ export default {
       requireAgree: false,
       // 同意声明文案
       agreeText: '我已阅读并确认以上内容真实、准确',
-      // 画布高度(px,按竖屏视口高度计算)
+      // 画布高度(px,横屏下按短边视口高度计算,旋转后由 refreshCanvasHeight 重算)
       canvasHeight: 200,
+      // 窗口尺寸变化监听句柄(onUnload 注销,防泄漏)
+      _resizeHandler: null,
       // 防止重复 navigateBack
       _backed: false
     }
@@ -69,6 +71,15 @@ export default {
       if (text) this.agreeText = text
     }
     this.canvasHeight = this.computeCanvasHeight()
+    // 进入即自动横屏(微信小程序);H5 等不支持的环境降级为竖屏直签
+    this.enterLandscape()
+    // 监听窗口尺寸变化(横竖屏切换时重算画布高度)
+    this.bindResizeListener()
+  },
+  onUnload: function() {
+    // 退出前注销尺寸监听并恢复竖屏,防止监听泄漏并确保返回后页面回到竖屏
+    this.unbindResizeListener()
+    this.restorePortrait()
   },
   methods: {
     // 兼容运行时未解码/已解码两种情况(含特殊字符时兜底不抛错)
@@ -81,7 +92,9 @@ export default {
       }
       return out
     },
-    // 竖屏视口高度(长边,如 812/844)减去顶部标题/底部操作栏/同意声明占位,得到画布可书写高度
+    // 视口高度减去固定栏(顶部标题/底部操作栏/同意声明占位)得到画布可书写高度。
+    // 横屏时 uni.getWindowInfo().windowHeight 即为短边(如 390),旋转后必须重跑本方法,
+    // 否则画布仍按竖屏长边(如 844)计算,会被 overflow:hidden 裁剪成窄条。
     computeCanvasHeight: function() {
       var win = null
       try {
@@ -92,11 +105,58 @@ export default {
         win = null
       }
       var vh = (win && win.windowHeight) ? win.windowHeight : 400
-      // 顶部标题 ~66px + 底部操作栏 92px + 同意声明 ~40px + 留白 16px
+      // 顶部标题 ~66px + 底部操作栏 92px + 同意声明 ~40px + 留白 16px(可按横屏实际固定栏微调)
       var reserved = this.requireAgree ? 214 : 174
       var h = parseInt(vh, 10) - reserved
       return h > 160 ? h : 160
     },
+    // 旋转后重算画布高度(height prop 响应式,组件 2d 初始化会读取最新 height)
+    refreshCanvasHeight: function() {
+      this.canvasHeight = this.computeCanvasHeight()
+    },
+    // 进入自动横屏:仅微信小程序支持;H5 等环境降级不切横屏
+    enterLandscape: function() {
+      var self = this
+      if (typeof wx === 'undefined' || typeof wx.setPageOrientation !== 'function') {
+        return
+      }
+      wx.setPageOrientation({
+        orientation: 'landscape',
+        complete: function() {
+          // complete 在 success/fail 后均触发,作为唯一重算点(避免 success+complete 重复计算)
+          self.refreshCanvasHeight()
+        }
+      })
+    },
+    // 退出恢复竖屏,确保返回后其它页面保持竖屏
+    restorePortrait: function() {
+      if (typeof wx === 'undefined' || typeof wx.setPageOrientation !== 'function') {
+        return
+      }
+      wx.setPageOrientation({ orientation: 'portrait' })
+    },
+    // 注册窗口尺寸变化监听(微信 wx.onWindowResize / H5 window resize),onUnload 注销
+    bindResizeListener: function() {
+      var self = this
+      this._resizeHandler = function() {
+        self.refreshCanvasHeight()
+      }
+      if (typeof wx !== 'undefined' && typeof wx.onWindowResize === 'function') {
+        wx.onWindowResize(this._resizeHandler)
+      } else if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
+        window.addEventListener('resize', this._resizeHandler)
+      }
+    },
+    // 注销窗口尺寸变化监听
+    unbindResizeListener: function() {
+      if (!this._resizeHandler) return
+      if (typeof wx !== 'undefined' && typeof wx.offWindowResize === 'function') {
+        wx.offWindowResize(this._resizeHandler)
+      } else if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
+        window.removeEventListener('resize', this._resizeHandler)
+      }
+      this._resizeHandler = null
+    },
     // HandSignature 确认导出成功(内部「确认签名」按钮已被隐藏,由页面「确认」驱动 handleConfirm 走到这里)
     onConfirm: function(dataUrl) {
       var channel = this.getOpenerEventChannel ? this.getOpenerEventChannel() : null
@@ -136,6 +196,8 @@ export default {
     doBack: function() {
       if (this._backed) return
       this._backed = true
+      // 退出前先恢复竖屏,确保返回后页面回到竖屏
+      this.restorePortrait()
       uni.navigateBack()
     }
   }
@@ -159,14 +221,14 @@ export default {
 }
 .sig-title {
   display: block;
-  font-size: calc(32rpx * var(--font-scale, 1));
+  font-size: calc(16px * var(--font-scale, 1));
   font-weight: bold;
   color: $facade-text-strong;
 }
 .sig-subtitle {
   display: block;
   margin-top: 4px;
-  font-size: calc(24rpx * var(--font-scale, 1));
+  font-size: calc(12px * var(--font-scale, 1));
   color: $facade-text-muted;
 }
 
@@ -196,7 +258,7 @@ export default {
   padding: 0;
   border: none;
   border-radius: 24rpx;
-  font-size: calc(30rpx * var(--font-scale, 1));
+  font-size: calc(15px * var(--font-scale, 1));
   text-align: center;
 }
 .sig-btn::after {

+ 1 - 1
tests/test-cases/signature.md

@@ -3,7 +3,7 @@
 > 关联 SRS:`docs/superpowers/specs/2026-11-09-signature-canvas2d-rewrite.md`(需求编号 `DOC-2026-11-09-signature-canvas2d-rewrite`,状态:待开发)。
 > **取代**:`docs/superpowers/specs/2026-11-08-signature-landscape-fix.md`(整份作废,见 §历史)与旧台账(横屏 TC-SIG-01~05 系列已作废归档)。
 > 能力键:`signature.canvas2d.rewrite`(前端 UI 纵切,无独立后端接口)。
-> 涉改文件:`cfc-frontend/pages/signature/signature.vue`(S4-1 竖屏直签)、`cfc-frontend/components/hand-signature/hand-signature.vue`(S4-2 canvas 2d)。
+> 涉改文件:`cfc-frontend/pages/signature/signature.vue`(S4-1 竖屏直签)、`cfc-frontend/pages/signature/components/hand-signature/hand-signature.vue`(S4-2 canvas 2d,位于 signature 子包内)。
 > 派生自 `user-scenarios/` 的家长/孩子签名回传场景(health-plan-summary / checkout / consignee-edit 跳转进入)。
 
 | TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |