From eef7be72583211a1aa1a41124ba9cf0397111584 Mon Sep 17 00:00:00 2001 From: seagull Date: Sat, 12 Sep 2026 12:18:37 +0800 Subject: [PATCH] =?UTF-8?q?D2=E4=B8=9A=E5=8A=A1=E5=91=98H5+=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=A8=A1=E6=8B=9F=E5=85=A8=E7=BB=BF=20/=20P2-2?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=B8=85=E5=8D=95+ALLOWED=5FHOSTS=E9=80=9A?= =?UTF-8?q?=E9=85=8D=E7=AC=A6=E9=97=A8=20/=20P3-1=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS_AGI_ITERATION_7.md | 116 +++++ backend/config/settings/prod.py | 8 + backend/scripts/verify_salesman_h5.py | 212 +++++++++ docs/DEPLOY_CHECKLIST.md | 55 +++ frontend/src/pages/Salesman.vue | 630 ++++++++++++++++++++++++++ frontend/src/router/index.js | 3 +- 6 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 PROGRESS_AGI_ITERATION_7.md create mode 100644 backend/scripts/verify_salesman_h5.py create mode 100644 docs/DEPLOY_CHECKLIST.md create mode 100644 frontend/src/pages/Salesman.vue diff --git a/PROGRESS_AGI_ITERATION_7.md b/PROGRESS_AGI_ITERATION_7.md new file mode 100644 index 0000000..45ca036 --- /dev/null +++ b/PROGRESS_AGI_ITERATION_7.md @@ -0,0 +1,116 @@ +# PROGRESS_AGI_ITERATION_7 · D2 业务员 H5 + P2-2 部署清单 + +> 跑法口径:后端 `pytest` 全量(`tests/` + `apps/core/tests`),前端 `npm run build` + `npm test`。 +> - 后端 SQLite:**510 passed / 4 skipped**(4 skipped 均为 PG 专用并发用例) +> - 后端 PG 5433 `-m postgres`:**4 passed** +> - 前端:`npm run build` ✅ 5.64s;`npm test` ✅ 11 pass / 0 fail +> - `manage.py check` 0 issues;`makemigrations --check` No changes +> - D2 移动模拟:`backend/scripts/verify_salesman_h5.py` 全 10 步通过,0 console errors +> - P2-2:`docker compose config` exit 0 + `docs/DEPLOY_CHECKLIST.md` 落地;镜像构建/上线因无凭证未做,不虚报 +> - P3-1:按规格跳过,未建模 + +## 一、P2-3 D2 业务员移动开单 H5(`PROGRESS_BATCH_D.md:236` + `NEXT_PLAN.md` §P2-3) + +规格原文:复用 `Storefront.vue` 外壳 + `SalesBills` 取价/单位/抹零组件, +换业务员角色(外勤开单/查欠款/收款登记),Chrome 移动模拟全流程走通。 + +### 交付 + +- 新建 `frontend/src/pages/Salesman.vue`(约 550 行,`dist/assets/Salesman-*.js` 16.26 kB): + - 外壳:复用 Storefront 的 `sf-wrap` 移动布局(max-width 560px + 480px 断点纵排), + 登录用后台用户 JWT(`POST /auth/token/`,与 PC 同一套账号),公开路由 `/#/salesman`。 + - 外勤开单:客户/仓库下拉 + 商品搜索 + 单位下拉 + 数量; + 取价走 `GET /partner/price-quote/`(客户专属价→等级价→上次成交价→默认价, + 与 SalesBills 同源,source 标签同文案);单位换算走 + `GET /catalog/products//units/` + `utils/transaction.js` 的 `unitRate/lineAmount` + (与 SalesBills 同口径);税率/税额行内显示(价内拆分,与 D4 同公式); + 抹零(不抹/抹分/抹角/抹元)+ 应收合计;保存走 `POST /sales/bills/create-bill/` + 后自动 `POST /sales/bills//confirm/`(402 超限弹强制过账确认,与 SalesBills 同流程)。 + - 查欠款:`GET /finance/receivables/` 只留 open/partial(前端过滤,BaseTenantViewSet + 无精确 customer 过滤器,不伪造后端参数),显示单号/客户/日期/共欠/已收/未结余额 + 状态标签 + 未结合计。 + - 收款登记:`POST /finance/receipts/`(客户/单号必填/日期/金额>0/方式/备注), + 单号必填是实测结论(Receipt.bill_no 无自生成,漏填 400),表单 rules 与 + `Receivables.vue` 登记 dialog 同约束。 +- 路由:`frontend/src/router/index.js` 加 `/salesman` + `PUBLIC_ROUTES` 加 `salesman` + (P0-3 同类坑:不加则匿名被踢回 login)。 + +### 验证(干净库实跑,非残留数据) + +`backend/scripts/verify_salesman_h5.py`(Playwright Chromium,Pixel 5 移动模拟 +393×851 + touch + 移动 UA),dev 后端 :18099 + vite :15173(临时 verify 配置代理 /api): + +``` +[pre] customer=C001 warehouse=WH01 product=B001 token=ok +[1] login ok (mobile viewport 393x851) +[2] customer: OK:C001 张三便利店 / warehouse: OK:WH01 主仓库 +[3] add-to-order ok (qty=2) +[4] round_to: OK:抹角(0.1)/ submit+confirm clicked +[5] debt tab: 未结 ¥31.00(1 单)RC202609120001 … 未收 +[6] receipt customer OK / submit clicked +[7] console errors: 0 +[8] latest bill: XS202609120001 state=confirmed total=31.0000 +[9] receipt SMH50002 ok amount=50.0000 +[10] open receivables: 1, first balance=31.0000 +ALL SALESMAN-H5 CHECKS PASSED +``` + +验证后残留(XS202609120001/SMH50002/RC202609120001)已删,`default` 租户恢复 0 单据。 +为验证造的最小数据保留(alice/alice12345 + B001/C001/WH01/1000 库存),属可用 fixture。 + +### 踩坑 + +1. 密码框无 placeholder(只有前两个 input 有),用 `.sm-login input` 序号定位。 +2. 移动视口下 `li[role=option]`(非 `.el-select-dropdown .el-option`)且常判不可见, + Playwright click(连 force)会被 visible 挡 → 一律用 JS `offsetParent` 过滤可见项后 `.click()`。 +3. 收款 tab 切换用 `#tab-receipt/#pane-receipt` 稳定选择器,不用 role 或 is-active。 +4. 填单号前先 `Escape` 关残留 popper,否则输入框被浮层挡住无法 fill。 + +## 二、P2-2 部署 readiness(`NEXT_PLAN.md` §P2-2) + +- `docs/DEPLOY_CHECKLIST.md`(新建):本地校验表(7 项全 ✅ exit 0/实测值)+ + 上线 gate(环境变量/构建/迁移/collectstatic/DEBUG/PG/HTTPS/日志轮转/备份/冒烟)+ + 诚实记录(未构建镜像、未上线、无备份脚本,均不虚报)。 +- 附带真缺口修复:`backend/config/settings/prod.py` 拒绝 `DJANGO_ALLOWED_HOSTS` 含 `*`。 + 根因:`base.py:read_env` 把 dev `.env`(`DJANGO_ALLOWED_HOSTS=*`)写进 `os.environ`, + prod 空值门读到 `*` 静默通过。验证:不传变量 → `ImproperlyConfigured(通配符)`; + `=example.com` → check 0 issues。全仓 grep `settings.prod` 零命中,无测试依赖。 +- 本地校验实测:`docker compose config` exit 0(插值全展开); + prod `check` 0 issues;`collectstatic` 216 files;prod 基线 + (DEBUG=False / dealerhub INFO / PG pool 2/10/10 / HSTS 30 天+include+preload / + Secure Cookie / proxy https / DENY)逐项打印确认。 + +## 三、P3-1 跳过声明 + +按 `NEXT_PLAN.md` §P3-1 与 `PROGRESS_BATCH_D.md:238` 定调"按客户驱动,不提前做": +本轮未建 `Serial`/辅助属性/套装拆件,未动 inventory/catalog 模型, +`makemigrations --check` No changes 即为预期状态。 + +## 四、最终验证数字(本轮实跑) + +``` +backend pytest tests/(SQLite): 496 passed / 4 skipped +backend pytest 全量(含 core/tests): 510 passed / 4 skipped +backend PG -m postgres: 4 passed +frontend npm run build: ✓ 5.64s(含 Salesman-*.js 16.26 kB) +frontend npm test: 11 pass / 0 fail +manage.py check: 0 issues;makemigrations --check: No changes +D2 verify_salesman_h5.py: ALL PASSED(0 console errors) +``` + +## 五、变更文件清单 + +``` +frontend/src/pages/Salesman.vue [新] D2 业务员 H5 三职一页 +frontend/src/router/index.js [改] +/salesman 路由 + PUBLIC_ROUTES +backend/scripts/verify_salesman_h5.py [新] D2 移动模拟全流程脚本(10 步断言) +backend/config/settings/prod.py [改] ALLOWED_HOSTS 拒绝通配符 * +docs/DEPLOY_CHECKLIST.md [新] P2-2 readiness gate 清单 +frontend/dist/ [重建] +``` + +## 六、遗留 + +- 镜像构建 + 上线 192.168.5.7:无 Docker 目标机/服务器凭证,停在清单 + 本地校验。 +- 日志轮转/备份:运维侧动作,仓库无实现(清单已如实标注)。 +- D2 欠款页用前端过滤(open/partial + 按客户),数据量大时应补后端 `customer/status` + 精确过滤;当前为 H5 外勤场景可接受范围。 diff --git a/backend/config/settings/prod.py b/backend/config/settings/prod.py index d76717c..9fa7417 100644 --- a/backend/config/settings/prod.py +++ b/backend/config/settings/prod.py @@ -16,6 +16,14 @@ if not _configured_hosts: ALLOWED_HOSTS = [h.strip() for h in _configured_hosts.split(",") if h.strip()] if not ALLOWED_HOSTS: raise ImproperlyConfigured("DJANGO_ALLOWED_HOSTS 解析为空,请填入实际域名") +if "*" in ALLOWED_HOSTS: + # base 的 read_env 会把 backend/.env(dev 用,含 DJANGO_ALLOWED_HOSTS=*) + # 写进 os.environ;生产若不显式覆盖就会静默接受任意 Host。 + # 通配符在生产无意义,直接拒绝,强制部署者显式声明域名。 + raise ImproperlyConfigured( + "生产环境不允许 DJANGO_ALLOWED_HOSTS 含通配符 '*'(可能继承了 dev 的 .env)," + "请显式设置真实域名,如 DJANGO_ALLOWED_HOSTS=erp.example.com" + ) # PostgreSQL(用 DATABASE_URL 环境变量) DATABASES = { diff --git a/backend/scripts/verify_salesman_h5.py b/backend/scripts/verify_salesman_h5.py new file mode 100644 index 0000000..56c97e2 --- /dev/null +++ b/backend/scripts/verify_salesman_h5.py @@ -0,0 +1,212 @@ +"""D2 业务员移动开单 H5 全流程验证(Chrome 移动模拟)。 + +跑法: python scripts/verify_salesman_h5.py +前置: 后端 dev 在 127.0.0.1:18099, vite(verify配置) 在 127.0.0.1:15173 +流程: 登录(alice/default) → 开单tab(客户/仓库/商品/单位/取价/数量/抹零/保存过账) + → 欠款tab(未结单+余额) → 收款tab(登记收款单) → 断言后端落库 +""" +import asyncio +import json +import sys +import urllib.request + +BASE = "http://127.0.0.1:18099" +PAGE = "http://127.0.0.1:15173/static/#/salesman" + + +def api(method, path, token=None, tenant="default", body=None): + req = urllib.request.Request( + BASE + path, + data=json.dumps(body).encode() if body is not None else None, + method=method, + headers={"Content-Type": "application/json"}, + ) + if token: + req.add_header("Authorization", f"Bearer {token}") + req.add_header("X-Tenant-Id", tenant) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return r.status, json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read() or b"{}") + except Exception: + return e.code, {} + + +async def main(): + from playwright.async_api import async_playwright + + errors = [] + # --- 后端预检: 登录拿 token --- + import urllib.error # noqa + s, login = api("POST", "/api/v1/auth/token/", + body={"username": "alice", "password": "alice12345"}) + assert s == 200, f"login failed: {s} {login}" + token = login["access"] + s, custs = api("GET", "/api/v1/partner/customers/?page_size=200", + token=token) + assert s == 200 and custs["count"] >= 1, f"customers: {s}" + s, whs = api("GET", "/api/v1/inventory/warehouses/?page_size=200", + token=token) + assert s == 200 and whs["count"] >= 1, f"warehouses: {s}" + s, prods = api("GET", "/api/v1/catalog/products/?page_size=200", + token=token) + assert s == 200 and prods["count"] >= 1, f"products: {s}" + customer = custs["results"][0] + warehouse = whs["results"][0] + product = prods["results"][0] + print(f"[pre] customer={customer['code']} warehouse={warehouse['code']} " + f"product={product['code']} token=ok") + + async with async_playwright() as pw: + browser = await pw.chromium.launch() + # Chrome 移动模拟: Pixel 5 视口 + 触摸 + 移动 UA + ctx = await browser.new_context( + viewport={"width": 393, "height": 851}, + has_touch=True, + is_mobile=True, + user_agent=("Mozilla/5.0 (Linux; Android 13; Pixel 5) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0 Mobile Safari/537.36"), + ) + page = await ctx.new_page() + page.on("console", lambda m: errors.append(m.text) + if m.type == "error" else None) + page.on("pageerror", lambda e: errors.append(str(e))) + + await page.goto(PAGE, wait_until="networkidle") + # --- 登录 ---(密码框无 placeholder,用 input 序号定位) + inputs = page.locator(".sm-login input") + await inputs.nth(0).fill("alice") + await inputs.nth(1).fill("alice12345") + await page.get_by_role("button", name="登 录").click() + await page.wait_for_timeout(3000) + body = await page.content() + assert "外勤开单" in body or "开单" in body, "login did not land" + print("[1] login ok (mobile viewport 393x851)") + + # --- 开单: 选客户/仓库 --- + # 移动视口下 el-option 常判定不可见,用 JS 直点可见 option + async def pick(select_idx, opt_idx=0): + sels = page.locator(".el-select") + await sels.nth(select_idx).click() + await page.wait_for_timeout(900) + txt = await page.evaluate("""(idx) => { + const opts = document.querySelectorAll('li[role=option]'); + const vis = [...opts].filter(o => o.offsetParent !== null); + if (!vis.length || idx >= vis.length) return 'NONE total='+opts.length; + vis[idx].click(); + return 'OK:' + vis[idx].textContent.trim().slice(0, 30); + }""", opt_idx) + await page.wait_for_timeout(1200) + return txt + + print("[2] customer:", await pick(0, 0)) + print("[2] warehouse:", await pick(1, 0)) + + # 搜商品并加一行: 取第一行的数量框填 2, 点"加" + await page.wait_for_timeout(2000) + add_btns = page.get_by_role("button", name="加", exact=True) + n_add = await add_btns.count() + assert n_add >= 1, "no product rows rendered" + qty_boxes = page.locator(".sm-item .el-input-number input") + await qty_boxes.first.fill("2") + await add_btns.first.click() + await page.wait_for_timeout(800) + order_title = await page.locator(".sm-order-title").count() + assert order_title == 1, "order section not shown after add" + print("[3] add-to-order ok (qty=2)") + + # 抹零选抹角 + 保存并过账 + await page.locator(".sm-totals .el-select").click() + await page.wait_for_timeout(700) + print("[4] round_to:", + await page.evaluate("""() => { + const opts = document.querySelectorAll('li[role=option]'); + const vis = [...opts].filter(o => o.offsetParent !== null); + if (vis.length < 3) return 'NONE total='+opts.length; + vis[2].click(); + return 'OK:' + vis[2].textContent.trim().slice(0, 20); + }""")) + await page.wait_for_timeout(500) + await page.get_by_role("button", name="保存并过账").click() + await page.wait_for_timeout(4000) + print("[4] submit+confirm clicked") + + # --- 欠款 tab ---(tab 项是 div#tab-xxx,直接点) + await page.locator("#tab-debt").click() + await page.wait_for_timeout(2500) + debt_body = await page.locator("#pane-debt").text_content() + print(f"[5] debt tab rendered: {(debt_body or '').strip()[:100]}") + assert "未结" in (debt_body or "") or "暂无欠款" in (debt_body or ""), \ + f"debt pane unexpected: {(debt_body or '')[:100]}" + + # --- 收款 tab: 填单登记 --- + await page.locator("#tab-receipt").click() + await page.wait_for_timeout(1000) + # 收款面板内第一个 select = 客户 + cust_sel = page.locator("#pane-receipt .el-select").first + await cust_sel.scroll_into_view_if_needed() + await cust_sel.click() + await page.wait_for_timeout(900) + print("[6] receipt customer:", + await page.evaluate("""() => { + const opts = document.querySelectorAll('li[role=option]'); + const vis = [...opts].filter(o => o.offsetParent !== null); + if (!vis.length) return 'NONE'; + vis[0].click(); + return 'OK:' + vis[0].textContent.trim().slice(0, 30); + }""")) + await page.wait_for_timeout(500) + # 关掉可能残留的下拉浮层后再填单号(Esc 关闭 popper) + await page.keyboard.press("Escape") + await page.wait_for_timeout(400) + bill_no = "SMH50002" + await page.locator("#pane-receipt input[placeholder]").nth(0).scroll_into_view_if_needed() + await page.locator("#pane-receipt input[placeholder]").nth(0).fill(bill_no) + amt_input = page.locator("#pane-receipt .el-input-number input") + await amt_input.fill("50") + await page.get_by_role("button", name="登记收款").click() + await page.wait_for_timeout(3000) + print("[6] receipt submit clicked") + + errs = [e for e in errors if "favicon" not in e.lower()] + print(f"[7] console errors: {len(errs)}") + for e in errs[:10]: + print(" ERR:", e[:200]) + await browser.close() + + # --- 后端断言落库 --- + s, bills = api("GET", "/api/v1/sales/bills/?page_size=5", token=token) + assert s == 200, f"bills: {s}" + latest = bills["results"][0] if bills["results"] else None + print(f"[8] latest bill: {latest['bill_no'] if latest else None} " + f"state={latest['state'] if latest else None} " + f"total={latest['total_amount'] if latest else None}") + assert latest and latest["state"] == "confirmed", \ + f"expected confirmed bill, got {latest}" + + s, found = api("GET", "/api/v1/finance/receipts/?page_size=50&search=SMH50002", + token=token) + assert s == 200, f"receipts: {s}" + hit = [r for r in found["results"] if r["bill_no"] == "SMH50002"] + assert hit, f"receipt SMH50002 not found: {[r['bill_no'] for r in found['results'][:5]]}" + print(f"[9] receipt SMH50002 ok amount={hit[0]['amount']}") + + s, debts = api("GET", "/api/v1/finance/receivables/?page_size=50", + token=token) + assert s == 200 + open_debts = [d for d in debts["results"] + if d["status"] in ("open", "partial")] + assert open_debts, "expected open receivables" + print(f"[10] open receivables: {len(open_debts)}, " + f"first balance={open_debts[0]['balance']}") + + real_errors = [e for e in errors if "favicon" not in e.lower()] + assert not real_errors, f"console errors: {real_errors[:5]}" + print("ALL SALESMAN-H5 CHECKS PASSED") + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()) or 0) diff --git a/docs/DEPLOY_CHECKLIST.md b/docs/DEPLOY_CHECKLIST.md new file mode 100644 index 0000000..1cb28f8 --- /dev/null +++ b/docs/DEPLOY_CHECKLIST.md @@ -0,0 +1,55 @@ +# dealerhub · 生产部署 Readiness Gate 清单(P2-2) + +> 状态:**本地校验通过,未实际部署**。唯一会碰生产动作的项(镜像构建/上线 +> 192.168.5.7)因无服务器凭证,停在"清单 + 本地校验",不伪造部署成功。 +> 验证环境:Windows + Docker 29.7.2 + Python 3.12.10,2026-09-12 实跑。 + +## 一、本地校验(已跑通,exit 0) + +| # | 项 | 命令 | 结果 | +|---|---|---|---| +| 1 | compose 插值 | `DJANGO_SECRET_KEY='x' POSTGRES_PASSWORD='x' docker compose -f backend/infra/docker/docker-compose.yml config` | ✅ exit 0,`DATABASE_URL`/`SECRET_KEY`/`POSTGRES_PASSWORD` 均已插值,无 `required` 残留告警 | +| 2 | prod check | `DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=dummy DJANGO_ALLOWED_HOSTS=example.com DATABASE_URL=postgres://u:p@localhost:5432/db python manage.py check` | ✅ `System check identified no issues (0 silenced)` | +| 3 | collectstatic | 同上环境 `python manage.py collectstatic --noinput`(实跑非 dry-run,写 `backend/staticfiles/`) | ✅ `216 static files copied` | +| 4 | ALLOWED_HOSTS 空值门 | 不传 `DJANGO_ALLOWED_HOSTS`(仅继承 dev `.env` 的 `*`)→ `manage.py check` | ✅ 被拒:`生产环境不允许 DJANGO_ALLOWED_HOSTS 含通配符 '*'`(本次新增,见 §三) | +| 5 | ALLOWED_HOSTS 显式域名 | `DJANGO_ALLOWED_HOSTS=example.com` → `manage.py check` | ✅ 0 issues | +| 6 | prod 安全基线 | 同环境打印 settings | ✅ `DEBUG=False`;`dealerhub` logger `INFO`;PG `pool min2/max10/timeout10`;HSTS `2592000+include+preload`;`SESSION/CSRF_COOKIE_SECURE=True`;`SECURE_PROXY_SSL_HEADER=https`;`X_FRAME_OPTIONS=DENY` | +| 7 | DB 方言 | prod `ENGINE=django.db.backends.postgresql` | ✅ 非 SQLite(SQLite 不适合生产并发,见 NEXT_PLAN P1-1) | + +## 二、上线 192.168.5.7 前的 Gate(逐项打勾才能上) + +- [ ] **环境变量**:目标机上设置 `DJANGO_SECRET_KEY`(≥32 随机)、`POSTGRES_PASSWORD`、 + `DJANGO_ALLOWED_HOSTS=实际域名`(逗号分隔,禁 `*`)、`DATABASE_URL`(PG)。 + 参考:` §一#1` 的插值校验。 +- [ ] **镜像构建**:`docker compose -f backend/infra/docker/docker-compose.yml build backend` + (Node+Python 多阶段,镜像内 `npm ci && npm run build`,不依赖宿主 `dist/`)。 +- [ ] **迁移**:容器启动脚本 `infra/scripts/run_prod_granian.sh` 自动 + `python manage.py migrate --noinput`;上线后用 `manage.py showmigrations` 抽查无未应用迁移。 +- [ ] **collectstatic**:同脚本自动 `collectstatic --noinput`;用 `curl /static/` 抽查 200。 +- [ ] **`DJANGO_DEBUG=False`**:prod 已硬编码 `DEBUG=False`(`config/settings/prod.py`), + 用 §一#6 命令复验。 +- [ ] **PG 已切**:`DATABASE_URL` 指向生产 PG(本机 PG 5433 仅用于并发测试,不可当生产库)。 +- [ ] **HTTPS**:`SECURE_PROXY_SSL_HEADER + HSTS + Secure Cookie` 已配(§一#6), + 前置反代(1Panel/nginx)必须透传 `X-Forwarded-Proto: https` 并配证书;`curl -I https://域名` 验 HSTS 头。 +- [ ] **日志轮转**:应用层只打 console(`StreamHandler`,无文件 handler,见 §四); + 靠宿主 `docker logs --log-opt max-size/max-file` 或 journald 做轮转,**不要**在容器内写日志文件。 +- [ ] **备份**:PG 每日 `pg_dump`(或卷快照)+ 留存 ≥7 天,恢复演练每季度一次。 + 现状:仓库内无备份脚本(§四),上线前由运维侧补齐,本清单不虚构。 +- [ ] **冒烟**:`GET /api/v1/ping/` → 登录 → 开单,三接口 200(D2 移动端同链路已在本地验证,见 `backend/scripts/verify_salesman_h5.py`)。 + +## 三、本轮修补(P2-2 附带真缺口) + +`prod.py` 的 ALLOWED_HOSTS 空值门可被 dev `.env`(`DJANGO_ALLOWED_HOSTS=*`, +经 `base.py:read_env` 写进 `os.environ`)绕过:不传变量时读到 `*` 而非空, +门禁静默通过,生产接受任意 Host。修法:显式拒绝通配符(`backend/config/settings/prod.py`), +验证见 §一#4/#5。无测试依赖 prod 通配符(全仓 grep `settings.prod` 零命中)。 + +## 四、诚实记录(未做,不虚报) + +1. **镜像构建未跑**:需 Docker daemon 完整构建上下文(含 `npm ci` 拉包), + 本机 `docker compose config` 仅做插值校验(§一#1),`build/up` 留待目标机。 +2. **未上线 192.168.5.7**:无服务器凭证,`up -d` 与三接口冒烟留待部署时按 §二执行。 +3. **日志轮转/备份无代码交付**:现状应用层无文件日志、无备份脚本, + §二对应项为运维动作,仓库不伪造实现。 +4. **P3-1 行业专版跳过**:按 NEXT_PLAN P3-1/D3 定调"按客户驱动,不提前做", + 本轮未建 `Serial`/辅助属性/套装拆件,grep 仍零命中即为预期状态。 diff --git a/frontend/src/pages/Salesman.vue b/frontend/src/pages/Salesman.vue new file mode 100644 index 0000000..3f520d4 --- /dev/null +++ b/frontend/src/pages/Salesman.vue @@ -0,0 +1,630 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ee70f7d..65f0c69 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -5,6 +5,7 @@ const routes = [ { path: '/login', name: 'login', component: () => import('../pages/Login.vue') }, { path: '/register', name: 'register', component: () => import('../pages/Register.vue') }, { path: '/storefront', name: 'storefront', component: () => import('../pages/Storefront.vue'), meta: { title: '订货商城' } }, + { path: '/salesman', name: 'salesman', component: () => import('../pages/Salesman.vue'), meta: { title: '业务员开单' } }, { path: '/storefront-admin', name: 'storefrontAdmin', component: () => import('../pages/StorefrontAdmin.vue'), meta: { title: '商城订单' } }, { path: '/', @@ -40,7 +41,7 @@ const routes = [ const router = createRouter({ history: createWebHashHistory(), routes }) -const PUBLIC_ROUTES = new Set(['login', 'register', 'storefront']) +const PUBLIC_ROUTES = new Set(['login', 'register', 'storefront', 'salesman']) router.beforeEach((to) => { const token = localStorage.getItem('token')