test(client): accelerate T-103 feedback loop
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
@@ -48,6 +50,16 @@ def _hash(path: Path) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _source_png(size: tuple[int, int]) -> bytes:
|
||||||
|
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||||
|
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||||
|
image.paste((255, 0, 0), (0, 0, 8, 540))
|
||||||
|
output = BytesIO()
|
||||||
|
image.save(output, format="PNG")
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _default_xml() -> str:
|
def _default_xml() -> str:
|
||||||
return (
|
return (
|
||||||
"<hierarchy rotation='0'>"
|
"<hierarchy rotation='0'>"
|
||||||
@@ -116,12 +128,7 @@ def _write_raw(
|
|||||||
raw = root / "raw"
|
raw = root / "raw"
|
||||||
raw.mkdir(parents=True)
|
raw.mkdir(parents=True)
|
||||||
screenshot = raw / "screenshot.png"
|
screenshot = raw / "screenshot.png"
|
||||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
screenshot.write_bytes(_source_png(size))
|
||||||
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
|
||||||
for y in range(540):
|
|
||||||
for x in range(8):
|
|
||||||
image.putpixel((x, y), (255, 0, 0))
|
|
||||||
image.save(screenshot, format="PNG")
|
|
||||||
hierarchy = raw / "hierarchy.xml"
|
hierarchy = raw / "hierarchy.xml"
|
||||||
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||||
manifest = {
|
manifest = {
|
||||||
|
|||||||
@@ -124,6 +124,31 @@ def _center(bounds: str) -> tuple[int, int]:
|
|||||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def _flow(device: _RawDevice, timeout_seconds: float = 0.02) -> SkuSelectionFlow:
|
||||||
|
now = [0.0]
|
||||||
|
return SkuSelectionFlow(
|
||||||
|
UiautomatorSkuPanelAdapter(device, 10),
|
||||||
|
timeout_seconds,
|
||||||
|
0.01,
|
||||||
|
lambda: now[0],
|
||||||
|
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fast_runner_flow(
|
||||||
|
device: SkuPanelDevice,
|
||||||
|
entry_wait_timeout_seconds: float = 0.02,
|
||||||
|
) -> SkuSelectionFlow:
|
||||||
|
now = [0.0]
|
||||||
|
return SkuSelectionFlow(
|
||||||
|
device,
|
||||||
|
entry_wait_timeout_seconds,
|
||||||
|
0.01,
|
||||||
|
lambda: now[0],
|
||||||
|
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mutate_unique_node(
|
def _mutate_unique_node(
|
||||||
hierarchy: str,
|
hierarchy: str,
|
||||||
*,
|
*,
|
||||||
@@ -321,8 +346,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None:
|
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
flow = _flow(device)
|
||||||
flow = SkuSelectionFlow(adapter)
|
|
||||||
|
|
||||||
flow.open_sku_panel(_TARGET_URL)
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
|
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
|
||||||
@@ -332,7 +356,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
self.assertEqual(_actions(device, "pressKey"), [])
|
self.assertEqual(_actions(device, "pressKey"), [])
|
||||||
|
|
||||||
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
|
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
|
||||||
restored_flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(restored, 10))
|
restored_flow = _flow(restored)
|
||||||
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
|
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
|
||||||
restored_flow.exit_sku_panel_safely()
|
restored_flow.exit_sku_panel_safely()
|
||||||
@@ -342,7 +366,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
_flow(device).open_sku_panel(_TARGET_URL)
|
||||||
|
|
||||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||||
self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
|
self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
|
||||||
@@ -408,7 +432,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
|
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
|
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(
|
_flow(device).open_sku_panel(
|
||||||
_TARGET_URL,
|
_TARGET_URL,
|
||||||
_home_with_unverified_entry_labels(),
|
_home_with_unverified_entry_labels(),
|
||||||
)
|
)
|
||||||
@@ -477,7 +501,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
with self.subTest():
|
with self.subTest():
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
flow = _flow(device)
|
||||||
|
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
flow.open_sku_panel(_TARGET_URL, old_page)
|
flow.open_sku_panel(_TARGET_URL, old_page)
|
||||||
@@ -531,7 +555,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
|
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
|
_flow(device).select_sku_options(
|
||||||
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
|
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
|
||||||
)
|
)
|
||||||
self.assertEqual(_actions(device, "click"), [])
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
@@ -546,7 +570,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
for hierarchy in cases:
|
for hierarchy in cases:
|
||||||
with self.subTest(), self.assertRaises(SkuSelectionError):
|
with self.subTest(), self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
_flow(_RawDevice(hierarchy)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
|
|
||||||
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
|
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
|
||||||
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
|
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
|
||||||
@@ -585,13 +609,13 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
|
device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
_flow(device).open_sku_panel(_TARGET_URL)
|
||||||
self.assertEqual(_actions(device, "click"), [])
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
def test_color_readback_failure_never_attempts_second_option(self) -> None:
|
def test_color_readback_failure_never_attempts_second_option(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
device.fail_color_readback = True
|
device.fail_color_readback = True
|
||||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
flow = _flow(device)
|
||||||
flow.open_sku_panel(_TARGET_URL)
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
@@ -599,7 +623,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||||
device = _RawDevice()
|
device = _RawDevice()
|
||||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
flow = _flow(device)
|
||||||
flow.open_sku_panel(_TARGET_URL)
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
device.select_alternates()
|
device.select_alternates()
|
||||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
@@ -613,10 +637,10 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
with self.subTest(replacement=replacement):
|
with self.subTest(replacement=replacement):
|
||||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
|
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
_flow(device).read_sku_unit_price()
|
||||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
|
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
_flow(device).read_sku_unit_price()
|
||||||
clickable_parent = _mutate_unique_node(
|
clickable_parent = _mutate_unique_node(
|
||||||
_FIXTURE.read_text(encoding="utf-8"),
|
_FIXTURE.read_text(encoding="utf-8"),
|
||||||
class_name="android.widget.LinearLayout",
|
class_name="android.widget.LinearLayout",
|
||||||
@@ -625,7 +649,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
value="true",
|
value="true",
|
||||||
)
|
)
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
|
_flow(_RawDevice(clickable_parent)).read_sku_unit_price()
|
||||||
|
|
||||||
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
|
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
|
||||||
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"}
|
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"}
|
||||||
@@ -671,13 +695,13 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
|
|
||||||
device = NoPanelAfterEntry()
|
device = NoPanelAfterEntry()
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
_flow(device).open_sku_panel(_TARGET_URL)
|
||||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||||
|
|
||||||
duplicate = _duplicate_entry()
|
duplicate = _duplicate_entry()
|
||||||
device = _RawDevice(duplicate)
|
device = _RawDevice(duplicate)
|
||||||
with self.assertRaises(SkuSelectionError):
|
with self.assertRaises(SkuSelectionError):
|
||||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
_flow(device).open_sku_panel(_TARGET_URL)
|
||||||
self.assertEqual(_actions(device, "click"), [])
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
||||||
@@ -719,6 +743,11 @@ class _CompletedFlow:
|
|||||||
|
|
||||||
|
|
||||||
class SkuSelectionRunnerTests(unittest.TestCase):
|
class SkuSelectionRunnerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
flow_patch = patch.object(runner_module, "SkuSelectionFlow", _fast_runner_flow)
|
||||||
|
flow_patch.start()
|
||||||
|
self.addCleanup(flow_patch.stop)
|
||||||
|
|
||||||
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
|
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
|
||||||
device.hierarchy = "<hierarchy />"
|
device.hierarchy = "<hierarchy />"
|
||||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||||
@@ -1018,7 +1047,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
|
|
||||||
device = _RawDevice(); device.select_alternates()
|
device = _RawDevice(); device.select_alternates()
|
||||||
device.package = "other"
|
device.package = "other"
|
||||||
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
|
with self.assertRaises(SkuSelectionError): _flow(device).exit_sku_panel_safely()
|
||||||
self.assertEqual(_actions(device, "pressKey"), [])
|
self.assertEqual(_actions(device, "pressKey"), [])
|
||||||
|
|
||||||
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None:
|
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None:
|
||||||
|
|||||||
+5
-1
@@ -25,7 +25,7 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T04:34:06Z sha256=6e089363c4d47d10e02c1af12cebfb57a40dec4d9d04459c6438c9b5aa0987ba -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T04:46:50Z sha256=f225a9770923a004a3bae956e052e0cecdccee2e475b220c0487f534589bfb39 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
||||||
@@ -274,6 +274,10 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
### 2026-08-05T04:33:56Z · ila
|
### 2026-08-05T04:33:56Z · ila
|
||||||
|
|
||||||
2026-08-05 T-103 reveal 离线实现与终审通过:新增独立 `capture_sku_reveal_spike.py`,只在拼多多 8.17.0 / PKG110 / Android 16 / 1080×2376、goods_id `937122477375`、完整 `COLOR_SELECTED_SIZE_HIDDEN` 前置两次重证后,封存并发出唯一一次固定 reveal 手势;RPC 正常或结果不明均只读调和且绝不重试。后置要求目标颜色保持精确 selected、S/M 均可见且 selected=false、摘要仍为“请选择:尺码”、提交硬拒绝区仍在面板下方;成功不 Back,等待人工核对。四态 classifier 已绑定 A/B/S/M 真机证据的完整父链与 exact selected set;未知 clickable 祖先、窄浮层、SystemUI 同 bounds、坏 bounds 均失败关闭。独立终审 PASS;聚焦 55/55、client 全量 255/255、compileall、agent-context、diff-check 全部 PASS。未引用数量、确认页、围栏、提交或支付能力。`needs_device=true`,T-103 保持 Doing,等待项目所有者从拼多多首页、仅 Wi-Fi ADB、使用全新 output-dir 执行唯一一次真机 reveal 并人工确认 before/after。
|
2026-08-05 T-103 reveal 离线实现与终审通过:新增独立 `capture_sku_reveal_spike.py`,只在拼多多 8.17.0 / PKG110 / Android 16 / 1080×2376、goods_id `937122477375`、完整 `COLOR_SELECTED_SIZE_HIDDEN` 前置两次重证后,封存并发出唯一一次固定 reveal 手势;RPC 正常或结果不明均只读调和且绝不重试。后置要求目标颜色保持精确 selected、S/M 均可见且 selected=false、摘要仍为“请选择:尺码”、提交硬拒绝区仍在面板下方;成功不 Back,等待人工核对。四态 classifier 已绑定 A/B/S/M 真机证据的完整父链与 exact selected set;未知 clickable 祖先、窄浮层、SystemUI 同 bounds、坏 bounds 均失败关闭。独立终审 PASS;聚焦 55/55、client 全量 255/255、compileall、agent-context、diff-check 全部 PASS。未引用数量、确认页、围栏、提交或支付能力。`needs_device=true`,T-103 保持 Doing,等待项目所有者从拼多多首页、仅 Wi-Fi ADB、使用全新 output-dir 执行唯一一次真机 reveal 并人工确认 before/after。
|
||||||
|
|
||||||
|
### 2026-08-05T04:45:40Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-011 方案 B 由 T-103 所有者就地完成 pdd/device 测试提速:仅修改测试。`test_sku_selection` 保持 47 项,无删除/合并/skip,使用生产既有时钟注入缝和 runner 测试工厂,把 4.029s 降至 1.725s(≤2s);`test_sku_evidence_sanitizer` 保持 25 项,确认根因不是时钟而是重复编码 1080×2376 合成 PNG,改为按尺寸缓存同一确定性 PNG bytes,把 5.383s 降至 4.360s。两模块合计 72/72 PASS;与 T-304 所有者的 polling 改动合并后 client 全量仍为 255/255,12.892s(≤15s),compileall/diff-check/agent-context 均通过。未修改生产代码或任何规格/价格/提交安全断言。T-103 仍因真机 reveal 与最终完整链路人工验收保持 Doing。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user