Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e7354a809 | ||
|
|
642604f688 | ||
|
|
551909ad0c |
@@ -45,6 +45,8 @@ SHA-256 内容哈希不可变保存,重复上传返回 `duplicate` 状态并
|
|||||||
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
公司账号创建时生成随机一次性初始密码,只在创建响应中显示一次,
|
||||||
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
首次登录强制改密(「重置密码」同样生成随机一次性密码并吊销会话)。
|
||||||
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
- 会话有效期 8 小时;同一账号同一 IP 10 分钟内登录失败 5 次将被限流。
|
||||||
|
内网测试环境可设 `APP_LOGIN_RATE_LIMIT_DISABLED=1` 暂时关闭该锁定
|
||||||
|
(默认未设置 = 保持限流,正式环境不得开启该变量)。
|
||||||
|
|
||||||
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
访问地址(服务默认监听 `0.0.0.0:4173`,同局域网设备把 `127.0.0.1` 换成本机局域网 IP 即可访问;可用环境变量 `APP_HOST` / `APP_PORT` 覆盖):
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
|
||||||
且在窗口内不再记录新尝试,行为确定、可测试。
|
且在窗口内不再记录新尝试,行为确定、可测试。
|
||||||
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
|
||||||
|
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
|
||||||
|
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
|
||||||
|
「5 次失败锁 10 分钟」的生产安全策略不变。
|
||||||
|
|
||||||
## 角色与公司绑定
|
## 角色与公司绑定
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HEL-174: 360 / 820 / 1440 截图——待确认黄 vs 已完成绿。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from functools import partial
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WEB = ROOT / "web"
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "hel174-shots"
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
VIEWPORTS = [
|
||||||
|
(1440, 900, "1440"),
|
||||||
|
(820, 900, "820"),
|
||||||
|
(360, 800, "360"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_fn(source: str, name: str) -> str:
|
||||||
|
marker = f"function {name}("
|
||||||
|
start = source.index(marker)
|
||||||
|
depth = 0
|
||||||
|
for i, ch in enumerate(source[start:], start):
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return source[start : i + 1]
|
||||||
|
raise RuntimeError(name)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app_js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
inject = "\n".join(
|
||||||
|
[
|
||||||
|
"const state = {};",
|
||||||
|
"function $(sel, root) { return (root || document).querySelector(sel); }",
|
||||||
|
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
|
||||||
|
extract_fn(app_js, "formatWorkspaceAmount"),
|
||||||
|
extract_fn(app_js, "applyCompanyWorkspace"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = httpd.server_address[1]
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
html = f"""<!doctype html>
|
||||||
|
<html lang="zh-CN"><head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<link rel="stylesheet" href="{base}/design-system.css"/>
|
||||||
|
<title>HEL-174 往来确认状态色</title>
|
||||||
|
<style>
|
||||||
|
body {{ margin: 0; background: var(--bg); font-family: var(--font-body); color: var(--fg); }}
|
||||||
|
.shot-wrap {{ padding: 16px; max-width: 1100px; margin: 0 auto; }}
|
||||||
|
.shot-label {{ font-size: 13px; color: var(--muted); margin: 0 0 10px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-portal="company">
|
||||||
|
<div class="shot-wrap">
|
||||||
|
<p class="shot-label" id="shotLabel">状态预览</p>
|
||||||
|
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
|
||||||
|
<div class="card" id="workspaceTodos" style="margin-top:12px;">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
|
||||||
|
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
|
||||||
|
</div>
|
||||||
|
<div id="workspaceTodoList"></div>
|
||||||
|
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:12px;">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">…</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flow">
|
||||||
|
<a class="flow-step part" href="#upload">
|
||||||
|
<div class="fs-top"><span class="fs-idx">01</span><span class="fs-dot"></span><span class="fs-name">流水导入</span></div>
|
||||||
|
<div class="fs-state">部分完成</div>
|
||||||
|
</a>
|
||||||
|
<a class="flow-step done" href="#manual">
|
||||||
|
<div class="fs-top"><span class="fs-idx">02</span><span class="fs-dot"></span><span class="fs-name">手工补录</span></div>
|
||||||
|
<div class="fs-state">已完成</div>
|
||||||
|
</a>
|
||||||
|
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
|
||||||
|
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
|
||||||
|
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
|
||||||
|
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
|
||||||
|
</a>
|
||||||
|
<div class="flow-step wait">
|
||||||
|
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
|
||||||
|
<div class="fs-state">等待集团</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notice warn" id="blocking-notice" style="margin-top:12px;">
|
||||||
|
<div>
|
||||||
|
<div class="n-title" id="notice-title">…</div>
|
||||||
|
<div class="n-body" id="notice-body">…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<span class="pill pill-warn">待确认</span>
|
||||||
|
<span class="pill pill-success" style="margin-left:8px;">已确认</span>
|
||||||
|
</div>
|
||||||
|
<span class="tab-count" id="count-match" hidden>0</span>
|
||||||
|
<nav class="side-nav" hidden><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
|
||||||
|
</div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||||
|
page = browser.new_page()
|
||||||
|
for width, height, tag in VIEWPORTS:
|
||||||
|
page.set_viewport_size({"width": width, "height": height})
|
||||||
|
page.set_content(html, wait_until="domcontentloaded")
|
||||||
|
page.add_script_tag(content=inject)
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
document.getElementById('shotLabel').textContent = '待确认(应黄)';
|
||||||
|
applyCompanyWorkspace({
|
||||||
|
pending_unilateral: 3, pending_total: 3,
|
||||||
|
unilateral_events: [
|
||||||
|
{event_id:1, amount:'100000.00', currency:'CNY',
|
||||||
|
counterparty_company_name:'金牛贸易', effective_at:'2026-07-03'},
|
||||||
|
{event_id:2, amount:'200000.00', currency:'CNY',
|
||||||
|
counterparty_company_name:'金牛物流', effective_at:'2026-07-11'},
|
||||||
|
{event_id:3, amount:'300000.00', currency:'CNY',
|
||||||
|
counterparty_company_name:'金牛置业', effective_at:'2026-07-24'}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.screenshot(path=str(OUT / f"hel174-pending-{tag}.png"), full_page=True)
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
document.getElementById('shotLabel').textContent = '已完成(应绿)';
|
||||||
|
applyCompanyWorkspace({
|
||||||
|
pending_unilateral: 0, pending_total: 0, unilateral_events: []
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.screenshot(path=str(OUT / f"hel174-done-{tag}.png"), full_page=True)
|
||||||
|
|
||||||
|
colors = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
|
||||||
|
const state = document.getElementById('workspaceConfirmState');
|
||||||
|
const pill = document.getElementById('workspacePendingStatus');
|
||||||
|
return {
|
||||||
|
stepClass: step.className,
|
||||||
|
stateText: state.textContent,
|
||||||
|
stateColor: getComputedStyle(state).color,
|
||||||
|
pillClass: pill.className,
|
||||||
|
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
assert "done" in colors["stepClass"], colors
|
||||||
|
assert "pill-success" in colors["pillClass"], colors
|
||||||
|
assert colors["stateText"] == "已完成", colors
|
||||||
|
assert not colors["overflowX"], colors
|
||||||
|
print(tag, colors)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
httpd.shutdown()
|
||||||
|
print("shots:", sorted(p.name for p in OUT.glob("*.png")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+25
-12
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import string
|
import string
|
||||||
@@ -28,6 +29,17 @@ RATE_LIMIT_WINDOW_MINUTES = 10
|
|||||||
INITIAL_PASSWORD_LENGTH = 12
|
INITIAL_PASSWORD_LENGTH = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _env_flag(value: str | None) -> bool:
|
||||||
|
"""Parse a yes/no style environment flag; blank/absent means False."""
|
||||||
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
# Ops switch for internal test environments: APP_LOGIN_RATE_LIMIT_DISABLED=1
|
||||||
|
# turns off the login-failure lockout entirely. The default (unset) keeps the
|
||||||
|
# production policy — 5 failures within 10 minutes lock the (账号, IP) pair.
|
||||||
|
RATE_LIMIT_DISABLED = _env_flag(os.environ.get("APP_LOGIN_RATE_LIMIT_DISABLED"))
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
"""Hash ``password`` as ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||||
salt = secrets.token_bytes(16)
|
salt = secrets.token_bytes(16)
|
||||||
@@ -146,18 +158,19 @@ def authenticate(
|
|||||||
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
Every non-rate-limited attempt is recorded in ``login_attempts`` and
|
||||||
``audit_log``; the password itself is never stored anywhere.
|
``audit_log``; the password itself is never stored anywhere.
|
||||||
"""
|
"""
|
||||||
window_start = (
|
if not RATE_LIMIT_DISABLED:
|
||||||
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
window_start = (
|
||||||
).isoformat()
|
datetime.now(timezone.utc) - timedelta(minutes=RATE_LIMIT_WINDOW_MINUTES)
|
||||||
failures = connection.execute(
|
).isoformat()
|
||||||
"""
|
failures = connection.execute(
|
||||||
SELECT COUNT(*) AS n FROM login_attempts
|
"""
|
||||||
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
SELECT COUNT(*) AS n FROM login_attempts
|
||||||
""",
|
WHERE username = ? AND ip = ? AND success = 0 AND created_at >= ?
|
||||||
(username, ip, window_start),
|
""",
|
||||||
).fetchone()
|
(username, ip, window_start),
|
||||||
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
).fetchone()
|
||||||
return None, "rate_limited"
|
if failures["n"] >= RATE_LIMIT_MAX_FAILURES:
|
||||||
|
return None, "rate_limited"
|
||||||
|
|
||||||
user = connection.execute(
|
user = connection.execute(
|
||||||
"SELECT * FROM users WHERE username = ?", (username,)
|
"SELECT * FROM users WHERE username = ?", (username,)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import hashlib
|
import hashlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from bank_importer import auth
|
from bank_importer import auth
|
||||||
from bank_importer.db import connect, migrate, utc_now
|
from bank_importer.db import connect, migrate, utc_now
|
||||||
@@ -167,6 +168,40 @@ class AuthenticateTests(AuthTestCase):
|
|||||||
self.assertIsNotNone(user)
|
self.assertIsNotNone(user)
|
||||||
self.assertIsNone(reason)
|
self.assertIsNone(reason)
|
||||||
|
|
||||||
|
def test_env_flag_parses_truthy_and_falsy_values(self) -> None:
|
||||||
|
cases = {
|
||||||
|
"1": True,
|
||||||
|
"true": True,
|
||||||
|
"YES": True,
|
||||||
|
" on ": True,
|
||||||
|
"": False,
|
||||||
|
"0": False,
|
||||||
|
"false": False,
|
||||||
|
"off": False,
|
||||||
|
None: False,
|
||||||
|
}
|
||||||
|
for value, expected in cases.items():
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertEqual(expected, auth._env_flag(value))
|
||||||
|
|
||||||
|
def test_rate_limit_enabled_by_default(self) -> None:
|
||||||
|
self.assertFalse(auth.RATE_LIMIT_DISABLED)
|
||||||
|
|
||||||
|
def test_env_switch_disables_rate_limit_but_not_credential_checks(self) -> None:
|
||||||
|
self.create_company_user()
|
||||||
|
with mock.patch.object(auth, "RATE_LIMIT_DISABLED", True):
|
||||||
|
for _ in range(auth.RATE_LIMIT_MAX_FAILURES + 2):
|
||||||
|
user, reason = auth.authenticate(
|
||||||
|
self.connection, "cashier-a", "Wrong999", "10.0.0.1"
|
||||||
|
)
|
||||||
|
self.assertIsNone(user)
|
||||||
|
self.assertEqual("bad_credentials", reason)
|
||||||
|
user, reason = auth.authenticate(
|
||||||
|
self.connection, "cashier-a", "Init1234", "10.0.0.1"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user)
|
||||||
|
self.assertIsNone(reason)
|
||||||
|
|
||||||
|
|
||||||
class SessionTests(AuthTestCase):
|
class SessionTests(AuthTestCase):
|
||||||
def test_create_and_resolve_roundtrip(self) -> None:
|
def test_create_and_resolve_roundtrip(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""HEL-174: 公司端往来确认完成态用 success 绿,待确认保留 warn 黄。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from functools import partial
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WEB = ROOT / "web"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError: # pragma: no cover - 默认测试环境无浏览器依赖
|
||||||
|
sync_playwright = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmStatusSourceContractTests(unittest.TestCase):
|
||||||
|
"""不依赖浏览器:锁住完成态切绿 / 待确认仍黄的实现契约。"""
|
||||||
|
|
||||||
|
def test_app_js_toggles_success_green_when_pending_zero(self) -> None:
|
||||||
|
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('pill ${total ? "pill-warn" : "pill-success"}', js)
|
||||||
|
self.assertIn('flowStep.classList.toggle("doing", pending > 0)', js)
|
||||||
|
self.assertIn('flowStep.classList.toggle("done", pending === 0)', js)
|
||||||
|
self.assertIn('notice.classList.toggle("warn", pending > 0)', js)
|
||||||
|
self.assertIn('notice.classList.toggle("success", pending === 0)', js)
|
||||||
|
# 文案:完成=已完成,不把待确认一并改绿
|
||||||
|
self.assertRegex(js, r'status\.textContent = total \? `\$\{total\} 项待处理` : "已完成"')
|
||||||
|
self.assertRegex(js, r'flowState\.textContent = pending \? `待处理 \$\{pending\} 笔` : "已完成"')
|
||||||
|
|
||||||
|
def test_design_tokens_map_done_to_success_doing_to_warn(self) -> None:
|
||||||
|
css = (WEB / "design-system.css").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("--success:", css)
|
||||||
|
self.assertIn("--warn:", css)
|
||||||
|
self.assertIn(".flow-step.done .fs-dot { background: var(--success); }", css)
|
||||||
|
self.assertIn(".flow-step.done .fs-state { color: var(--success); }", css)
|
||||||
|
self.assertIn(".flow-step.doing .fs-dot { background: var(--warn);", css)
|
||||||
|
self.assertIn(".pill-success { background: var(--success-soft); color: var(--success); }", css)
|
||||||
|
self.assertIn(".pill-warn { background: var(--warn-soft);", css)
|
||||||
|
self.assertIn(".notice.success { background: var(--success-soft);", css)
|
||||||
|
self.assertIn(".notice.warn { background: var(--warn-soft);", css)
|
||||||
|
|
||||||
|
def test_company_html_exposes_flow_step_and_cache_bust(self) -> None:
|
||||||
|
html = (WEB / "company.html").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('id="workspaceConfirmState"', html)
|
||||||
|
self.assertIn('id="workspacePendingStatus"', html)
|
||||||
|
self.assertIn('id="workspaceFlowSub"', html)
|
||||||
|
self.assertIn('data-view-link="reconcile"', html)
|
||||||
|
self.assertIn("app.js?v=11", html)
|
||||||
|
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
||||||
|
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_fn(source: str, name: str) -> str:
|
||||||
|
marker = f"function {name}("
|
||||||
|
start = source.index(marker)
|
||||||
|
depth = 0
|
||||||
|
for i, ch in enumerate(source[start:], start):
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return source[start : i + 1]
|
||||||
|
raise AssertionError(f"未能截取 function {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验")
|
||||||
|
class ConfirmStatusDomTests(unittest.TestCase):
|
||||||
|
"""真实浏览器:pending>0 为黄,pending=0 为绿。"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
|
||||||
|
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
cls.port = cls.httpd.server_address[1]
|
||||||
|
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||||
|
cls.thread.start()
|
||||||
|
cls.base = f"http://127.0.0.1:{cls.port}"
|
||||||
|
app_js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
cls.inject_js = "\n".join(
|
||||||
|
[
|
||||||
|
"const state = {};",
|
||||||
|
"function $(sel, root) { return (root || document).querySelector(sel); }",
|
||||||
|
"function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }",
|
||||||
|
_extract_fn(app_js, "formatWorkspaceAmount"),
|
||||||
|
_extract_fn(app_js, "applyCompanyWorkspace"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls) -> None:
|
||||||
|
cls.httpd.shutdown()
|
||||||
|
cls.httpd.server_close()
|
||||||
|
|
||||||
|
def _open_fixture(self, page):
|
||||||
|
page.goto(f"{self.base}/design-system.css", wait_until="domcontentloaded")
|
||||||
|
page.set_content(
|
||||||
|
f"""<!doctype html>
|
||||||
|
<html lang="zh-CN"><head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="stylesheet" href="{self.base}/design-system.css"/>
|
||||||
|
</head>
|
||||||
|
<body data-portal="company">
|
||||||
|
<button id="workspaceUnilateralCta" class="btn btn-primary">去确认单边流水 (0)</button>
|
||||||
|
<div class="card" id="workspaceTodos">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">…</span></span>
|
||||||
|
<span class="pill pill-warn" id="workspacePendingStatus">加载中</span>
|
||||||
|
</div>
|
||||||
|
<div id="workspaceTodoList"></div>
|
||||||
|
<div class="table-foot" id="workspaceTodoFoot"><span>…</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-title">账期流程<span class="sub" id="workspaceFlowSub">…</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flow">
|
||||||
|
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
|
||||||
|
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
|
||||||
|
<div class="fs-state" id="workspaceConfirmState">加载中…</div>
|
||||||
|
<div class="fs-meta" id="workspaceConfirmMeta">…</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notice warn" id="blocking-notice">
|
||||||
|
<div class="n-title" id="notice-title">…</div>
|
||||||
|
<div class="n-body" id="notice-body">…</div>
|
||||||
|
</div>
|
||||||
|
<span class="tab-count" id="count-match">0</span>
|
||||||
|
<nav class="side-nav"><a data-view="reconcile"><span class="nav-badge">0</span></a></nav>
|
||||||
|
</body></html>""",
|
||||||
|
wait_until="domcontentloaded",
|
||||||
|
)
|
||||||
|
page.add_script_tag(content=self.inject_js)
|
||||||
|
page.wait_for_function("() => typeof applyCompanyWorkspace === 'function'")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _colors(page) -> dict:
|
||||||
|
return page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const step = document.querySelector('.flow-step[data-view-link="reconcile"]');
|
||||||
|
const state = document.getElementById('workspaceConfirmState');
|
||||||
|
const pill = document.getElementById('workspacePendingStatus');
|
||||||
|
const notice = document.getElementById('blocking-notice');
|
||||||
|
const cs = (el) => getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
stepClass: step.className,
|
||||||
|
stateText: state.textContent,
|
||||||
|
stateColor: cs(state).color,
|
||||||
|
pillClass: pill.className,
|
||||||
|
pillColor: cs(pill).color,
|
||||||
|
pillText: pill.textContent,
|
||||||
|
noticeClass: notice.className,
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pending_stays_warn_completed_turns_success(self) -> None:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||||
|
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||||
|
self._open_fixture(page)
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""() => applyCompanyWorkspace({
|
||||||
|
pending_unilateral: 2,
|
||||||
|
pending_total: 2,
|
||||||
|
unilateral_events: [
|
||||||
|
{event_id: 1, amount: '100.00', currency: 'CNY',
|
||||||
|
counterparty_company_name: '乙', effective_at: '2026-07-01'}
|
||||||
|
]
|
||||||
|
})"""
|
||||||
|
)
|
||||||
|
pending = self._colors(page)
|
||||||
|
self.assertIn("doing", pending["stepClass"])
|
||||||
|
self.assertNotIn("done", pending["stepClass"].split())
|
||||||
|
self.assertIn("pill-warn", pending["pillClass"])
|
||||||
|
self.assertNotIn("pill-success", pending["pillClass"])
|
||||||
|
self.assertIn("warn", pending["noticeClass"].split())
|
||||||
|
self.assertIn("待处理", pending["stateText"])
|
||||||
|
self.assertIn("待处理", pending["pillText"])
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""() => applyCompanyWorkspace({
|
||||||
|
pending_unilateral: 0,
|
||||||
|
pending_total: 0,
|
||||||
|
unilateral_events: []
|
||||||
|
})"""
|
||||||
|
)
|
||||||
|
done = self._colors(page)
|
||||||
|
self.assertIn("done", done["stepClass"])
|
||||||
|
self.assertNotIn("doing", done["stepClass"].split())
|
||||||
|
self.assertIn("pill-success", done["pillClass"])
|
||||||
|
self.assertNotIn("pill-warn", done["pillClass"])
|
||||||
|
self.assertIn("success", done["noticeClass"].split())
|
||||||
|
self.assertEqual("已完成", done["stateText"])
|
||||||
|
self.assertEqual("已完成", done["pillText"])
|
||||||
|
|
||||||
|
self.assertNotEqual(pending["stateColor"], done["stateColor"])
|
||||||
|
self.assertNotEqual(pending["pillColor"], done["pillColor"])
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+12
@@ -2619,6 +2619,18 @@ function applyCompanyWorkspace(payload) {
|
|||||||
? `单边流水 ${pending} 笔待确认`
|
? `单边流水 ${pending} 笔待确认`
|
||||||
: "已全部确认,等待集团结账";
|
: "已全部确认,等待集团结账";
|
||||||
}
|
}
|
||||||
|
// 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing)
|
||||||
|
const flowStep = flowState?.closest(".flow-step");
|
||||||
|
if (flowStep) {
|
||||||
|
flowStep.classList.toggle("doing", pending > 0);
|
||||||
|
flowStep.classList.toggle("done", pending === 0);
|
||||||
|
}
|
||||||
|
const flowSub = $("#workspaceFlowSub");
|
||||||
|
if (flowSub) {
|
||||||
|
flowSub.textContent = pending
|
||||||
|
? "当前停在第 3 步「往来确认」,完成后即可等待集团结账"
|
||||||
|
: "第 3 步「往来确认」已完成,等待集团复核与结账";
|
||||||
|
}
|
||||||
|
|
||||||
const countMatch = $("#count-match");
|
const countMatch = $("#count-match");
|
||||||
if (countMatch) countMatch.textContent = String(pending);
|
if (countMatch) countMatch.textContent = String(pending);
|
||||||
|
|||||||
+2
-2
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<span class="card-title">账期流程 · 2026-07<span class="sub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
|
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
|
||||||
<span class="pill pill-warn">结账日顺延至 08-29</span>
|
<span class="pill pill-warn">结账日顺延至 08-29</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow">
|
<div class="flow">
|
||||||
@@ -814,6 +814,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||||
<script src="app.js?v=10"></script>
|
<script src="app.js?v=11"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user