Compare commits

...
Author SHA1 Message Date
7e7354a809 HEL-174: 公司端往来确认完成态改为 success 绿
确认全部完成后,账期流程第 3 步从 doing(warn 黄)切到 done(success 绿),
本月待办 pill 与阻断提示同步用 success;待确认仍保留 warn。补前端契约与 DOM 色值测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 12:56:20 +00:00
总工 642604f688 HEL-171 部署合并:公司端工作台待办同步(bf5754e) + 登录限流运维开关(551909a) 2026-08-27 12:28:04 +00:00
总工andmultica-agent 551909ad0c HEL-166: 增加 APP_LOGIN_RATE_LIMIT_DISABLED 运维开关(默认保持限流)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 11:48:00 +00:00
8 changed files with 475 additions and 14 deletions
+2
View File
@@ -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` 覆盖):
+3
View File
@@ -26,6 +26,9 @@
- 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429, - 计数来自 `login_attempts` 表,窗口为滚动 10 分钟;触发后返回 429,
且在窗口内不再记录新尝试,行为确定、可测试。 且在窗口内不再记录新尝试,行为确定、可测试。
- 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。 - 失败提示统一为「账号或密码不正确」,不泄露是哪一部分错误。
- 运维开关:环境变量 `APP_LOGIN_RATE_LIMIT_DISABLED` 设为 `1/true/yes/on`
时完全跳过限流检查,仅限内网测试环境临时使用;默认未设置,保持
「5 次失败锁 10 分钟」的生产安全策略不变。
## 角色与公司绑定 ## 角色与公司绑定
+186
View File
@@ -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
View File
@@ -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,)
+35
View File
@@ -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:
+210
View File
@@ -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
View File
@@ -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
View File
@@ -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>