diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md
index c44bc29..cceec92 100644
--- a/.project-docs/20-architecture/data-flow.md
+++ b/.project-docs/20-architecture/data-flow.md
@@ -6,6 +6,7 @@
2. `POST /api/login` accepts strict same-origin JSON, verifies runtime-owned credentials under a bounded per-client attempt ledger and issues a random server-side session plus CSRF token in an `HttpOnly`, `SameSite=Strict` cookie.
3. Every portal page, detailed health response, read API, upload/mutation, task trace and artifact download validates the authenticated session. Mutations additionally validate the session CSRF token.
4. `POST /api/logout` validates CSRF, revokes the server-side session and expires the cookie. `/healthz` remains a no-detail anonymous readiness signal for container orchestration.
+5. `GET /api/history-months` is an authenticated read-only discovery path. It combines daily processing/monthly publication counts from PostgreSQL with durable company-job counts; each desktop history then reads its selected month through the existing paged list endpoint.
## Primary XML Flow
diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md
index bc3bdd8..d95cdf2 100644
--- a/.project-docs/20-architecture/module-map.md
+++ b/.project-docs/20-architecture/module-map.md
@@ -15,10 +15,10 @@
| `arr_storage/aliyun_oss_v2.py` | Encrypted/unversioned OSS adapter | Writes all objects private |
| `arr_web/downloads.py` | OSS daily + controlled local report download routing | Rechecks metadata, size and SHA-256 |
| `arr_web/job_trace.py` | Programmatic persisted-fact trace | No external trace store |
-| `arr_web/app.py`, `arr_web/repository.py`, `arr_web/company_jobs.py` | Authenticated portal routes and paged history reads | Default-deny login gate protects pages/APIs/downloads; daily/monthly counts and rows share a repeatable-read snapshot; company totals/slices share one lock |
+| `arr_web/app.py`, `arr_web/repository.py`, `arr_web/company_jobs.py` | Authenticated portal routes, paged history reads and read-only history-month discovery | Default-deny login gate protects pages/APIs/downloads; daily/monthly counts and rows share a repeatable-read snapshot; `/api/history-months` merges daily/monthly database counts with company job-state counts; company totals/slices share one lock |
| `arr_web/server.py` | Standard-library HTTP transport | Dispatches GET/POST/PATCH/DELETE with one bounded body reader; real socket tests cover review update/delete and missing/oversized lengths |
| `arr_web/static/login.html`, `login.css`, `login.js` | Responsive ARR login gateway | Labeled form, password visibility, generic inline failures, safe desktop/H5 return target and reduced-motion support |
-| `arr_web/static/app.js`, `arr_web/static/h5.js` | Authenticated client state, rendering and polling | Desktop includes 50-row Booking draft review/edit, the draft's validated uploaded filename below the review title, individual/all-visible selection, count-aware in-page delete confirmation and activation; company generation keeps the fixed five-company context beside the page title, a four-card upload/period setup row, compact upper-right month control, cumulative CO display labels on the three period cards, short centered period actions and an in-page generation confirmation dialog; session-expiry redirect and CSRF logout remain shared; monthly versions auto-refresh every four seconds |
+| `arr_web/static/app.js`, `arr_web/static/h5.js` | Authenticated client state, rendering and polling | Desktop daily/monthly/company histories own independent viewing-month state and default to the latest non-empty month; company generation month remains separate. Desktop also includes 50-row Booking draft review/edit, the draft's validated uploaded filename below the review title, individual/all-visible selection, count-aware in-page delete confirmation and activation; company generation keeps the fixed five-company context beside the page title, a four-card upload/period setup row, cumulative CO display labels, short centered period actions and an in-page generation confirmation dialog; session-expiry redirect and CSRF logout remain shared; monthly versions auto-refresh the selected viewing month every four seconds |
| `monthly_reports/worker.py` | Dedicated outbox consumer | Lease/reclaim, retry/dead-letter, success acknowledgement after activation |
| `monthly_reports/repository.py` | Monthly snapshot and publication repository | Derives scope from `ARRIVAL`; persists metadata/lineage/artifact identities |
| `monthly_reports/xlsx/build_workbook.mjs` | Monthly XLSX builder and reopen validator | Exact row-relative `TOTAL PRICE` formulas only in column S |
@@ -34,4 +34,4 @@ by the active ARR2.0 entrypoint.
## Last Updated
-2026-07-31
+2026-08-02
diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md
index 15253a5..8dc5300 100644
--- a/.project-docs/30-worklog/current-state.md
+++ b/.project-docs/30-worklog/current-state.md
@@ -12,8 +12,34 @@ or historical C/O period can be generated from the current committed Finance sna
blocked. A not-yet-ended period keeps its fixed C/O cutoff and must be rerun after later source facts arrive if the final
workbook needs those facts.
+## Completed On 2026-08-02
+
+- Restored cross-month history visibility for Daily Report, monthly processing and company-channel generation history.
+ Each list now has an independent previous/month/next/`本月` navigator and defaults to its latest non-empty month using
+ authenticated read-only `GET /api/history-months`. Counts and empty states name the active month and can jump to the
+ latest month with records. Company `生成月份` is now visibly labeled and independent from history `查看月份`; monthly
+ polling follows the viewing month without changing generation contracts. A guarded live read returns July counts of
+ 30 daily and four monthly records, while company state retains four July jobs. The full 356-test suite passes with 10
+ skips, and isolated Chinese/English/Thai browser QA at 1440/900/390 pixels found no page overflow. Port 8766 was
+ restarted under detached Screen and is `ready`; no upload, report generation, source activation or business-data write
+ occurred.
+
## Completed On 2026-07-31
+- Refined the Channel BI utility surface in preserve mode: removed the visible BI panel heading and ready-state
+ `数据库已连接` copy while retaining the navigation label, browser metadata and semantic status dot. Compressed the
+ month selector to a compact control on desktop/H5, and changed task-log/logout utilities from button chrome to
+ low-emphasis accessible links without changing their behavior. JavaScript syntax plus focused BI, daily visual,
+ task-log and auth tests pass; no API or business data changed.
+- Tightened the Channel BI panel's top rhythm after the heading removal: the desktop panel now starts 20px higher
+ relative to the shared 38px page padding, while the responsive desktop shell keeps a 10px mobile-only adjustment.
+ The four-tab bar, panel content and BI data behavior are unchanged; focused Web regression remains green.
+- Refined the Channel BI presentation and data-range contract. Removed the BI-only CHANNEL PERFORMANCE, snapshot
+ watermark copy and visible TOTAL PRICE labels on desktop and H5, and added a localized 数据范围 line below the
+ company-sales title. The read-only analytics payload now supplies the minimum and maximum ARRIVAL coverage dates
+ from one repeatable-read current-version snapshot; no Finance/report business data changed. JavaScript syntax and
+ focused analytics/Web/UI tests pass.
+- Implemented the ARR Web Chinese/English/Thai presentation layer. Desktop, H5 and login now share `arr_web/static/i18n.js` with locale selectors, localStorage/cookie preference, `html.lang`, dynamic prompt/API-error translation, Bangkok/Gregorian/Latin-digit date/number formatting and locale-aware duration/money display. The runtime preserves original source strings so switching back to Chinese is safe. `ARR Report`, operational proper nouns, business/API behavior and XLSX/monthly/company report output contracts remain unchanged. Scoped Web regression passed 61/61; full discovery still has only the pre-existing environment errors for missing `httpx`/unavailable OSS client.
- Rotated both ARR Web operator credential values in separate macOS Keychain items without recording either value in
source, launcher arguments, logs or project memory. The exact port-8766 listener was stopped by controlled SIGINT and replaced at
15:30 +08 under one detached `arr2-web-8766` Screen session. Live acceptance rejects the prior pair, accepts the new
@@ -36,8 +62,9 @@ workbook needs those facts.
API version metadata remains intact. JavaScript/HTML checks, 28 focused Web/company tests and targeted deduplication
execution pass.
- Rebuilt the company-channel report setup as a dense four-card desktop row: `上传 Excel 报表` followed by the first,
- second and third C/O periods. The `报表月份` picker is a compact visually unlabeled control in the large setup
- card's upper-right corner, and all period CTAs now read `生成` with responsive card-sized widths. Replaced the
+ second and third C/O periods. At that point the `报表月份` picker was a compact visually unlabeled control in the large
+ setup card's upper-right corner; the 2026-08-02 history repair later labeled it `生成月份` and moved history filtering
+ to its own control. All period CTAs read `生成` with responsive card-sized widths. Replaced the
browser-native generation confirm with an in-page accessible dialog showing month/period context; cancel, backdrop
and Escape dismiss without submitting, while explicit confirmation preserves the existing payload, generation polling
and business behavior. JavaScript/HTML checks, 28 focused Web/company tests, 348-test discovery and isolated
@@ -45,8 +72,9 @@ workbook needs those facts.
14:36:45 +08 and its `/healthz` endpoint returns `ready`.
- Rebalanced the company-channel setup surface for the current Finance workflow. The fixed five-company scope now sits
beside `公司渠道明细` as pale helper text, the Excel upload action is bounded to a 760px desktop rail and remains
- full-width/single-column on mobile, and `刷新任务` exposes a read-only scope tooltip. `报表月份` remains the C/O
- report-month selector because it drives period completeness state, history filtering and the generation payload; no report,
+ full-width/single-column on mobile, and `刷新任务` exposes a read-only scope tooltip. The generation month remains the
+ C/O report-month selector for period completeness and the generation payload; its former history-filter role was
+ removed by the 2026-08-02 cross-month history repair. No report,
upload or database behavior changed. JavaScript/HTML checks, 28 focused Web/company tests, 348-test discovery and
isolated 1280x720/390x844 browser QA pass with no overflow or console warnings. Port 8766 was not mutated by QA.
- Compressed the daily overview row to a compact equal-height desktop treatment. The upload card now uses a horizontal
@@ -189,9 +217,9 @@ workbook needs those facts.
four-second cadence. The current API now lists V04 active above those versions; ARR2 Web remains green and the worker
remains separate.
- ARR2 Web is active on all local interfaces at port 8766 under detached Screen session `arr2-web-8766`. The current
- trusted-Wi-Fi entry is `http://192.168.3.48:8766/`; anonymous root redirects to login and `/login` plus `/healthz`
- return HTTP 200. The current process restarted at 2026-07-31 15:30:04 +08 after a Keychain-only credential rotation
- and loads the Booking bulk-review
+ trusted-Wi-Fi entry is `http://192.168.3.103:8766/`; anonymous root redirects to login and `/login` plus `/healthz`
+ return HTTP 200. The current process restarted at 2026-07-31 19:27:42 +08 to load the Web i18n static resources
+ after the source implementation; it loads the Booking bulk-review
composition; the public health check returns `ready`.
Authenticated acceptance reports database, processing, monthly reports, downloads, company reports and company-source
upload ready. At the earlier 11:30 acceptance the draft endpoint returned no open draft; this later UI task did not
@@ -235,6 +263,7 @@ workbook needs those facts.
## Verification Status
+- The 2026-07-31 Web i18n implementation passed JavaScript syntax checks, a DOM/runtime locale smoke (Chinese → English → Thai → Chinese), and 61 focused ARR Web tests. A full 298-test discovery was attempted; eight import/runtime errors are environment-only (`httpx` absent and OSS client unavailable), with no i18n-related failure.
- Migration 012 passed transaction-only up/down probes, including a synthetic active publication followed by rollback.
- The 38-test monthly/migration/Web regression set passed, including real XLSX build/reopen checks.
- A clean full-suite rerun with the optional ARR1 compatibility dependency installed passed 291 tests in 108.2 seconds
diff --git a/.project-docs/30-worklog/task-history.md b/.project-docs/30-worklog/task-history.md
index 5c34c10..2e34963 100644
--- a/.project-docs/30-worklog/task-history.md
+++ b/.project-docs/30-worklog/task-history.md
@@ -4,6 +4,12 @@
| Date | Task | Outcome | Docs Updated |
|---|---|---|---|
+| 2026-08-02 | Restore prior-month history across Daily Report, monthly processing and company-channel detail | Added a read-only merged history-month count endpoint and three independent month navigators with latest-non-empty defaults, month-aware empty/pagination states and Chinese/English/Thai responsive copy. Company generation/history months are separate and monthly polling follows only the viewing month. A live read confirms July 30/4/4 records, 356 tests pass, isolated 1440/900/390 QA is overflow-free, and the restarted 8766 runtime is ready without business writes | Current state/history, history-visibility evidence/index, stale item, scoped planning record |
+| 2026-08-02 | Diagnose missing prior-month history and design its display | Proved the data was retained: July has 30 daily jobs, 4 monthly publications and 4 company jobs, while the August-default frontend view is empty. Daily/monthly loaders hard-code the Bangkok current month; company history hides its month filtering inside generation setup. Proposed visible per-history month navigation, recent-record empty states and separation of company generation/history months; no product code, runtime or business data changed | History-visibility evidence/index, stale item, scoped planning record |
+| 2026-07-31 | Tighten Channel BI top spacing | Raised the desktop BI panel by 20px toward the four-tab bar and applied a lighter 10px mobile adjustment. Other panels, tab behavior and BI data are unchanged; JavaScript syntax and 39 focused Web tests pass | Current state/history, Channel BI utility evidence |
+| 2026-07-31 | Quiet the Channel BI header utilities | Removed the visible BI page heading and ready-state `数据库已连接` copy, compressed the desktop/H5 month selector, and restyled task-log/logout as low-emphasis links while preserving click behavior. JavaScript syntax plus focused BI/daily/task-log/auth tests pass; no API or business data changed | Current state/history, Channel BI utility evidence/index |
+| 2026-07-31 | Remove BI labels and add actual data coverage | Removed CHANNEL PERFORMANCE, the BI snapshot watermark and visible BI TOTAL PRICE labels on desktop/H5. Added localized 数据范围 below each company-sales title, backed by additive min_arrival_date plus existing max_arrival_date values from the same read-only analytics snapshot. JavaScript syntax and focused analytics/Web/UI tests pass; no Finance/report business data changed | Current state/history, Channel BI evidence/index, channel-analytics README |
+| 2026-07-31 | Restart ARR Web after Web i18n implementation | Replaced the stale port-8766 process under detached Screen `arr2-web-8766` with the current source launcher at 19:27:42 +08. Loopback and current LAN `192.168.3.103` return the expected anonymous root redirect, `/login` 200, `/healthz` 200 and `/assets/i18n.js` 200; the prior `.48` address is no longer assigned to this workstation. No upload, report generation, download or business-data mutation occurred | Current state/history, i18n implementation evidence/index, stale-items |
| 2026-07-31 | Rotate the ARR Web login credentials | Updated the two existing macOS Keychain-backed runtime values without storing them in source or process arguments, normalized the password item's account metadata and performed one controlled port-8766 restart under detached Screen. The prior pair returns 401, the new pair passes session/cookie/CSRF/logout plus desktop/H5 and all six readiness flags, and loopback/current LAN health return 200. Two read-only snapshots stayed at 30 daily jobs, 4 monthly versions and 4 company jobs; latest business records predate restart, nine focused tests pass and literal/process scans are clean. The requested password still equals the username, so the strength follow-up remains open | Current state/history, login-runtime evidence/index, stale items, commitments, scoped planning record |
| 2026-07-31 | Allow early company-channel report generation and rename period states | Removed the old time-release gate from company-channel generation. Current-month incomplete periods now generate from the current committed Finance snapshot with the existing fixed C/O cutoff; historical months can rerun, while future months remain blocked. Replaced the permission-oriented state copy with `周期未结束` / `周期已结束` and added a confirmation warning that later data requires rerun. `node --check`, Python compilation, HTML parsing, 28 focused tests and the project-venv full discovery of 348 tests with 10 skips pass; no report or business data was generated | Business rules, current state/history, evidence topic/index, scoped planning record |
| 2026-07-31 | Simplify company-channel period card labels | Removed the visible `第一期`/`第二期`/`第三期` copy and changed the three card headlines to `C/O:01-10`, `C/O:11-20`, plus a month-aware final range (`C/O:21-30` or `C/O:21-31`). Release status and opening time remain visible; internal period keys and generation requests are unchanged. JavaScript/HTML checks, 28 focused tests, 348-test discovery and isolated 1280×720/390×844 browser QA pass with no overflow or page-console warnings | Current state/history, module map, company-period evidence/index |
diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md
index 8a5cff1..a851516 100644
--- a/.project-docs/40-domain/business-rules.md
+++ b/.project-docs/40-domain/business-rules.md
@@ -9,6 +9,9 @@
- Daily history and task trace show the validated browser-uploaded XML basename. Canonical internal source name
`source.xml` is not user-facing; historical rows with no recorded basename display an unknown marker rather than a
guessed filename.
+- Daily, monthly-publication and company-generation histories have independent viewing months and default to the most
+ recent month containing that record type. Viewing-month state is read-only: it resets list paging but never changes
+ monthly derivation or a company generation request. Company `生成月份` and history `查看月份` are separate inputs.
- ARR's frozen processor generates complete structured data, which must be independently validated and successfully committed to the database before downstream monthly processing starts.
- The production XML path must not require Agent, MCP, prompts, model-generated JSON, fetch grants, or a public source URL.
- A successful database commit automatically triggers the monthly-report processing program through the business system.
diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md
index 384a832..69a9c60 100644
--- a/.project-docs/50-evidence/evidence-index.md
+++ b/.project-docs/50-evidence/evidence-index.md
@@ -4,6 +4,11 @@ Use this index for searchable, traceable evidence records.
| Date | Topic | Status | Source | Detail |
|---|---|---|---|---|
+| 2026-08-02 | Cross-month history visibility | Implemented and runtime-active | [Evidence topic](topics/2026-08-02-history-month-visibility.md) | Three independent history-month navigators now default to the latest non-empty month through a read-only count endpoint; company generation/history months are separated. July resolves to 30 daily/4 monthly/4 company jobs, 356 tests pass, 1440/900/390 browser QA is overflow-free, port 8766 is ready and no business write occurred. |
+| 2026-07-31 | Channel BI header utilities and compact month selector | Implemented; focused static checks pass | [Evidence topic](topics/2026-07-31-channel-bi-header-utilities.md) | The visible BI panel heading and ready-state 数据库已连接 copy are gone from desktop/H5 while the navigation label, browser metadata and status dot remain. The month selector is compact; task-log/logout use low-emphasis accessible links, and the desktop BI panel now sits 20px closer to the tab bar with a 10px mobile adjustment. No API or business data changed. |
+| 2026-07-31 | Channel BI copy and data coverage | Implemented; focused checks pass | [Evidence topic](topics/2026-07-31-channel-bi-copy-and-data-coverage.md) | Desktop and H5 remove the BI-only CHANNEL PERFORMANCE, snapshot watermark and visible BI TOTAL PRICE labels. The company-sales panels show localized 数据范围 from additive min_arrival_date plus existing max_arrival_date values returned by the same repeatable-read current-version snapshot. |
+| 2026-07-31 | ARR Web 中英泰国际化实现 | Implemented; scoped Web regression passes | [Evidence topic](topics/2026-07-31-web-i18n-implementation.md) | Desktop、H5、登录页共用三语词典/runtime,支持 locale 持久化、动态提示/API 错误/日期数字格式化和切回中文;`ARR Report`、专有名词、业务逻辑及 XLSX 契约保持不变。 |
+| 2026-07-31 | 21–31 same-room-category multi-price warning | Active data-review finding; reports still succeed | [Evidence topic](topics/2026-07-31-multi-price-warning-diagnostic.md) | Job `8aa1fdef…` emitted 13 pricing warnings across 116 Finance records: one Finance room category has multiple prices within an output segment. Booking Room is independent; no Booking-match error was found. |
| 2026-07-31 | Live 21–31 company-job Booking Room diagnostic | No Booking-write defect found | [Evidence topic](topics/2026-07-31-live-company-job-booking-room-diagnostic.md) | Published job `8aa1fdef…` contains 598 rows: 224 Booking Room values and 374 expected blanks. Of 986 Finance facts, 372 have no Group Code and two other output rows carry unmatched codes. Total Booking Price is Finance-derived, not a Booking-price calculation. |
| 2026-07-31 | Company-channel early generation and period completeness state | Implemented; focused and project-venv full checks pass | [Evidence topic](topics/2026-07-31-company-report-early-generation.md) | Current-month incomplete C/O periods can be generated from the current committed Finance snapshot using fixed `as_of_date` boundaries; later facts require rerun, and future months remain blocked. UI states are `周期未结束` / `周期已结束`, with an early-generation confirmation note. No report or business data was generated during verification. |
| 2026-07-31 | Company-channel period card copy and result details | Implemented; focused/full checks, month-boundary and deduplication checks pass | [Evidence topic](topics/2026-07-31-company-period-card-copy.md) | Removed stage labels, made the C/O ranges month-aware, collapsed duplicate review problems for display, and replaced the user-facing version number with generation time. Internal period keys, API version metadata, release rules and generation payloads are unchanged. |
diff --git a/.project-docs/50-evidence/topics/2026-07-31-channel-bi-copy-and-data-coverage.md b/.project-docs/50-evidence/topics/2026-07-31-channel-bi-copy-and-data-coverage.md
new file mode 100644
index 0000000..1cac204
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-channel-bi-copy-and-data-coverage.md
@@ -0,0 +1,35 @@
+# Channel BI Copy and Data Coverage
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented
+- Scope: Desktop Channel BI and H5 BI presentation plus additive analytics range fields
+- Confidence: Fact
+- Source: channel analytics contracts/provider, desktop and H5 static assets, focused tests
+- Last verified: 2026-07-31
+
+## User-Facing Outcome
+
+- The BI-only CHANNEL PERFORMANCE eyebrow is removed from desktop and H5.
+- The BI heading no longer shows the updated-through database snapshot copy.
+- Visible BI TOTAL PRICE labels are removed; the revenue helper now reads 销售金额合计.
+- 各公司本月销售情况 now has a small 数据范围:开始日期 – 结束日期 line immediately below its title on both surfaces.
+
+## Data Contract
+
+- The analytics endpoint continues to read current Finance facts in one repeatable-read, read-only snapshot.
+- The response now adds min_arrival_date; the existing max_arrival_date remains the end of the coverage range.
+- Both dates come from current daily-version business dates, which are validated to equal the included ARRIVAL dates. The
+ browser formats those API values by the active locale and never infers a full calendar month from the browser clock.
+
+## Verification
+
+- Desktop and H5 JavaScript syntax checks passed.
+- Focused analytics/Web/UI tests passed, including the desktop/H5 copy contracts and min/max date API assertions.
+- No Finance facts, monthly publication artifacts or report-generation behavior changed.
+
+## Design Boundary
+
+This is a preserve-mode refinement using the existing neutral/blue visual system. The range is intentionally a quiet
+metadata line under the chart title so it clarifies the partial-month snapshot without competing with the sales ranking.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-channel-bi-header-utilities.md b/.project-docs/50-evidence/topics/2026-07-31-channel-bi-header-utilities.md
new file mode 100644
index 0000000..7163780
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-channel-bi-header-utilities.md
@@ -0,0 +1,26 @@
+# Channel BI header utilities
+
+## Scope
+
+This records the 2026-07-31 visual refinement of the ARR Web Channel BI page. The change is presentation-only and
+does not alter analytics payloads, report generation, authentication endpoints or business data.
+
+## Implemented
+
+- Removed the visible `渠道BI` page heading from the desktop BI panel and H5 hero. The top navigation label and H5
+ document title remain available as context.
+- Removed the ready-state `数据库已连接` wording. The desktop/H5 status indicator keeps its colored dot and an
+ accessible status label, while the unavailable state still exposes its visible warning copy.
+- Reduced the `查看月份` control to a compact 92px/26px selector family on desktop and H5 so it reads as a quiet
+ utility rather than a competing page heading.
+- Raised the desktop BI panel by 20px toward the four-tab bar after removing the page heading; the responsive
+ desktop shell uses a smaller 10px adjustment on narrow widths to retain breathing room.
+- Replaced the desktop task-log and logout button chrome with focusable text links. Their event interception, dialog
+ opening, logout request and busy/error handling remain intact; H5 logout follows the same low-emphasis pattern.
+
+## Verification
+
+- `node --check arr_web/static/app.js`
+- `node --check arr_web/static/h5.js`
+- 21 focused BI, daily visual, task-log and auth tests passed.
+- 18 `tests.test_arr_web` tests passed.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-multi-price-warning-diagnostic.md b/.project-docs/50-evidence/topics/2026-07-31-multi-price-warning-diagnostic.md
new file mode 100644
index 0000000..4d59133
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-multi-price-warning-diagnostic.md
@@ -0,0 +1,29 @@
+# Evidence Topic: 21–31 same-room-category multi-price warning
+
+- Date: 2026-07-31
+- Status: Active data-review finding; no code or database mutation
+- Scope: Successful job `8aa1fdef3b644f3a9cc0f26924445a29`, July `21-month-end`
+- Confidence: Fact
+- Stale trigger: Finance-version change, report rerun, or pricing rule change
+
+## Rule
+
+For each output segment `(normalized Group Code, ARRIVAL, DEPARTURE)`, the processor groups Finance facts by
+`(room_category_label, total_price)`. If one Finance room-category label has more than one distinct `total_price` in
+that segment, it emits `COMPANY_REPORT_MULTI_PRICE_REVIEW` with stage `pricing` and keeps the report successful. It does
+not use Booking Room or Markdown room types for this check, and it does not choose min/max/average on behalf of the user.
+
+## Observed job
+
+- Five companies succeeded; warnings were LianTai 4, QBD 8, DY-AI-Easy-KB 0, FengRun 1 and HanaTour 0.
+- There were 13 warning groups covering 116 Finance record IDs.
+- Representative LianTai case: Group Code `LT260718KB`, stay `2026-07-20 → 2026-07-22`; `RM3` appears at 3,600 for
+ nine records and 6,800 for one record. The output therefore keeps both terms, e.g. `【RM3】3,600×9 + 【RM3】6,800×1`.
+- The Booking lookup for that same Group Code remains independent (`【DBL】12` in the current Markdown source).
+
+## Interpretation
+
+The warning means Finance/Opera contains a price inconsistency or a legitimate pricing split for the same Finance room
+category within one output segment. It is a human-review signal, not a Booking matching failure and not a report error.
+To resolve it, inspect the underlying Finance/Opera pricing rows and decide whether the differing prices are expected. If
+the source is wrong, correct the Finance input and rerun; if expected, retain the warning as an audit note.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-web-i18n-implementation.md b/.project-docs/50-evidence/topics/2026-07-31-web-i18n-implementation.md
new file mode 100644
index 0000000..7953710
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-web-i18n-implementation.md
@@ -0,0 +1,26 @@
+# ARR Web 中英泰国际化实现
+
+## 结论
+
+2026-07-31 用户确认执行后,ARR Web 展示层完成 `zh-CN`、`en`、`th` 三语切换基础实现。桌面 `/`、H5 `/h5` 和登录 `/login` 共用一个前端词典/runtime;默认中文,语言选择保存到 localStorage 与同源 cookie,并同步 `html.lang`。顶部品牌 `ARR Report`、XML/Excel/Booking/Finance/BI/C/O、字段名、公司/渠道/房型和报告文件/worksheet 等专有名词保持原样或仅翻译其周边说明。
+
+## 实施范围
+
+- 新增 `arr_web/static/i18n.js`:语义词典、稳定 API 错误码映射、动态 DOM/属性翻译、分页/任务文本模式、日期/整数/CNY/耗时格式化;泰文格式强制 Gregorian calendar 与 Latin digits,业务时区仍为 `Asia/Bangkok`。
+- `arr_web/static/index.html`、`h5.html`、`login.html` 增加语言选择器和 runtime;`app.js`、`h5.js`、`login.js` 接入状态、Toast、确认/空态、错误、任务日志、无障碍属性及 locale-change 重绘。
+- `styles.css`、`h5.css`、`login.css` 增加泰文字体回退和选择器样式;`arr_web/app.py` 将 i18n runtime 加入静态路由及匿名登录资源白名单。
+
+## 边界与不变量
+
+- 未触发上传、月报/公司报表生成、Booking 草稿删除/激活、下载或数据库写入;未修改认证、CSRF、轮询和业务状态判断。
+- 业务状态值、API 结构和报表 Excel 文件名、sheet、表头、公式契约保持不变;语言只作用于用户展示层。API 错误继续通过 code 识别,前端按 locale 显示人类可见文案。
+- 语言切换不把已翻译文本当作源文本;runtime 保存原始文案,因此可以从英文/泰文切回中文。
+
+## 验证
+
+- `node --check arr_web/static/i18n.js arr_web/static/app.js arr_web/static/h5.js arr_web/static/login.js`:通过。
+- Node DOM/runtime smoke:中文 → English → ไทย → 中文恢复正常;`2026-07` 月份文案和 Thai/English locale 格式化通过;未知 API 错误在英文/泰文下回退到对应语言通用提示。
+- `python3 -m unittest discover -s tests -p 'test_arr_web*.py'`:61/61 通过。
+- 当前源码临时预览的 390px 登录页验证中文、English、ไทย切换;`ARR Report` 保持不变,泰文长文案无横向溢出。预览进程已关闭,未触碰共享 8766 服务或业务数据。
+- 19:27:42 +08 按现有 Screen/Keychain launcher 重启共享 8766;旧进程因未重启仍重定向 `/assets/i18n.js`,重启后 loopback 与当前 LAN `192.168.3.103` 的 `/login`、`/healthz` 和 `/assets/i18n.js` 分别返回 200,匿名根路径返回登录重定向。原 `192.168.3.48` 已不在本机网卡上,未执行业务写操作。
+- 完整 `python3 -m unittest discover -s tests`:298 个测试中 8 个环境错误,均为既有测试环境缺少 `httpx` 或 OSS client 映射为 unavailable;未出现与本次 Web 国际化改动相关的失败。
diff --git a/.project-docs/50-evidence/topics/2026-08-02-history-month-visibility.md b/.project-docs/50-evidence/topics/2026-08-02-history-month-visibility.md
new file mode 100644
index 0000000..70ffabb
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-08-02-history-month-visibility.md
@@ -0,0 +1,64 @@
+# Cross-month history visibility
+
+## Metadata
+
+- Date: 2026-08-02
+- Status: Implemented and runtime-active
+- Scope: desktop daily history, monthly publications and company-channel job history
+- Confidence: Verified implementation and runtime evidence
+- Source: frontend/backend inspection, controlled read-only PostgreSQL queries, automated tests and isolated browser QA
+- Business-data mutation: none
+
+## Reported symptom
+
+After the Bangkok business month advanced from July to August, users could no longer see the prior month's daily,
+monthly or company-channel records from the default page state.
+
+## Root-cause evidence
+
+- `arr_web/static/app.js` makes both `loadJobs()` and `loadMonthly()` query `localMonth()` directly. Their history
+ panels expose pagination but no visible month control.
+- The company page initializes `#company-report-month` to `localMonth()` and uses the same input for C/O generation
+ state/payload and history filtering. Its visible label is screen-reader-only and the control sits in the generation
+ setup panel, so its history-filter role is easy to miss.
+- All three backend list contracts already accept a month. Daily rows are filtered by `business_date` with a
+ `created_at` fallback, monthly publications by `period_start`, and company jobs by `report_month`.
+- A controlled repeatable-read query on 2026-08-02 found 30 daily jobs and four monthly publications for 2026-07,
+ versus zero and zero for 2026-08.
+- Local company job state retains one 2026-06 job and four 2026-07 jobs; July contains three succeeded and one failed
+ job across all three C/O periods. June/July company workbooks and July monthly archives also remain on disk.
+
+## Finding
+
+This is a history-navigation/display-state defect, not evidence of data deletion. Entering a new month makes the
+default frontend requests target an empty current month and provides no discoverable daily/monthly path back; the
+company path exists but conflates generation scope with history scope.
+
+## Implemented repair
+
+- Added authenticated read-only `GET /api/history-months`. It merges daily/monthly counts from the guarded PostgreSQL
+ repository with company job-state counts and introduces no table, migration or report-data change.
+- Added consistent previous/month/next/`本月` controls to Daily Report, monthly processing and company generation
+ history. Daily and monthly default independently to their latest non-empty month.
+- Pagination and empty states name the selected month. Empty months expose a direct link to the latest month with that
+ record type.
+- Split company `生成月份` from history `查看月份`; changing either one no longer changes the other. A newly submitted
+ company job moves only the history view to the submitted month.
+- Monthly polling continues to read the selected viewing month. It is presentation state only and is never added to
+ the report-generation contract.
+- Added Chinese, English and Thai copy and responsive wrapping/focus treatment.
+
+## Acceptance results
+
+- A direct guarded read-only repository call returns `2026-07` with 30 daily and four monthly records. Durable company
+ job state contributes four July and one June jobs; all three July histories therefore become the initial view while
+ the Bangkok current month is empty.
+- `node --check`, Python compilation and `git diff --check` pass. The complete project discovery passes 356 tests with
+ 10 environment-conditional skips.
+- Isolated authenticated browser QA at 1440, 900 and 390 pixels found no document-level horizontal overflow. Tables
+ retain their intentional internal scroll on narrow screens.
+- Chinese, English and Thai mobile controls remain within the 390-pixel viewport. Company history was changed from
+ July to empty August, exposed `查看 2026年7月`, and returned to four July rows while `生成月份` remained August.
+- The production Web process on port 8766 was restarted under detached Screen after verification; `/healthz` returned
+ `ready`. The isolated QA service was stopped. No upload, source activation, report generation or business-data write
+ was performed.
diff --git a/arr_web/app.py b/arr_web/app.py
index 2699cca..8a614a0 100644
--- a/arr_web/app.py
+++ b/arr_web/app.py
@@ -58,6 +58,7 @@ STATIC_ROUTES = {
"/h5": ("h5.html", "text/html; charset=utf-8"),
"/h5.html": ("h5.html", "text/html; charset=utf-8"),
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
+ "/assets/i18n.js": ("i18n.js", "text/javascript; charset=utf-8"),
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/assets/h5.css": ("h5.css", "text/css; charset=utf-8"),
"/assets/h5.js": ("h5.js", "text/javascript; charset=utf-8"),
@@ -70,6 +71,7 @@ LOGIN_STATIC_ROUTES = {
"/login",
"/login.html",
"/assets/login.css",
+ "/assets/i18n.js",
"/assets/login.js",
}
LOGIN_DOCUMENT_ROUTES = {"/login", "/login.html"}
@@ -345,6 +347,34 @@ class PortalApplication:
offset=offset,
),
)
+ if method == "GET" and route.path == "/api/history-months":
+ combined: Dict[str, Dict[str, Any]] = {}
+ for item in self._repository.list_history_month_counts():
+ month_key = str(item.get("month_key", ""))
+ validate_month(month_key)
+ combined[month_key] = {
+ "month_key": month_key,
+ "daily_count": int(item.get("daily_count", 0) or 0),
+ "monthly_count": int(item.get("monthly_count", 0) or 0),
+ "company_count": 0,
+ }
+ for item in self._company_reports.list_month_counts():
+ month_key = str(item.get("month_key", ""))
+ validate_month(month_key)
+ row = combined.setdefault(
+ month_key,
+ {
+ "month_key": month_key,
+ "daily_count": 0,
+ "monthly_count": 0,
+ "company_count": 0,
+ },
+ )
+ row["company_count"] = int(item.get("company_count", 0) or 0)
+ return Response.json(
+ 200,
+ success([combined[key] for key in sorted(combined, reverse=True)]),
+ )
if method == "GET" and route.path == "/api/months":
return Response.json(200, success(self._repository.list_months()))
if method == "GET" and route.path == "/api/h5/months":
diff --git a/arr_web/company_jobs.py b/arr_web/company_jobs.py
index d97e6a5..5dd4760 100644
--- a/arr_web/company_jobs.py
+++ b/arr_web/company_jobs.py
@@ -133,6 +133,9 @@ class CompanyReportCoordinator(Protocol):
) -> tuple[List[Dict[str, Any]], int]:
...
+ def list_month_counts(self) -> List[Dict[str, Any]]:
+ ...
+
def get_job(self, job_id: str) -> Dict[str, Any]:
...
@@ -160,6 +163,9 @@ class UnavailableCompanyReportCoordinator:
) -> tuple[List[Dict[str, Any]], int]:
raise self._unavailable()
+ def list_month_counts(self) -> List[Dict[str, Any]]:
+ return []
+
def get_job(self, job_id: str) -> Dict[str, Any]:
raise self._unavailable()
@@ -292,6 +298,23 @@ class PersistentCompanyReportCoordinator:
page = states[offset : offset + limit]
return [self._public_locked(state) for state in page], total
+ def list_month_counts(self) -> List[Dict[str, Any]]:
+ with self._condition:
+ counts: Dict[str, int] = {}
+ for state in self._states_locked():
+ month_key = state.get("report_month")
+ if not isinstance(month_key, str):
+ continue
+ try:
+ validate_month(month_key)
+ except PortalError:
+ continue
+ counts[month_key] = counts.get(month_key, 0) + 1
+ return [
+ {"month_key": month_key, "company_count": counts[month_key]}
+ for month_key in sorted(counts, reverse=True)
+ ]
+
def get_job(self, job_id: str) -> Dict[str, Any]:
with self._condition:
return self._public_locked(self._read_locked(job_id))
diff --git a/arr_web/repository.py b/arr_web/repository.py
index 3c2cf53..fcd1ab3 100644
--- a/arr_web/repository.py
+++ b/arr_web/repository.py
@@ -46,6 +46,9 @@ class PortalRepository(Protocol):
) -> tuple[List[Dict[str, Any]], int]:
...
+ def list_history_month_counts(self) -> List[Dict[str, Any]]:
+ ...
+
def list_months(self) -> List[Dict[str, Any]]:
...
@@ -94,6 +97,9 @@ class UnavailablePortalRepository:
) -> tuple[List[Dict[str, Any]], int]:
self._raise()
+ def list_history_month_counts(self) -> List[Dict[str, Any]]:
+ self._raise()
+
def list_months(self) -> List[Dict[str, Any]]:
self._raise()
@@ -374,6 +380,37 @@ WHERE run.period_start = %s
""".strip()
+HISTORY_MONTH_COUNTS_SQL = """
+WITH daily AS (
+ SELECT
+ CASE
+ WHEN run.business_date IS NOT NULL
+ THEN date_trunc('month', run.business_date)::date
+ ELSE date_trunc(
+ 'month',
+ run.created_at AT TIME ZONE 'Asia/Bangkok'
+ )::date
+ END AS period_start,
+ count(*) AS record_count
+ FROM ingestion.processing_runs AS run
+ WHERE run.pipeline_type = 'opera_daily'
+ GROUP BY 1
+),
+monthly AS (
+ SELECT run.period_start, count(*) AS record_count
+ FROM reporting.monthly_runs AS run
+ GROUP BY run.period_start
+)
+SELECT
+ coalesce(daily.period_start, monthly.period_start) AS period_start,
+ coalesce(daily.record_count, 0) AS daily_count,
+ coalesce(monthly.record_count, 0) AS monthly_count
+FROM daily
+FULL OUTER JOIN monthly USING (period_start)
+ORDER BY period_start DESC
+""".strip()
+
+
DAILY_DOWNLOAD_SQL = """
SELECT
artifact.artifact_kind,
@@ -708,6 +745,32 @@ class PostgresPortalRepository:
finally:
connection.close()
+ def list_history_month_counts(self) -> List[Dict[str, Any]]:
+ connection = self._open()
+ try:
+ with connection.transaction():
+ with connection.cursor() as cursor:
+ self._begin(cursor)
+ cursor.execute(HISTORY_MONTH_COUNTS_SQL)
+ rows = list(cursor.fetchall())
+ return [
+ {
+ "month_key": row[0].strftime("%Y-%m"),
+ "daily_count": int(row[1] or 0),
+ "monthly_count": int(row[2] or 0),
+ }
+ for row in rows
+ ]
+ except PortalDataError:
+ raise
+ except Exception:
+ raise PortalDataError(
+ "DATABASE_QUERY_FAILED",
+ "历史月份查询失败",
+ ) from None
+ finally:
+ connection.close()
+
def list_months(self) -> List[Dict[str, Any]]:
try:
return self._analytics.list_months()
diff --git a/arr_web/static/app.js b/arr_web/static/app.js
index 2fa52b6..7d0a4bd 100644
--- a/arr_web/static/app.js
+++ b/arr_web/static/app.js
@@ -29,12 +29,16 @@
jobsLoading: false,
jobsTotal: 0,
jobsOffset: 0,
+ jobsMonth: "",
monthlyRuns: [],
monthlyLoaded: false,
monthlyLoading: false,
monthlyPollTimer: null,
monthlyTotal: 0,
monthlyOffset: 0,
+ monthlyMonth: "",
+ historyMonths: [],
+ historyMonthsLoaded: false,
companyReportJobs: [],
companyReportCurrentJob: null,
companyReportPollTimer: null,
@@ -42,11 +46,13 @@
companyHistoryLoading: false,
companyReportsTotal: 0,
companyReportsOffset: 0,
+ companyHistoryMonth: "",
companyReportsReady: false,
companySourceUploadReady: false,
};
const BUSINESS_TIME_ZONE = "Asia/Bangkok";
+ const I18N = window.ARRI18n;
const TRACE_POLL_INTERVAL = 4000;
const MONTHLY_POLL_INTERVAL = 4000;
const HISTORY_PAGE_SIZE = 50;
@@ -54,32 +60,32 @@
const COMPANY_REPORT_NAMES = ["LianTai", "QBD", "DY-AI-Easy-KB", "FengRun", "HanaTour"];
const COMPANY_REPORT_PERIODS = ["01-10", "11-20", "21-month-end"];
const DAILY_UPLOAD_PROGRESS_STAGES = [
- { value: 42, label: "正在运行固定处理器", delay: 600 },
- { value: 68, label: "正在独立验收结果", delay: 1400 },
- { value: 88, label: "正在提交数据库", delay: 1800 },
+ { value: 42, label: "upload.stage_processor", delay: 600 },
+ { value: 68, label: "upload.stage_validate", delay: 1400 },
+ { value: 88, label: "upload.stage_commit", delay: 1800 },
];
const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"];
const jobStatus = {
- received: ["已接收", "running"],
- uploaded: ["已上传", "running"],
- queued: ["等待处理", "running"],
- running: ["处理中", "running"],
- delivered: ["等待验收", "running"],
- validating: ["正在验收", "running"],
- accepted: ["已完成", "success"],
- succeeded: ["已完成", "success"],
- rejected: ["验收失败", "failed"],
- failed: ["处理失败", "failed"],
- cancelled: ["已取消", "failed"],
+ received: ["status.received", "running"],
+ uploaded: ["status.uploaded", "running"],
+ queued: ["status.waiting_processing", "running"],
+ running: ["status.processing", "running"],
+ delivered: ["status.waiting_validation", "running"],
+ validating: ["status.validating", "running"],
+ accepted: ["status.completed", "success"],
+ succeeded: ["status.completed", "success"],
+ rejected: ["status.validation_failed", "failed"],
+ failed: ["status.failed", "failed"],
+ cancelled: ["status.cancelled", "failed"],
};
const reportStatus = {
- reserved: ["生成中", "running"],
- generating: ["生成中", "running"],
- validated: ["已验收", "running"],
- active: ["当前版本", "success current"],
- superseded: ["历史版本", ""],
- failed: ["生成失败", "failed"],
+ reserved: ["status.generating", "running"],
+ generating: ["status.generating", "running"],
+ validated: ["status.validated", "running"],
+ active: ["status.current_version", "success current"],
+ superseded: ["status.history_version", ""],
+ failed: ["status.generation_failed", "failed"],
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -115,7 +121,7 @@
function showToast(message, isError = false) {
const toast = $("#toast");
- toast.textContent = message;
+ toast.textContent = I18N?.text(message) || message;
toast.classList.toggle("is-error", isError);
toast.classList.add("is-visible");
window.clearTimeout(state.toastTimer);
@@ -138,16 +144,17 @@
const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" });
if (response.status === 401) {
redirectToLogin();
- throw new Error("登录状态已失效");
+ throw new Error(I18N?.errorMessage("SESSION_INVALID", "登录状态已失效") || "登录状态已失效");
}
let payload;
try {
payload = await response.json();
} catch (_) {
- throw new Error("服务返回了无法识别的结果");
+ throw new Error(I18N?.t("error.unrecognized_response") || "服务返回了无法识别的结果");
}
if (!response.ok || !payload.ok) {
- throw new Error(payload?.error?.message || "请求未完成");
+ const error = payload?.error || {};
+ throw new Error(I18N?.errorMessage(error.code, error.message) || error.message || "请求未完成");
}
return returnEnvelope ? payload : payload.data;
}
@@ -164,43 +171,109 @@
return Math.max(0, Math.min(offset + direction * HISTORY_PAGE_SIZE, lastOffset));
}
+ const HISTORY_SCOPES = {
+ jobs: { stateKey: "jobsMonth", countKey: "daily_count" },
+ monthly: { stateKey: "monthlyMonth", countKey: "monthly_count" },
+ company: { stateKey: "companyHistoryMonth", countKey: "company_count" },
+ };
+
+ function validMonth(value) {
+ return /^\d{4}-(0[1-9]|1[0-2])$/.test(value || "");
+ }
+
+ function shiftMonth(value, amount) {
+ if (!validMonth(value)) return localMonth();
+ const [year, month] = value.split("-").map(Number);
+ const shifted = new Date(Date.UTC(year, month - 1 + amount, 1));
+ return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}`;
+ }
+
+ function latestHistoryMonth(scope) {
+ const countKey = HISTORY_SCOPES[scope].countKey;
+ return state.historyMonths.find((item) => Number(item[countKey] || 0) > 0)?.month_key || "";
+ }
+
+ function historyMonth(scope) {
+ return state[HISTORY_SCOPES[scope].stateKey] || localMonth();
+ }
+
+ function renderHistoryMonthControl(scope) {
+ const selected = historyMonth(scope);
+ const current = localMonth();
+ const input = $(`#${scope}-history-month`);
+ if (!input) return;
+ input.value = selected;
+ input.max = current;
+ $(`#${scope}-month-previous`).disabled = selected <= input.min;
+ $(`#${scope}-month-next`).disabled = selected >= current;
+ $(`#${scope}-month-current`).disabled = selected === current;
+ }
+
+ function renderHistoryMonthControls() {
+ Object.keys(HISTORY_SCOPES).forEach(renderHistoryMonthControl);
+ }
+
+ async function loadHistoryMonths() {
+ try {
+ const months = await api("/api/history-months");
+ state.historyMonths = Array.isArray(months)
+ ? months.filter((item) => validMonth(item?.month_key))
+ : [];
+ state.historyMonthsLoaded = true;
+ } catch (_) {
+ state.historyMonths = [];
+ }
+ Object.entries(HISTORY_SCOPES).forEach(([scope, config]) => {
+ if (!state[config.stateKey]) {
+ state[config.stateKey] = latestHistoryMonth(scope) || localMonth();
+ }
+ });
+ renderHistoryMonthControls();
+ }
+
+ function historyEmptyMarkup(scope, emptyKey, fallback) {
+ const selected = historyMonth(scope);
+ const recent = latestHistoryMonth(scope);
+ const message = I18N?.t(emptyKey, { month: monthLabel(selected) }) || `${monthLabel(selected)} · ${fallback}`;
+ const jump = recent && recent !== selected
+ ? ``
+ : "";
+ return `${escapeHtml(message)}${jump}`;
+ }
+
function renderPagination(scope, total, offset, loading = false) {
const pageCount = Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE));
const page = Math.min(pageCount, Math.floor(offset / HISTORY_PAGE_SIZE) + 1);
const start = total > 0 && offset < total ? offset + 1 : 0;
const end = start > 0 ? Math.min(offset + HISTORY_PAGE_SIZE, total) : 0;
- $(`#${scope}-pagination-summary`).textContent = total > 0
- ? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`
- : "共 0 条";
- $(`#${scope}-page-label`).textContent = `第 ${formatInteger(page)} / ${formatInteger(pageCount)} 页`;
+ const summary = total > 0
+ ? (I18N?.t("pagination.summary", { total: formatInteger(total), start: formatInteger(start), end: formatInteger(end) }) || `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`)
+ : (I18N?.t("pagination.zero") || "共 0 条");
+ $(`#${scope}-pagination-summary`).textContent = I18N?.t("history.pagination", { month: monthLabel(historyMonth(scope)), summary }) || `${monthLabel(historyMonth(scope))} · ${summary}`;
+ $(`#${scope}-page-label`).textContent = I18N?.t("pagination.page", { page: formatInteger(page), pages: formatInteger(pageCount) }) || `第 ${formatInteger(page)} / ${formatInteger(pageCount)} 页`;
$(`#${scope}-prev`).disabled = loading || offset <= 0;
$(`#${scope}-next`).disabled = loading || offset + HISTORY_PAGE_SIZE >= total;
}
function formatInteger(value) {
- return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0));
+ return I18N?.formatInteger(value) || new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0));
}
function formatMoney(value, compact = false) {
+ if (I18N) return I18N.formatMoney(value, compact);
const amount = Number(value || 0);
- if (compact && Math.abs(amount) >= 10000) return `¥${(amount / 10000).toFixed(amount >= 100000 ? 1 : 2)}万`;
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount);
}
function formatDate(value, withTime = false) {
- if (!value) return "—";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return escapeHtml(value);
- return new Intl.DateTimeFormat("zh-CN", withTime
- ? { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }
- : { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit" }
- ).format(date);
+ return I18N?.formatDate(value, withTime) || "—";
}
function duration(job) {
if (!job.created_at || !(job.finished_at || job.updated_at)) return "—";
const seconds = Math.max(0, Math.round((new Date(job.finished_at || job.updated_at) - new Date(job.created_at)) / 1000));
if (!Number.isFinite(seconds)) return "—";
+ if (I18N) return I18N.formatDurationSeconds(seconds);
if (seconds < 60) return `${seconds} 秒`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
@@ -208,7 +281,8 @@
}
function chip(status, map) {
- const [label, style] = map[status] || [status || "未知", ""];
+ const [labelKey, style] = map[status] || ["common.unknown", ""];
+ const label = I18N?.t(labelKey) || labelKey;
return `${escapeHtml(label)}`;
}
@@ -245,20 +319,24 @@
const button = $("#logout-button");
if (!session.username) return;
button.hidden = false;
- button.disabled = false;
- button.title = `${session.username} · 退出登录`;
+ button.removeAttribute("aria-disabled");
+ button.classList.remove("is-busy");
+ button.title = `${session.username} · ${I18N?.t("auth.logout") || "退出登录"}`;
}
async function logout() {
const button = $("#logout-button");
- button.disabled = true;
+ if (button.getAttribute("aria-disabled") === "true") return;
+ button.setAttribute("aria-disabled", "true");
+ button.classList.add("is-busy");
try {
await api("/api/logout", { method: "POST" });
window.location.replace("/login");
} catch (error) {
if (document.visibilityState === "visible") {
- showToast(error.message || "退出登录失败,请重试", true);
- button.disabled = false;
+ showToast(error.message || I18N?.t("auth.logout_failed") || "退出登录失败,请重试", true);
+ button.removeAttribute("aria-disabled");
+ button.classList.remove("is-busy");
}
}
}
@@ -270,14 +348,24 @@
state.health = { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false };
}
const node = $("#system-status");
- node.classList.toggle("is-ready", state.health.database_ready);
- node.classList.toggle("is-error", !state.health.database_ready);
- $("span", node).textContent = state.health.database_ready ? "数据库已连接" : "数据服务未连接";
+ const databaseReady = Boolean(state.health.database_ready);
+ const statusText = $("span", node);
+ node.classList.toggle("is-ready", databaseReady);
+ node.classList.toggle("is-error", !databaseReady);
+ statusText.textContent = databaseReady
+ ? ""
+ : (I18N?.t("status.service_disconnected") || "数据服务未连接");
+ node.setAttribute(
+ "aria-label",
+ databaseReady
+ ? (I18N?.t("status.service_ready") || "服务正常")
+ : (I18N?.t("status.service_disconnected") || "数据服务未连接"),
+ );
const fileInput = $("#xml-file");
fileInput.disabled = !state.health.processing_ready;
$("#dropzone").classList.toggle("is-disabled", !state.health.processing_ready);
- $("#upload-hint").textContent = state.health.processing_ready ? "" : "文件接收服务尚未完成生产接线。";
+ $("#upload-hint").textContent = state.health.processing_ready ? "" : (I18N?.t("upload.service_unready") || "文件接收服务尚未完成生产接线。");
state.companyReportsReady = Boolean(state.health.company_reports_ready);
state.companySourceUploadReady = Boolean(state.health.company_source_upload_ready);
$("#company-report-unavailable").hidden = state.companyReportsReady;
@@ -288,24 +376,24 @@
function renderJobs(jobs) {
const body = $("#jobs-body");
if (!jobs.length) {
- body.innerHTML = '