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 = '本月暂无 Daily Report 记录'; + body.innerHTML = `${historyEmptyMarkup("jobs", "history.daily_empty", "暂无 Daily Report 记录")}`; $("#metric-arrival").textContent = "—"; $("#metric-duration").textContent = "—"; $("#metric-rooms").textContent = "—"; return; } body.innerHTML = jobs.map((job) => ` - + ${escapeHtml(job.filename || "—")} ${chip(job.status, jobStatus)}${job.failure_code ? `${escapeHtml(job.failure_code)}` : ""} - ${escapeHtml(job.arrival_date || "—")} + ${escapeHtml(job.arrival_date ? formatDate(job.arrival_date) : "—")} ${formatInteger(job.no_of_rooms)} ${duration(job)} ${formatDate(job.created_at, true)} - ${state.health.download_ready && job.daily_report_sha256 ? `下载` : ''} + ${state.health.download_ready && job.daily_report_sha256 ? `${escapeHtml(I18N?.t("common.download") || "下载")}` : ''} `).join(""); const latest = jobs.find((job) => job.status === "succeeded"); - $("#metric-arrival").textContent = latest?.arrival_date || "—"; + $("#metric-arrival").textContent = latest?.arrival_date ? formatDate(latest.arrival_date) : "—"; $("#metric-duration").textContent = latest ? duration(latest) : "—"; $("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—"; } @@ -341,7 +429,7 @@ function setTraceLiveState(copy, style = "") { const node = $("#trace-live-state"); node.className = `trace-live-state${style ? ` ${style}` : ""}`; - node.textContent = copy; + node.textContent = I18N?.text(copy) || copy; } function traceConsoleValue(value) { @@ -382,28 +470,28 @@ } } if (copyTextWithSelection(text)) return; - throw clipboardError || new Error("浏览器未允许访问剪贴板"); + throw clipboardError || new Error(I18N?.t("task.browser_clipboard") || "浏览器未允许访问剪贴板"); } async function copyAllTraceLogs() { const button = $("#copy-trace"); const text = $("#process-log").textContent || ""; if (!state.jobTrace || !text.trim()) { - showToast("暂无可复制的任务日志", true); + showToast(I18N?.t("task.none_to_copy") || "暂无可复制的任务日志", true); return; } button.disabled = true; - button.textContent = "复制中…"; + button.textContent = I18N?.t("task.copying_button") || "复制中…"; try { await writeClipboardText(text); - button.textContent = "已复制"; - showToast(`已复制全部任务日志(${text.split("\n").length} 行)`); + button.textContent = I18N?.t("task.copied_button") || "已复制"; + showToast(I18N?.t("task.copying_lines", { count: formatInteger(text.split("\n").length) }) || `已复制全部任务日志(${text.split("\n").length} 行)`); } catch (_) { - button.textContent = "复制失败"; - showToast("无法复制日志,请检查浏览器剪贴板权限", true); + button.textContent = I18N?.t("task.copy_failed_button") || "复制失败"; + showToast(I18N?.t("task.copy_denied") || "无法复制日志,请检查浏览器剪贴板权限", true); } finally { window.setTimeout(() => { - button.textContent = "复制全部日志"; + button.textContent = I18N?.t("task.copy_button") || "复制全部日志"; button.disabled = !state.jobTrace; }, 1600); } @@ -414,11 +502,11 @@ setTraceLiveState(liveCopy, tone ? `is-${tone}` : ""); $("#refresh-trace").disabled = !state.selectedJobId; $("#copy-trace").disabled = true; - $("#trace-log-count").textContent = "0 events"; + $("#trace-log-count").textContent = I18N?.t("trace.events", { count: 0 }) || "0 events"; $("#process-log").textContent = [ `$ arr trace${state.selectedJobId ? ` --job ${state.selectedJobId}` : ""}`, - `# ${title}`, - `# ${logCopy || detail || "请上传文件,或从下方选择一个历史任务。"}`, + `# ${I18N?.text(title) || title}`, + `# ${I18N?.text(logCopy || detail || "请上传文件,或从下方选择一个历史任务。") || logCopy || detail || "请上传文件,或从下方选择一个历史任务。"}`, ].join("\n"); } @@ -434,19 +522,22 @@ "--------------------------------------------------------------------------------", ...logs.map((log) => { const level = String(log.level || "info").toUpperCase().padEnd(7, " "); - return `${log.timestamp || "-"} ${level} stage=${log.stage || "-"} code=${log.code || "-"} title=${traceConsoleValue(log.title)} message=${traceConsoleValue(log.message)} details=${JSON.stringify(log.details || {})}`; + const title = I18N?.text(log.title || "") || log.title || ""; + const message = I18N?.errorMessage(log.code, log.message) || I18N?.text(log.message || "") || log.message || ""; + return `${log.timestamp || "-"} ${level} stage=${log.stage || "-"} code=${log.code || "-"} title=${traceConsoleValue(title)} message=${traceConsoleValue(message)} details=${JSON.stringify(log.details || {})}`; }), "--------------------------------------------------------------------------------", ]; if (job.failure) { - lines.push(`FAILURE stage=${job.current_stage || "-"} code=${job.failure.code || "-"} message=${traceConsoleValue(job.failure.message)}`); + const failureMessage = I18N?.errorMessage(job.failure.code, job.failure.message) || I18N?.text(job.failure.message || "") || job.failure.message || ""; + lines.push(`FAILURE stage=${job.current_stage || "-"} code=${job.failure.code || "-"} message=${traceConsoleValue(failureMessage)}`); } lines.push(`END status=${job.status || "unknown"} events=${logs.length} refreshed_at=${traceConsoleValue(trace.refreshed_at)}`); const output = $("#process-log"); output.textContent = lines.join("\n"); output.scrollTop = output.scrollHeight; - $("#trace-log-count").textContent = `${logs.length} events`; + $("#trace-log-count").textContent = I18N?.t("trace.events_count", { count: formatInteger(logs.length) }) || `${logs.length} events`; $("#copy-trace").disabled = false; } @@ -468,13 +559,13 @@ const job = trace.job || {}; syncTraceToJobs(trace); if (job.failure) { - setTraceLiveState(`FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error"); + setTraceLiveState(I18N?.t("trace.failed_status", { stage: job.current_stage || "unknown", code: job.failure.code || "unknown" }) || `FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error"); } else if (job.active) { - setTraceLiveState(`RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live"); + setTraceLiveState(I18N?.t("trace.running_status", { seconds: TRACE_POLL_INTERVAL / 1000 }) || `RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live"); } else if (job.status === "succeeded") { - setTraceLiveState("SUCCEEDED", "is-complete"); + setTraceLiveState(I18N?.t("trace.succeeded_status") || "SUCCEEDED", "is-complete"); } else { - setTraceLiveState(String(job.status || "UNKNOWN").toUpperCase()); + setTraceLiveState(I18N?.t("trace.unknown_status", { status: String(job.status || "UNKNOWN").toUpperCase() }) || String(job.status || "UNKNOWN").toUpperCase()); } $("#refresh-trace").disabled = !job.job_id; renderTraceConsole(trace); @@ -496,8 +587,8 @@ if (!jobId || state.jobTraceLoading) return; state.jobTraceLoading = true; if (!silent && state.jobTrace?.job?.job_id !== jobId) { - renderTracePlaceholder("正在读取任务日志", `任务 ${jobId}`, "running", "正在聚合数据库中的处理证据…"); - setTraceLiveState("正在读取", "is-live"); + renderTracePlaceholder(I18N?.t("task.reading") || "正在读取任务日志", I18N?.t("task.selected_job", { jobId }) || `任务 ${jobId}`, "running", I18N?.t("task.reading_detail") || "正在聚合数据库中的处理证据…"); + setTraceLiveState(I18N?.t("task.live_reading") || "正在读取", "is-live"); } try { const trace = await api(`/api/jobs/${encodeURIComponent(jobId)}/trace`); @@ -507,9 +598,9 @@ } catch (error) { if (state.selectedJobId !== jobId) return; if (!state.jobTrace) { - renderTracePlaceholder("任务日志暂时无法读取", error.message, "error", "服务端没有返回可用日志。"); + renderTracePlaceholder(I18N?.t("task.unavailable") || "任务日志暂时无法读取", error.message, "error", I18N?.t("task.no_server_log") || "服务端没有返回可用日志。"); } - setTraceLiveState(showErrors ? "日志刷新失败" : "自动刷新失败", "is-error"); + setTraceLiveState(showErrors ? (I18N?.t("task.live_failed") || "日志刷新失败") : (I18N?.t("task.live_auto_failed") || "自动刷新失败"), "is-error"); if (showErrors) showToast(error.message, true); } finally { const selectionChanged = state.selectedJobId !== jobId; @@ -538,7 +629,7 @@ state.jobsLoading = true; renderPagination("jobs", state.jobsTotal, state.jobsOffset, true); try { - const month = encodeURIComponent(localMonth()); + const month = encodeURIComponent(historyMonth("jobs")); let page = readPage(await api( `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`, { returnEnvelope: true }, @@ -559,13 +650,13 @@ await loadJobTrace(showErrors, Boolean(state.jobTrace)); } else if (!state.jobs.length && !state.selectedJobId) { renderTracePlaceholder( - "等待上传 ARR.XML", - "新任务创建后,这里会显示服务端保存的全流程日志。", + I18N?.t("task.waiting_upload") || "等待上传 ARR.XML", + I18N?.t("task.after_create") || "新任务创建后,这里会显示服务端保存的全流程日志。", ); - setTraceLiveState("未选择任务"); + setTraceLiveState(I18N?.t("task.live_unselected") || "未选择任务"); } } catch (error) { - $("#jobs-body").innerHTML = '日报记录暂时无法读取'; + $("#jobs-body").innerHTML = `${escapeHtml(I18N?.t("daily.unavailable") || "日报记录暂时无法读取")}`; if (showErrors) showToast(error.message, true); } finally { state.jobsLoading = false; @@ -586,7 +677,7 @@ const node = $("#monthly-live-state"); if (!node) return; node.className = `trace-live-state${style ? ` ${style}` : ""}`; - node.textContent = copy; + node.textContent = I18N?.text(copy) || copy; } function scheduleMonthlyPoll(delay = MONTHLY_POLL_INTERVAL) { @@ -598,27 +689,27 @@ function renderMonthly(runs) { const body = $("#monthly-body"); if (!runs.length) { - body.innerHTML = '该月份暂无月报处理记录'; + body.innerHTML = `${historyEmptyMarkup("monthly", "history.monthly_empty", "暂无月报处理记录")}`; return; } body.innerHTML = runs.map((run) => ` - ${escapeHtml(run.max_arrival_date || run.as_of_date || "—")} + ${escapeHtml(run.max_arrival_date || run.as_of_date ? formatDate(run.max_arrival_date || run.as_of_date) : "—")} ${chip(run.status, reportStatus)} ${formatInteger(run.row_count)} ${formatInteger(run.channel_count)} ${formatDate(run.updated_at, true)} - ${state.health.download_ready && run.artifact_sha256 ? `下载` : ''} + ${state.health.download_ready && run.artifact_sha256 ? `${escapeHtml(I18N?.t("common.download") || "下载")}` : ''} `).join(""); } async function loadMonthly(showErrors = false, silent = false) { if (state.monthlyLoading) return; state.monthlyLoading = true; - const month = localMonth(); + const month = historyMonth("monthly"); const previousTotal = state.monthlyTotal; renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, true); - if (!silent) setMonthlyLiveState("正在读取…", "is-live"); + if (!silent) setMonthlyLiveState(I18N?.t("monthly.loading") || "正在读取月报记录…", "is-live"); try { const encodedMonth = encodeURIComponent(month); let page = readPage(await api( @@ -643,13 +734,13 @@ state.monthlyTotal = page.total; state.monthlyLoaded = true; renderMonthly(state.monthlyRuns); - setMonthlyLiveState(`自动更新 · ${MONTHLY_POLL_INTERVAL / 1000} 秒`, "is-live"); - if (hasNewPublication) showToast("新月报已自动加入列表"); + setMonthlyLiveState(I18N?.t("progress.auto_update", { seconds: MONTHLY_POLL_INTERVAL / 1000 }) || `自动更新 · ${MONTHLY_POLL_INTERVAL / 1000} 秒`, "is-live"); + if (hasNewPublication) showToast(I18N?.t("monthly.new") || "新月报已自动加入列表"); } catch (error) { if (!silent || !state.monthlyLoaded) { - $("#monthly-body").innerHTML = '月报记录暂时无法读取'; + $("#monthly-body").innerHTML = `${escapeHtml(I18N?.t("monthly.unavailable") || "月报记录暂时无法读取")}`; } - setMonthlyLiveState("自动更新重试中", "is-error"); + setMonthlyLiveState(I18N?.t("monthly.retry") || "自动更新重试中", "is-error"); if (showErrors) showToast(error.message, true); } finally { state.monthlyLoading = false; @@ -677,7 +768,7 @@ ${escapeHtml(item.worksheet)} ${formatMoney(item.totals.total_price, true)} - `).join("") : '

暂无公司销售数据

'; + `).join("") : `

${escapeHtml(I18N?.t("bi.empty_sales") || "暂无公司销售数据")}

`; $$(".rank-track i", $("#company-ranking")).forEach((bar) => { bar.style.width = `${Number(bar.dataset.width || 0)}%`; }); const total = sorted.reduce((sum, item) => sum + Number(item.totals.total_price || 0), 0); @@ -697,7 +788,7 @@ function renderMatrix(data) { const roomTypes = data.overall.room_types.map((item) => item.room_type); if (!roomTypes.length || !data.channels.length) { - $("#room-matrix").innerHTML = '

暂无渠道与房型数据

'; + $("#room-matrix").innerHTML = `

${escapeHtml(I18N?.t("bi.empty") || "暂无渠道与房型数据")}

`; return; } const heading = roomTypes.map((room) => `${escapeHtml(room)}`).join(""); @@ -706,7 +797,15 @@ return `${escapeHtml(channel.worksheet)}${roomTypes.map((room) => `${formatInteger(values.get(room) || 0)}`).join("")}${formatInteger(channel.totals.rooms_sold)}`; }).join(""); const totals = new Map(data.overall.room_types.map((room) => [room.room_type, room.rooms_sold])); - $("#room-matrix").innerHTML = `${heading}${rows}${roomTypes.map((room) => ``).join("")}
渠道合计
合计${formatInteger(totals.get(room) || 0)}${formatInteger(data.overall.totals.rooms_sold)}
`; + const channelLabel = I18N?.t("bi.channel") || "渠道"; + const totalLabel = I18N?.t("common.total") || "合计"; + $("#room-matrix").innerHTML = `${heading}${rows}${roomTypes.map((room) => ``).join("")}
${escapeHtml(channelLabel)}${escapeHtml(totalLabel)}
${escapeHtml(totalLabel)}${formatInteger(totals.get(room) || 0)}${formatInteger(data.overall.totals.rooms_sold)}
`; + } + + function analyticsDataRange(data = {}) { + const start = data.min_arrival_date ? formatDate(data.min_arrival_date) : "—"; + const end = data.max_arrival_date ? formatDate(data.max_arrival_date) : "—"; + return I18N?.t("bi.data_range", { start, end }) || `数据范围:${start} – ${end}`; } function renderAnalytics(data) { @@ -716,7 +815,7 @@ $("#bi-revenue").textContent = formatMoney(totals.total_price, true); $("#bi-nights").textContent = formatInteger(totals.room_nights); $("#bi-channels").textContent = formatInteger(totals.channel_count); - $("#bi-subtitle").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`; + $("#bi-data-range").textContent = analyticsDataRange(data); renderCompanySales(data.channels); renderMatrix(data); } @@ -726,6 +825,7 @@ $("#company-ranking").innerHTML = `

${escapeHtml(message)}

`; $("#donut-legend").innerHTML = ""; $("#company-donut").style.background = "#d8e0ec"; + $("#bi-data-range").textContent = analyticsDataRange(); $("#room-matrix").innerHTML = `

${escapeHtml(message)}

`; } @@ -734,7 +834,7 @@ try { renderAnalytics(await api(`/api/analytics?month=${encodeURIComponent(month)}`)); } catch (error) { - resetAnalytics("该月份暂无可用看板数据"); + resetAnalytics(I18N?.t("bi.empty") || "该月份暂无可用看板数据"); if (showErrors) showToast(error.message, true); } } @@ -762,10 +862,10 @@ state.uploadProgressValue = bounded; progress.hidden = false; progress.className = `upload-progress${tone ? ` is-${tone}` : ""}`; - labelNode.textContent = label; + labelNode.textContent = I18N?.text(label) || label; percentNode.textContent = `${bounded}%`; track.setAttribute("aria-valuenow", String(bounded)); - track.setAttribute("aria-valuetext", `${label} ${bounded}%`); + track.setAttribute("aria-valuetext", `${I18N?.text(label) || label} ${bounded}%`); bar.style.transform = `scaleX(${bounded / 100})`; } @@ -776,21 +876,21 @@ progress.hidden = true; progress.className = "upload-progress"; state.uploadProgressValue = 0; - $("#upload-progress-label").textContent = "等待开始"; + $("#upload-progress-label").textContent = I18N?.t("upload.waiting") || "等待开始"; $("#upload-progress-percent").textContent = "0%"; $("#upload-progress-track").setAttribute("aria-valuenow", "0"); - $("#upload-progress-track").setAttribute("aria-valuetext", "等待开始 0%"); + $("#upload-progress-track").setAttribute("aria-valuetext", `${I18N?.t("upload.waiting") || "等待开始"} 0%`); $("#upload-progress-bar").style.transform = "scaleX(0)"; } function startUploadProgress() { clearUploadProgressTimer(); - setUploadProgress(12, "正在上传 ARR.XML"); + setUploadProgress(12, I18N?.t("upload.uploading") || "正在上传 ARR.XML"); let stageIndex = 0; const advance = () => { const stage = DAILY_UPLOAD_PROGRESS_STAGES[stageIndex]; if (!stage) return; - setUploadProgress(stage.value, stage.label); + setUploadProgress(stage.value, I18N?.t(stage.label) || stage.label); stageIndex += 1; if (DAILY_UPLOAD_PROGRESS_STAGES[stageIndex]) { state.uploadProgressTimer = window.setTimeout(advance, stage.delay); @@ -819,18 +919,18 @@ fileInput.disabled = true; dropzone.classList.add("is-disabled"); button.setAttribute("aria-busy", "true"); - button.textContent = "处理中…"; + button.textContent = I18N?.t("upload.processing") || "处理中…"; clearTracePoll(); state.selectedJobId = ""; state.jobTrace = null; renderJobs(state.jobs); renderTracePlaceholder( - "正在处理ARR.XML文件", - "正在安全上传、运行固定处理器、独立验收并提交数据库。", + I18N?.t("upload.processing") || "正在处理ARR.XML文件", + I18N?.t("upload.processing_detail") || "正在安全上传、运行固定处理器、独立验收并提交数据库。", "running", - "处理完成后将立即读取服务端全流程日志…", + I18N?.t("upload.after_processing") || "处理完成后将立即读取服务端全流程日志…", ); - setTraceLiveState("正在创建任务", "is-live"); + setTraceLiveState(I18N?.t("upload.creating_task") || "正在创建任务", "is-live"); startUploadProgress(); try { const receipt = await api("/api/jobs", { @@ -840,28 +940,31 @@ }); state.selectedFile = null; $("#xml-file").value = ""; - $("#selected-file").textContent = "尚未选择文件"; + $("#selected-file").textContent = I18N?.t("upload.file_not_selected") || "尚未选择文件"; const failed = receipt.status === "failed"; - finishUploadProgress(!failed, failed ? "处理失败" : "处理完成"); - showToast(failed ? "ARR.XML 处理失败,请查看任务日志" : "ARR.XML 已处理并完成入库", failed); + finishUploadProgress(!failed, failed ? (I18N?.t("upload.processing_failed") || "处理失败") : (I18N?.t("upload.processing_done") || "处理完成")); + showToast(failed ? (I18N?.t("upload.failure_log") || "ARR.XML 处理失败,请查看任务日志") : (I18N?.t("upload.success") || "ARR.XML 已处理并完成入库"), failed); + const receiptMonth = String(receipt.business_date || receipt.arrival_date || "").slice(0, 7); + state.jobsMonth = validMonth(receiptMonth) ? receiptMonth : localMonth(); state.jobsOffset = 0; + renderHistoryMonthControl("jobs"); await loadJobs(false, false); if (receipt.job_id) { await selectJob(receipt.job_id); } else { - renderTracePlaceholder("处理已结束", "任务编号暂不可用,请刷新 Daily Report。", "success"); + renderTracePlaceholder(I18N?.t("task.trace_processing") || "处理已结束", I18N?.t("task.no_job_id") || "任务编号暂不可用,请刷新 Daily Report。", "success"); } } catch (error) { - finishUploadProgress(false, "处理失败"); - renderTracePlaceholder("处理未能完成", error.message, "error", "请刷新 Daily Report,确认是否已登记失败任务。"); - setTraceLiveState("处理失败", "is-error"); + finishUploadProgress(false, I18N?.t("upload.processing_failed") || "处理失败"); + renderTracePlaceholder(I18N?.t("task.trace_failed") || "处理未能完成", error.message, "error", I18N?.t("task.failed_refresh_hint") || "请刷新 Daily Report,确认是否已登记失败任务。"); + setTraceLiveState(I18N?.t("task.error_status") || "处理失败", "is-error"); showToast(error.message, true); } finally { state.uploadInFlight = false; fileInput.disabled = !state.health.processing_ready; dropzone.classList.toggle("is-disabled", !state.health.processing_ready); button.removeAttribute("aria-busy"); - button.textContent = "开始处理"; + button.textContent = I18N?.t("upload.start") || "开始处理"; button.disabled = !state.selectedFile || !state.health.processing_ready; } } @@ -879,7 +982,7 @@ function setCompanySourceError(message = "") { const node = $("#company-source-error"); - node.textContent = message; + node.textContent = I18N?.text(message) || message; node.hidden = !message; } @@ -892,7 +995,9 @@ const button = $("#company-upload-button"); button.disabled = !enabled || !state.companySourceFile; button.setAttribute("aria-busy", String(state.companySourceUploading)); - $("span", button).textContent = state.companySourceUploading ? "正在提取" : "提取并核对"; + $("span", button).textContent = state.companySourceUploading + ? (I18N?.t("company.checking") || "核对中") + : (I18N?.t("company.extract") || "提取并核对"); } function clearCompanyReviewSelection() { @@ -912,7 +1017,7 @@ pageCheckbox.checked = visibleIds.length > 0 && selectedCount === visibleIds.length; pageCheckbox.indeterminate = selectedCount > 0 && selectedCount < visibleIds.length; pageCheckbox.disabled = busy || visibleIds.length === 0; - $("#company-review-selection-count").textContent = `已选 ${formatInteger(selectedCount)} 条`; + $("#company-review-selection-count").textContent = I18N?.t("company.selected_count", { count: formatInteger(selectedCount) }) || `已选 ${formatInteger(selectedCount)} 条`; $("#company-review-delete-selected").disabled = busy || selectedCount === 0; $$("[data-review-select]", $("#company-review-body")).forEach((checkbox) => { const itemId = Number(checkbox.value); @@ -952,6 +1057,7 @@ panel.hidden = false; panel.classList.toggle("is-busy", busy); $("#company-review-filename").textContent = filename || "未记录文件名"; + if (!filename) $("#company-review-filename").textContent = I18N?.t("company.file_name_missing") || "未记录文件名"; if (filename) $("#company-review-filename").title = filename; else $("#company-review-filename").removeAttribute("title"); $("#company-review-confirmed").textContent = formatInteger(confirmed); @@ -962,18 +1068,18 @@ const isPending = item.review_status === "pending"; const itemId = Number(item.item_id); const selected = state.companyReviewSelectedIds.has(itemId); - const tourCode = escapeHtml(item.tour_code || "暂无"); - const raw = escapeHtml(item.room_type_raw || "暂无"); + const tourCode = escapeHtml(item.tour_code || I18N?.t("company.review_no_record") || "暂无"); + const raw = escapeHtml(item.room_type_raw || I18N?.t("company.review_no_record") || "暂无"); const roomType = escapeHtml(item.room_type || ""); const quantity = Number(item.quantity) || 1; return ` - + ${tourCode} - ${raw} - - - ${isPending ? "待人工确认" : "已确认"} -
+ ${raw} + + + ${isPending ? (I18N?.t("company.review_pending_confirm") || "待人工确认") : (I18N?.t("company.confirmed") || "已确认")} +
`; }).join(""); empty.hidden = items.length > 0; @@ -987,9 +1093,9 @@ const start = total > 0 ? offset + 1 : 0; const end = total > 0 ? Math.min(offset + items.length, total) : 0; $("#company-review-page-summary").textContent = total > 0 - ? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}` - : "共 0 条"; - $("#company-review-page-label").textContent = `第 ${formatInteger(currentPage)} / ${formatInteger(pageCount)} 页`; + ? (I18N?.t("pagination.summary", { total: formatInteger(total), start: formatInteger(start), end: formatInteger(end) }) || `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`) + : (I18N?.t("pagination.zero") || "共 0 条"); + $("#company-review-page-label").textContent = I18N?.t("pagination.page", { page: formatInteger(currentPage), pages: formatInteger(pageCount) }) || `第 ${formatInteger(currentPage)} / ${formatInteger(pageCount)} 页`; $("#company-review-prev").disabled = busy || offset <= 0; $("#company-review-next").disabled = busy || offset + items.length >= total; $("#company-review-discard").disabled = busy; @@ -1001,18 +1107,18 @@ state.companySourceFile = null; if (file) { if (!file.name.toLowerCase().endsWith(".xlsx")) { - setCompanySourceError("请选择 .xlsx 格式的 Excel 文件"); + setCompanySourceError(I18N?.t("company.file_xlsx") || "请选择 .xlsx 格式的 Excel 文件"); } else if (!file.size) { - setCompanySourceError("Excel 文件为空"); + setCompanySourceError(I18N?.t("company.file_empty") || "Excel 文件为空"); } else if (file.size > state.maxUploadBytes) { - setCompanySourceError("Excel 文件超过 25 MB"); + setCompanySourceError(I18N?.t("company.file_too_large") || "Excel 文件超过 25 MB"); } else { state.companySourceFile = file; } } $("#company-selected-file").textContent = state.companySourceFile ? `${state.companySourceFile.name} · ${formatInteger(state.companySourceFile.size / 1024)} KB` - : "尚未选择文件"; + : (I18N?.t("upload.file_not_selected") || "尚未选择文件"); updateCompanySourceUploadControls(); } @@ -1027,7 +1133,7 @@ setCompanySourceError(); } catch (error) { state.companySource = null; - setCompanySourceError("当前 Excel 数据源暂时无法读取"); + setCompanySourceError(I18N?.t("company.source_error") || "当前 Excel 数据源暂时无法读取"); if (showErrors) showToast(error.message, true); } updateCompanyReportControls(); @@ -1052,7 +1158,7 @@ state.companySourceDraft = page; } catch (error) { state.companySourceDraft = null; - setCompanySourceError("当前提取结果暂时无法读取"); + setCompanySourceError(I18N?.t("company.draft_error") || "当前提取结果暂时无法读取"); if (showErrors) showToast(error.message, true); } finally { state.companyReviewLoading = false; @@ -1082,13 +1188,15 @@ clearCompanyReviewSelection(); state.companySourceFile = null; $("#company-excel-file").value = ""; - $("#company-selected-file").textContent = "尚未选择文件"; + $("#company-selected-file").textContent = I18N?.t("upload.file_not_selected") || "尚未选择文件"; renderCompanyDraft(state.companySourceDraft); const pending = Number(receipt?.summary?.pending_items) || 0; - showToast(pending > 0 ? `提取完成,${formatInteger(pending)} 条需要人工确认` : "提取完成,可以确认并启用"); + showToast(pending > 0 + ? (I18N?.t("company.extract_pending", { count: formatInteger(pending) }) || `提取完成,${formatInteger(pending)} 条需要人工确认`) + : (I18N?.t("company.extract_done") || "提取完成,可以确认并启用")); } catch (error) { - setCompanySourceError(error.message || "Excel 文件未能完成提取"); - showToast(error.message || "Excel 文件未能完成提取", true); + setCompanySourceError(error.message || I18N?.t("company.file_extract_failed") || "Excel 文件未能完成提取"); + showToast(error.message || I18N?.t("company.file_extract_failed") || "Excel 文件未能完成提取", true); } finally { state.companySourceUploading = false; updateCompanySourceUploadControls(); @@ -1103,11 +1211,11 @@ const roomType = $("[data-review-room]", row)?.value.trim() || ""; const quantity = Number($("[data-review-quantity]", row)?.value); if (!roomType) { - setCompanySourceError("请填写房型后再保存"); + setCompanySourceError(I18N?.t("company.fill_room_type") || "请填写房型后再保存"); return; } if (!Number.isInteger(quantity) || quantity < 1 || quantity > 9999) { - setCompanySourceError("房间数量必须是 1 至 9999 的整数"); + setCompanySourceError(I18N?.t("company.quantity_invalid") || "房间数量必须是 1 至 9999 的整数"); return; } state.companyReviewMutating = true; @@ -1125,12 +1233,12 @@ }); state.companyReviewMutating = false; await loadCompanyDraft(); - showToast("房型记录已保存"); + showToast(I18N?.t("company.review_saved") || "房型记录已保存"); } catch (error) { state.companyReviewMutating = false; renderCompanyDraft(state.companySourceDraft); - setCompanySourceError(error.message || "房型记录未能保存"); - showToast(error.message || "房型记录未能保存", true); + setCompanySourceError(error.message || I18N?.t("company.review_save_failed") || "房型记录未能保存"); + showToast(error.message || I18N?.t("company.review_save_failed") || "房型记录未能保存", true); } } @@ -1147,8 +1255,8 @@ cancel.disabled = busy; submit.disabled = busy; submit.textContent = busy - ? (request?.kind === "discard" ? "正在放弃" : "正在删除") - : (request?.confirmLabel || "确认删除"); + ? (request?.kind === "discard" ? (I18N?.t("company.discarding") || "正在放弃") : (I18N?.t("company.deleting") || "正在删除")) + : (request?.confirmLabel || I18N?.t("company.confirm_delete") || "确认删除"); } function resetCompanyReviewConfirm(restoreFocus = true) { @@ -1179,11 +1287,11 @@ if (request.kind === "discard") { state.companyReviewConfirmRequest = { ...request, - confirmLabel: "确认放弃", + confirmLabel: I18N?.t("company.confirm_discard") || "确认放弃", }; - $("#company-review-confirm-title").textContent = "放弃本次提取"; - $("#company-review-confirm-description").textContent = "确认放弃本次提取?"; - $("#company-review-confirm-note").textContent = "当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。"; + $("#company-review-confirm-title").textContent = I18N?.t("company.confirm_discard_title") || "放弃本次提取"; + $("#company-review-confirm-description").textContent = I18N?.t("company.confirm_discard_description") || "确认放弃本次提取?"; + $("#company-review-confirm-note").textContent = I18N?.t("company.confirm_discard_note") || "当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。"; } else { const itemIds = [...new Set(request.itemIds || [])]; if (!itemIds.length) return; @@ -1191,13 +1299,13 @@ state.companyReviewConfirmRequest = { ...request, itemIds, - confirmLabel: single ? "确认删除" : `删除 ${formatInteger(itemIds.length)} 条`, + confirmLabel: single ? (I18N?.t("company.confirm_delete") || "确认删除") : (I18N?.t("company.delete_count", { count: formatInteger(itemIds.length) }) || `删除 ${formatInteger(itemIds.length)} 条`), }; - $("#company-review-confirm-title").textContent = "删除房型记录"; + $("#company-review-confirm-title").textContent = I18N?.t("company.confirm_delete_title") || "删除房型记录"; $("#company-review-confirm-description").textContent = single - ? `确认删除 ${request.tourCode || "这条记录"} 的这条房型记录?` - : `确认删除已选择的 ${formatInteger(itemIds.length)} 条房型记录?`; - $("#company-review-confirm-note").textContent = "此操作只影响本次提取结果,确认启用前原数据源不会改变。"; + ? (I18N?.t("company.confirm_single_delete", { tourCode: request.tourCode || I18N?.t("company.this_record") || "这条记录" }) || `确认删除 ${request.tourCode || "这条记录"} 的这条房型记录?`) + : (I18N?.t("company.confirm_multi_delete", { count: formatInteger(itemIds.length) }) || `确认删除已选择的 ${formatInteger(itemIds.length)} 条房型记录?`); + $("#company-review-confirm-note").textContent = I18N?.t("company.confirm_delete_note") || "此操作只影响本次提取结果,确认启用前原数据源不会改变。"; } setCompanyReviewConfirmBusy(false); if (typeof dialog.showModal === "function") dialog.showModal(); @@ -1210,7 +1318,7 @@ const row = button.closest("[data-review-item-id]"); const itemId = Number(row?.dataset.reviewItemId); if (!Number.isInteger(itemId) || itemId <= 0) return; - const tourCode = $(".company-review-tour", row)?.textContent?.trim() || "这条记录"; + const tourCode = $(".company-review-tour", row)?.textContent?.trim() || I18N?.t("company.this_record") || "这条记录"; openCompanyReviewConfirm({ kind: "items", itemIds: [itemId], tourCode, trigger: button }); } @@ -1251,7 +1359,7 @@ updateCompanySourceUploadControls(); updateCompanyReportControls(); closeCompanyReviewConfirm({ force: true, restoreFocus: false }); - showToast("本次提取已放弃,原数据源未改变"); + showToast(I18N?.t("company.review_discarded") || "本次提取已放弃,原数据源未改变"); return; } @@ -1265,12 +1373,14 @@ state.companyReviewMutating = false; await loadCompanyDraft(); closeCompanyReviewConfirm({ force: true, restoreFocus: false }); - showToast(deletedCount === 1 ? "房型记录已删除" : `已删除 ${formatInteger(deletedCount)} 条房型记录`); + showToast(deletedCount === 1 + ? (I18N?.t("company.review_deleted") || "房型记录已删除") + : (I18N?.t("company.deleted_count", { count: formatInteger(deletedCount) }) || `已删除 ${formatInteger(deletedCount)} 条房型记录`)); } catch (error) { state.companyReviewMutating = false; renderCompanyDraft(state.companySourceDraft); setCompanyReviewConfirmBusy(false); - const message = error.message || (request.kind === "discard" ? "本次提取未能放弃" : "房型记录未能删除"); + const message = error.message || (request.kind === "discard" ? (I18N?.t("company.review_discard_failed") || "本次提取未能放弃") : (I18N?.t("company.review_delete_failed") || "房型记录未能删除")); const errorNode = $("#company-review-confirm-error"); errorNode.textContent = message; errorNode.hidden = false; @@ -1298,12 +1408,12 @@ clearCompanyReviewSelection(); renderCompanyDraft(null); updateCompanyReportControls(); - showToast("人工核对完成,Booking 数据源已启用"); + showToast(I18N?.t("company.review_activated") || "人工核对完成,Booking 数据源已启用"); } catch (error) { state.companyReviewMutating = false; renderCompanyDraft(state.companySourceDraft); - setCompanySourceError(error.message || "Booking 数据源未能启用"); - showToast(error.message || "Booking 数据源未能启用", true); + setCompanySourceError(error.message || I18N?.t("company.source_activate_failed") || "Booking 数据源未能启用"); + showToast(error.message || I18N?.t("company.source_activate_failed") || "Booking 数据源未能启用", true); } } @@ -1317,7 +1427,7 @@ if (period === "01-10") return "01-10"; if (period === "11-20") return "11-20"; const monthEnd = companyMonthEndDay(reportMonth); - return monthEnd ? `21-${monthEnd}` : "21-月末"; + return monthEnd ? `21-${monthEnd}` : (I18N?.t("company.month_end") || "21-月末"); } function companyPeriodDisplayLabel(reportMonth, period) { @@ -1354,10 +1464,11 @@ function monthLabel(value) { const match = /^(\d{4})-(\d{2})$/.exec(value || ""); - return match ? `${match[1]}年${match[2]}月` : "未选择"; + return match ? (I18N?.formatMonth(value) || `${match[1]}年${match[2]}月`) : (I18N?.t("common.year_month_unselected") || "未选择"); } function formatDurationSeconds(value) { + if (I18N) return I18N.formatDurationSeconds(value); const seconds = Number(value); if (!Number.isFinite(seconds)) return "暂无"; if (seconds < 60) return `${Math.max(1, Math.round(seconds))} 秒`; @@ -1396,49 +1507,52 @@ const releaseNode = $('[data-company-period-release]', button); const statusNode = $('[data-company-period-state]', button); if (rangeNode) rangeNode.textContent = displayRange; - if (releaseNode) releaseNode.textContent = completeDate ? `周期结束:${completeDate} 00:00(曼谷)` : "选择月份后显示周期结束时间"; + if (releaseNode) releaseNode.textContent = completeDate + ? (I18N?.t("period.release", { date: formatDate(completeDate) }) || `周期结束:${completeDate} 00:00(曼谷)`) + : (I18N?.t("company.select_month") || "选择月份"); statusNode.className = "company-period-state"; if (!monthValid) { - statusNode.textContent = "选择月份"; + statusNode.textContent = I18N?.t("company.select_month") || "选择月份"; } else if (reviewOpen) { statusNode.classList.add("is-unavailable"); - statusNode.textContent = "先完成核对"; + statusNode.textContent = I18N?.t("company.review_open") || "先完成核对"; } else if (!sourceReady) { statusNode.classList.add("is-unavailable"); - statusNode.textContent = "先上传 Excel"; + statusNode.textContent = I18N?.t("company.source_required") || "先上传 Excel"; } else if (!state.companyReportsReady) { statusNode.classList.add("is-unavailable"); - statusNode.textContent = "服务未就绪"; + statusNode.textContent = I18N?.t("company.service_required") || "服务未就绪"; } else if (monthFuture) { statusNode.classList.add("is-unavailable"); - statusNode.textContent = "未来月份"; + statusNode.textContent = I18N?.t("company.future_month") || "未来月份"; } else if (!periodComplete) { statusNode.classList.add("is-in-progress"); - statusNode.textContent = "周期未结束"; + statusNode.textContent = I18N?.t("company.period_not_finished") || "周期未结束"; } else { statusNode.classList.add("is-complete"); - statusNode.textContent = "周期已结束"; + statusNode.textContent = I18N?.t("company.period_finished") || "周期已结束"; } button.disabled = active || !state.companyReportsReady || !sourceReady || !monthValid || monthFuture; button.setAttribute("aria-busy", String(state.companyReportSubmittingPeriod === period)); - if (!monthValid) button.title = "请先选择有效的报表月份"; - else if (reviewOpen) button.title = "请先确认或放弃当前 Excel 提取结果"; - else if (!sourceReady) button.title = "请先提取并启用 Excel 报表"; - else if (!state.companyReportsReady) button.title = "公司渠道明细服务尚未就绪"; - else if (monthFuture) button.title = "未来报表月份暂不可生成"; - else if (!periodComplete) button.title = `周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 ${completeDate} 00:00`; + if (!monthValid) button.title = I18N?.t("company.no_valid_month") || "请先选择有效的报表月份"; + else if (reviewOpen) button.title = I18N?.t("company.review_open") || "请先确认或放弃当前 Excel 提取结果"; + else if (!sourceReady) button.title = I18N?.t("company.source_required") || "请先提取并启用 Excel 报表"; + else if (!state.companyReportsReady) button.title = I18N?.t("company.service_required") || "公司渠道明细服务尚未就绪"; + else if (monthFuture) button.title = I18N?.t("company.future_month") || "未来报表月份暂不可生成"; + else if (!periodComplete) button.title = I18N?.t("period.release_hint", { date: formatDate(completeDate) }) || `周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 ${completeDate} 00:00`; else button.removeAttribute("title"); }); } function companyStateLabel(value) { - return { - queued: "已排队", - running: "生成中", - succeeded: "全部成功", - partial_failure: "部分成功", - failed: "未完成", - }[value] || "未知"; + const key = { + queued: "company.queued", + running: "company.generating", + succeeded: "company.all_success", + partial_failure: "company.partial_success", + failed: "company.incomplete", + }[value]; + return I18N?.t(key || "common.unknown") || "未知"; } function companyStateStyle(value) { @@ -1449,28 +1563,29 @@ } function companyCutoffLabel(job) { - if (job.period === "01-10") return "截至 10 日"; - if (job.period === "11-20") return "截至 20 日"; - return "截至月末"; + if (job.period === "01-10") return I18N?.t("company.cutoff_10") || "截至 10 日"; + if (job.period === "11-20") return I18N?.t("company.cutoff_20") || "截至 20 日"; + return I18N?.t("company.cutoff_month_end") || "截至月末"; } function companyProblemLabel(code) { - return { - COMPANY_REPORT_MULTI_PRICE_REVIEW: "同房型存在不同静态价格,需人工复核", - COMPANY_REPORT_SOURCE_VERSION_MISSING: "数据源版本缺失", - COMPANY_REPORT_GROUP_CODE_MISSING: "Group Code 缺失", - COMPANY_REPORT_GROUP_CODE_NOT_FOUND: "Group Code 未找到", - COMPANY_REPORT_BOOKING_PARSE_FAILED: "预订解析异常", - COMPANY_REPORT_ROOM_ITEMS_MISSING: "房型明细缺失", - COMPANY_REPORT_STAY_DATE_INVALID: "住宿日期异常", - COMPANY_REPORT_NIGHTS_CONFLICT: "间夜数据冲突", - COMPANY_REPORT_TOTAL_PRICE_INVALID: "静态价格异常", - COMPANY_REPORT_OUTPUT_VALIDATION_FAILED: "Excel 输出校验失败", - COMPANY_REPORT_PUBLISH_FAILED: "正式表格发布失败", - COMPANY_REPORT_INTERNAL_ERROR: "处理服务异常", - COMPANY_REPORT_EXECUTION_FAILED: "任务执行未完成", - COMPANY_REPORT_JOB_INTERRUPTED: "服务重启中断任务", - }[code] || "需复核处理结果"; + const key = { + COMPANY_REPORT_MULTI_PRICE_REVIEW: "problem.multi_price", + COMPANY_REPORT_SOURCE_VERSION_MISSING: "problem.source_missing", + COMPANY_REPORT_GROUP_CODE_MISSING: "problem.group_missing", + COMPANY_REPORT_GROUP_CODE_NOT_FOUND: "problem.group_not_found", + COMPANY_REPORT_BOOKING_PARSE_FAILED: "problem.booking_parse", + COMPANY_REPORT_ROOM_ITEMS_MISSING: "problem.room_items_missing", + COMPANY_REPORT_STAY_DATE_INVALID: "problem.stay_date", + COMPANY_REPORT_NIGHTS_CONFLICT: "problem.nights_conflict", + COMPANY_REPORT_TOTAL_PRICE_INVALID: "problem.total_price", + COMPANY_REPORT_OUTPUT_VALIDATION_FAILED: "problem.output_validation", + COMPANY_REPORT_PUBLISH_FAILED: "problem.publish_failed", + COMPANY_REPORT_INTERNAL_ERROR: "problem.internal", + COMPANY_REPORT_EXECUTION_FAILED: "problem.execution", + COMPANY_REPORT_JOB_INTERRUPTED: "problem.interrupted", + }[code]; + return I18N?.t(key || "problem.review_result") || "需复核处理结果"; } function uniqueCompanyProblems(problems) { @@ -1503,17 +1618,17 @@ function companyPeriodCell(job, result, period) { const range = companyPeriodRange(job.report_month, period); if (!companyPeriodIncluded(job.period, period)) { - return `待生成`; + return `${escapeHtml(I18N?.t("company.period_pending") || "待生成")}`; } if (!result) { - return `${isCompanyReportActive(job) ? "等待生成" : "无结果"}`; + return `${escapeHtml(isCompanyReportActive(job) ? (I18N?.t("company.period_waiting") || "等待生成") : (I18N?.t("company.period_none") || "无结果"))}`; } if (result.status === "failed") { - return `未生成`; + return `${escapeHtml(I18N?.t("company.period_not_generated") || "未生成")}`; } const countKey = companyPeriodCountKey(job.report_month, period); const count = Object.prototype.hasOwnProperty.call(result.period_row_counts || {}, countKey) ? result.period_row_counts[countKey] : null; - return `${count === null ? "未记录" : `✓ ${formatInteger(count)} 条`}`; + return `${count === null ? (I18N?.t("company.period_not_recorded") || "未记录") : (I18N?.t("company.period_rows", { count: formatInteger(count) }) || `✓ ${formatInteger(count)} 条`)}`; } function renderCompanyResultRows(job) { @@ -1522,29 +1637,34 @@ const result = byCompany.get(company); const warnings = result?.warnings || []; const rowStyle = result?.status === "failed" ? "is-failed" : warnings.length ? "is-review" : ""; - let resultStatus = isCompanyReportActive(job) ? "等待生成" : "无结果"; + let resultStatus = isCompanyReportActive(job) ? (I18N?.t("company.waiting_generation") || "等待生成") : (I18N?.t("company.period_none") || "无结果"); let resultStyle = isCompanyReportActive(job) ? "running" : "failed"; if (result?.status === "success") { - resultStatus = warnings.length ? "成功 · 待复核" : "成功"; + resultStatus = warnings.length ? (I18N?.t("company.success_review") || "成功 · 待复核") : (I18N?.t("company.success") || "成功"); resultStyle = warnings.length ? "review" : "success"; } else if (result?.status === "failed") { - resultStatus = "失败"; + resultStatus = I18N?.t("company.failed") || "失败"; resultStyle = "failed"; } const problems = companyProblemsForResult(result); const problemHtml = problems.length - ? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || "暂无")}`).join("") - : '暂无'; + ? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || I18N?.t("company.review_no_record") || "暂无")}`).join("") + : `${escapeHtml(I18N?.t("company.review_no_record") || "暂无")}`; const download = result?.download_url?.startsWith("/api/company-reports/") - ? `下载` - : `${isCompanyReportActive(job) ? "待生成" : "暂无"}`; + ? `${escapeHtml(I18N?.t("common.download") || "下载")}` + : `${escapeHtml(isCompanyReportActive(job) ? (I18N?.t("company.period_pending") || "待生成") : (I18N?.t("company.review_no_record") || "暂无"))}`; + const companyLabel = I18N?.t("common.company") || "公司"; + const totalLabel = I18N?.t("common.total") || "合计"; + const statusLabel = I18N?.t("common.status") || "状态"; + const problemLabel = I18N?.t("company.warning_count") || "复核 / 异常"; + const generatedLabel = I18N?.t("common.generated_at") || "生成时间"; return ` - ${escapeHtml(company)} + ${escapeHtml(company)} ${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")} - ${result ? formatInteger(result.row_count) : "暂无"} - ${escapeHtml(resultStatus)} -
${problemHtml}
- ${formatDate(job.finished_at || job.created_at, true)} + ${result ? formatInteger(result.row_count) : escapeHtml(I18N?.t("company.review_no_record") || "暂无")} + ${escapeHtml(resultStatus)} +
${problemHtml}
+ ${formatDate(job.finished_at || job.created_at, true)} ${download} `; }).join(""); @@ -1555,11 +1675,11 @@ $("#company-report-job-panel").hidden = false; const terminal = ["succeeded", "partial_failure", "failed"].includes(job.state); const kicker = $("#company-report-job-kicker"); - kicker.textContent = "TASK STATUS"; + kicker.textContent = I18N?.t("company.task_status") || "TASK STATUS"; kicker.hidden = terminal; $("#company-report-job-title").textContent = `${monthLabel(job.report_month)} · ${companyCutoffLabel(job)}`; const message = $("#company-report-job-message"); - const messageText = job.state === "failed" && !job.failure_code ? "" : (job.message || ""); + const messageText = job.state === "failed" && !job.failure_code ? "" : (I18N?.errorMessage(job.failure_code, job.message) || I18N?.text(job.message || "") || job.message || ""); message.textContent = messageText; message.hidden = !messageText; const status = $("#company-report-job-state"); @@ -1567,14 +1687,14 @@ status.textContent = companyStateLabel(job.state); $("#company-report-success-count").textContent = `${companySuccessCount(job)} / 5`; const percent = Math.max(0, Math.min(100, Number(job.progress?.percent) || 0)); - $("#company-report-progress-label").textContent = job.progress?.label || "等待生成"; + $("#company-report-progress-label").textContent = I18N?.text(job.progress?.label || "等待生成") || (job.progress?.label || I18N?.t("company.report_waiting") || "等待生成"); $("#company-report-progress-percent").textContent = `${percent}%`; $("#company-report-progress-track").setAttribute("aria-valuenow", String(percent)); $("#company-report-progress-bar").style.transform = `scaleX(${percent / 100})`; $("#company-report-job-month").textContent = monthLabel(job.report_month); - $("#company-report-job-as-of").textContent = job.as_of_date || "暂无"; + $("#company-report-job-as-of").textContent = job.as_of_date ? formatDate(job.as_of_date) : (I18N?.t("company.review_no_record") || "暂无"); $("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds); - $("#company-report-warning-count").textContent = `${companyWarningCount(job)} 项`; + $("#company-report-warning-count").textContent = I18N?.t("company.report_warning_count", { count: formatInteger(companyWarningCount(job)) }) || `${companyWarningCount(job)} 项`; $("#company-result-month-end-header").textContent = companyPeriodRange(job.report_month, "21-month-end"); renderCompanyResultRows(job); localStorage.setItem("arr:last-company-report-job", job.job_id); @@ -1586,17 +1706,17 @@ state.companyReportJobs = jobs; const body = $("#company-history-rows"); if (!jobs.length) { - body.innerHTML = '该月份还没有公司渠道明细任务'; + body.innerHTML = `${historyEmptyMarkup("company", "history.company_empty", "暂无公司渠道明细任务")}`; return; } body.innerHTML = jobs.map((job) => ` ${formatDate(job.created_at, true)} - ${escapeHtml(job.report_month || "暂无")} + ${escapeHtml(job.report_month ? monthLabel(job.report_month) : (I18N?.t("company.review_no_record") || "暂无"))} ${escapeHtml(companyCutoffLabel(job))} ${escapeHtml(companyStateLabel(job.state))} ${companySuccessCount(job)} / 5 ${companyWarningCount(job)} - + `).join(""); $$('[data-company-job-id]', body).forEach((button) => button.addEventListener("click", () => loadCompanyReportJob(button.dataset.companyJobId, true))); } @@ -1612,7 +1732,7 @@ } state.companyHistoryLoading = true; renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, true); - const month = $("#company-report-month").value || localMonth(); + const month = historyMonth("company"); try { const encodedMonth = encodeURIComponent(month); let page = readPage(await api( @@ -1634,7 +1754,7 @@ if (target) await loadCompanyReportJob(target.job_id); } } catch (error) { - $("#company-history-rows").innerHTML = '任务记录暂时无法读取'; + $("#company-history-rows").innerHTML = `${escapeHtml(I18N?.t("company.draft_error") || "任务记录暂时无法读取")}`; if (restore) showToast(error.message, true); } finally { state.companyHistoryLoading = false; @@ -1670,7 +1790,7 @@ dialog.setAttribute("aria-busy", String(busy)); cancel.disabled = busy; submit.disabled = busy; - submit.textContent = busy ? "正在生成" : "确认生成"; + submit.textContent = busy ? (I18N?.t("company.generating") || "正在生成") : (I18N?.t("company.confirm_generate") || "确认生成"); } function resetCompanyReportConfirm(restoreFocus = true) { @@ -1701,12 +1821,12 @@ if (!reportMonth || reportMonth > localMonth()) return; const dialog = $("#company-report-confirm-dialog"); state.companyReportConfirmRequest = { period, reportMonth, trigger }; - $("#company-report-confirm-description").textContent = `${monthLabel(reportMonth)},${companyPeriodRange(reportMonth, period)}`; + $("#company-report-confirm-description").textContent = I18N?.t("company.at_period", { month: monthLabel(reportMonth), period: companyPeriodRange(reportMonth, period) }) || `${monthLabel(reportMonth)},${companyPeriodRange(reportMonth, period)}`; const completeDate = companyPeriodCompleteDate(reportMonth, period); const periodComplete = Boolean(completeDate) && localDate() >= completeDate; $("#company-report-confirm-note").textContent = periodComplete - ? "将一次生成五家公司的正式 Excel。" - : "周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。"; + ? (I18N?.t("company.generate_five") || "将一次生成五家公司的正式 Excel。") + : (I18N?.t("company.generate_early") || "周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。"); setCompanyReportConfirmBusy(false); updateCompanyReportControls(); if (typeof dialog.showModal === "function") dialog.showModal(); @@ -1728,14 +1848,16 @@ body: JSON.stringify({ report_month: request.reportMonth, period: request.period }), }); localStorage.setItem("arr:last-company-report-job", created.job_id); + state.companyHistoryMonth = request.reportMonth; state.companyReportsOffset = 0; + renderHistoryMonthControl("company"); closeCompanyReportConfirm({ force: true, restoreFocus: false }); renderCompanyReportJob(created); $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" }); - showToast("五家公司渠道明细任务已提交"); + showToast(I18N?.t("company.job_submitted") || "五家公司渠道明细任务已提交"); await loadCompanyReportJob(created.job_id); } catch (error) { - const message = error.message || "公司渠道明细任务未能提交"; + const message = error.message || I18N?.t("company.job_submit_failed") || "公司渠道明细任务未能提交"; const errorNode = $("#company-report-confirm-error"); errorNode.textContent = message; errorNode.hidden = false; @@ -1756,7 +1878,27 @@ setCompanyReportError(); await loadHealth(); await Promise.all([loadCompanySource(true), loadCompanyDraft(true), loadCompanyReportHistory(true)]); - showToast("公司渠道明细任务已刷新"); + showToast(I18N?.t("company.jobs_refreshed") || "公司渠道明细任务已刷新"); + } + + function refreshLocalizedView() { + renderJobs(state.jobs); + renderPagination("jobs", state.jobsTotal, state.jobsOffset, state.jobsLoading); + renderMonthly(state.monthlyRuns); + renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, state.monthlyLoading); + if (state.analytics) renderAnalytics(state.analytics); + else resetAnalytics(I18N?.t("bi.empty") || "暂无渠道与房型数据"); + if (state.companySourceDraft?.summary) renderCompanyDraft(state.companySourceDraft); + else renderCompanyDraft(null); + renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, state.companyHistoryLoading); + renderCompanyReportHistory(state.companyReportJobs); + if (state.companyReportCurrentJob) renderCompanyReportJob(state.companyReportCurrentJob); + if (state.jobTrace) renderTrace(state.jobTrace); + else if (!state.selectedJobId) renderTracePlaceholder(I18N?.t("task.waiting_upload") || "等待上传 ARR.XML", I18N?.t("task.after_create") || "新任务创建后,这里会显示服务端保存的全流程日志。"); + updateCompanySourceUploadControls(); + updateCompanyReportControls(); + I18N?.translateDom(); + renderHistoryMonthControls(); } async function changeHistoryPage(totalKey, offsetKey, direction, loader) { @@ -1766,13 +1908,62 @@ await loader(); } + async function setHistoryMonth(scope, value) { + const config = HISTORY_SCOPES[scope]; + if (!config || !validMonth(value) || value > localMonth()) { + renderHistoryMonthControl(scope); + return; + } + if (state[config.stateKey] === value) return; + state[config.stateKey] = value; + if (scope === "jobs") { + state.jobsOffset = 0; + state.jobsTotal = 0; + state.selectedJobId = ""; + state.jobTrace = null; + clearTracePoll(); + renderHistoryMonthControl(scope); + await loadJobs(true, false); + return; + } + if (scope === "monthly") { + state.monthlyOffset = 0; + state.monthlyTotal = 0; + state.monthlyLoaded = false; + clearMonthlyPoll(); + renderHistoryMonthControl(scope); + await loadMonthly(true, false); + return; + } + state.companyReportsOffset = 0; + state.companyReportsTotal = 0; + renderHistoryMonthControl(scope); + await loadCompanyReportHistory(false); + } + + function bindHistoryMonthControls() { + Object.keys(HISTORY_SCOPES).forEach((scope) => { + $(`#${scope}-history-month`).addEventListener("change", (event) => setHistoryMonth(scope, event.currentTarget.value)); + $(`#${scope}-month-previous`).addEventListener("click", () => setHistoryMonth(scope, shiftMonth(historyMonth(scope), -1))); + $(`#${scope}-month-next`).addEventListener("click", () => setHistoryMonth(scope, shiftMonth(historyMonth(scope), 1))); + $(`#${scope}-month-current`).addEventListener("click", () => setHistoryMonth(scope, localMonth())); + }); + document.addEventListener("click", (event) => { + const jump = event.target.closest("[data-history-jump][data-history-month]"); + if (jump) setHistoryMonth(jump.dataset.historyJump, jump.dataset.historyMonth); + }); + } + function bindEvents() { + bindHistoryMonthControls(); $$("[data-tab]").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab))); $("#xml-file").addEventListener("change", (event) => { if (state.uploadInFlight) return; state.selectedFile = event.target.files?.[0] || null; if (!state.uploadInFlight) resetUploadProgress(); - $("#selected-file").textContent = state.selectedFile ? `${state.selectedFile.name} · ${formatInteger(state.selectedFile.size / 1024)} KB` : "尚未选择文件"; + $("#selected-file").textContent = state.selectedFile + ? `${state.selectedFile.name} · ${formatInteger(state.selectedFile.size / 1024)} KB` + : (I18N?.t("upload.file_not_selected") || "尚未选择文件"); $("#upload-button").disabled = !state.selectedFile || !state.health.processing_ready; }); const dropzone = $("#dropzone"); @@ -1885,7 +2076,10 @@ event.preventDefault(); selectJob(row.dataset.jobId, { showLog: true }); }); - $("#task-log-trigger").addEventListener("click", openTaskLog); + $("#task-log-trigger").addEventListener("click", (event) => { + event.preventDefault(); + openTaskLog(); + }); $("#close-task-log").addEventListener("click", closeTaskLog); $("#task-log-dialog").addEventListener("close", clearTracePoll); $("#task-log-dialog").addEventListener("click", (event) => { @@ -1925,20 +2119,18 @@ }); $("#refresh-trace").addEventListener("click", async () => { await loadJobTrace(true); - showToast("任务日志已刷新"); + showToast(I18N?.t("task.refreshed") || "任务日志已刷新"); }); $("#copy-trace").addEventListener("click", copyAllTraceLogs); - $("#logout-button").addEventListener("click", logout); + $("#logout-button").addEventListener("click", (event) => { + event.preventDefault(); + logout(); + }); $("#bi-month").addEventListener("change", () => loadAnalytics(true)); $("#refresh-company-reports").addEventListener("click", refreshCompanyReports); $("#company-report-month").addEventListener("change", async () => { - state.companyReportCurrentJob = null; - state.companyReportsOffset = 0; - state.companyReportsTotal = 0; - $("#company-report-job-panel").hidden = true; updateCompanyReportControls(); updateCompanySourceUploadControls(); - await loadCompanyReportHistory(false); }); $$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod, button))); document.addEventListener("visibilitychange", () => { @@ -1950,6 +2142,7 @@ if ($("#panel-monthly")?.classList.contains("is-active")) loadMonthly(false, true); } }); + document.addEventListener("arr:locale-change", refreshLocalizedView); window.addEventListener("hashchange", () => activateTab(location.hash.slice(1), false)); } @@ -1961,9 +2154,10 @@ try { await initSession(); } catch (_) { - showToast("页面会话初始化失败", true); + showToast(I18N?.t("auth.session_init_failed") || "页面会话初始化失败", true); return; } + await loadHistoryMonths(); await loadHealth(); await Promise.all([ loadJobs(), diff --git a/arr_web/static/h5.css b/arr_web/static/h5.css index 5ac022e..9d0c1f8 100644 --- a/arr_web/static/h5.css +++ b/arr_web/static/h5.css @@ -10,19 +10,22 @@ } * { box-sizing: border-box; } -body { margin: 0; min-width: 320px; color: var(--ink); background: var(--bg); font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; } +body { margin: 0; min-width: 320px; color: var(--ink); background: var(--bg); font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans Thai", "Leelawadee UI", Tahoma, "PingFang SC", "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; } button, select { font: inherit; } .mobile-header { height: 58px; padding: env(safe-area-inset-top) 18px 0; display: flex; align-items: center; justify-content: space-between; color: #fff; background: #172033; } .brand { display: flex; align-items: center; font-size: 17px; font-weight: 750; letter-spacing: -.03em; } .brand-name { display: block; } .mobile-header-actions { display: flex; align-items: center; gap: 11px; } +.mobile-header .locale-switcher select { min-height: 30px; max-width: 72px; padding: 0 19px 0 7px; border: 1px solid rgba(255, 255, 255, .22); border-radius: 8px; color: #eef3f9; background: rgba(255, 255, 255, .08); font-size: 10px; } .connection { display: inline-flex; align-items: center; gap: 7px; color: #c8d0dd; font-size: 11px; } .connection i { width: 7px; height: 7px; border-radius: 50%; background: #f79009; } .connection.ready i { background: #35c895; } .connection.error i { background: #f97066; } -.mobile-logout { min-height: 32px; padding: 0 10px; border: 1px solid rgba(255, 255, 255, .18); border-radius: 8px; color: #e8edf5; background: rgba(255, 255, 255, .07); font-size: 10px; font-weight: 700; cursor: pointer; } -.mobile-logout:disabled { cursor: wait; opacity: .5; } +.mobile-logout { display: inline-flex; align-items: center; min-height: 24px; padding: 0 1px; border-bottom: 1px solid transparent; color: #c8d0dd; background: transparent; font-size: 10px; font-weight: 650; line-height: 1.2; text-decoration: none; cursor: pointer; transition: color .16s ease, border-color .16s ease, opacity .16s ease, transform .16s ease; } +.mobile-logout:hover { color: #fff; border-bottom-color: rgba(255, 255, 255, .48); } +.mobile-logout:active { transform: translateY(1px); } +.mobile-logout[aria-disabled="true"] { cursor: wait; opacity: .5; pointer-events: none; } main { width: min(100% - 24px, 620px); margin: 0 auto; padding: 23px 0 calc(34px + env(safe-area-inset-bottom)); } .hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 4px 19px; } @@ -31,6 +34,9 @@ main { width: min(100% - 24px, 620px); margin: 0 auto; padding: 23px 0 calc(34px .hero small { color: var(--muted); font-size: 10px; } .hero label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 9px; font-weight: 700; } .hero select { min-height: 38px; max-width: 122px; padding: 0 29px 0 10px; border: 1px solid #d1d9e6; border-radius: 9px; color: var(--ink); background: #fff; outline: none; } +.bi-hero { justify-content: flex-end; margin-bottom: 12px; } +.bi-hero label { gap: 2px; font-size: 8px; } +.bi-hero select { width: 92px; min-height: 26px; max-width: 92px; padding: 0 20px 0 7px; border-radius: 7px; font-size: 9px; } .kpi-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } .kpi-grid article { min-height: 105px; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: 0 8px 24px rgba(16, 24, 40, .045); } @@ -43,6 +49,7 @@ main { width: min(100% - 24px, 620px); margin: 0 auto; padding: 23px 0 calc(34px .mobile-card { margin-top: 12px; overflow: hidden; border: 1px solid var(--line); border-radius: 17px; background: var(--surface); box-shadow: 0 9px 26px rgba(16, 24, 40, .05); } .card-title { padding: 18px 18px 0; } .card-title h2 { margin: 3px 0 0; font-size: 16px; letter-spacing: -.025em; } +.card-title .data-range { margin: 6px 0 0; color: var(--muted); font-size: 9px; letter-spacing: 0; font-variant-numeric: tabular-nums; } .mobile-donut-row { padding: 22px 18px 17px; display: grid; grid-template-columns: 138px 1fr; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); } .mobile-donut { width: 138px; height: 138px; display: grid; place-items: center; border-radius: 50%; background: #d8e0ec; } .mobile-donut > div { width: 86px; height: 86px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; } @@ -78,6 +85,13 @@ main { width: min(100% - 24px, 620px); margin: 0 auto; padding: 23px 0 calc(34px :focus-visible { outline: 3px solid rgba(37, 99, 235, .35); outline-offset: 2px; } +@media (max-width: 380px) { + .mobile-header { padding-left: 12px; padding-right: 12px; } + .mobile-header-actions { gap: 6px; } + .mobile-header .locale-switcher select { max-width: 58px; padding-left: 5px; padding-right: 15px; font-size: 9px; } + .connection { max-width: 76px; overflow: hidden; white-space: nowrap; font-size: 10px; } +} + @media (max-width: 370px) { .mobile-donut-row { grid-template-columns: 118px 1fr; gap: 12px; } .mobile-donut { width: 118px; height: 118px; } diff --git a/arr_web/static/h5.html b/arr_web/static/h5.html index ce1f297..327d370 100644 --- a/arr_web/static/h5.html +++ b/arr_web/static/h5.html @@ -6,36 +6,40 @@ 渠道BI · ARR Report +
ARR Report
+ 正在连接 - +
-
-
-

CHANNEL PERFORMANCE

-

渠道BI

- 基于已发布月报快照 -
+
售出房数NO. OF ROOM
-
销售金额TOTAL PRICE
+
销售金额销售金额合计
间夜房数 × 晚数
公司数月报子表
-

TOTAL PRICE

各公司本月销售情况

+

各公司本月销售情况

数据范围:—

销售金额
diff --git a/arr_web/static/h5.js b/arr_web/static/h5.js index 06426e8..2045312 100644 --- a/arr_web/static/h5.js +++ b/arr_web/static/h5.js @@ -3,8 +3,10 @@ const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"]; const BUSINESS_TIME_ZONE = "Asia/Bangkok"; + const I18N = window.ARRI18n; const $ = (selector) => document.querySelector(selector); let csrf = ""; + let lastData = null; function escapeHtml(value) { return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); @@ -20,12 +22,12 @@ } function integer(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 money(value, compact = true) { + if (I18N) return I18N.formatMoney(value, compact); const amount = Number(value || 0); - if (compact && amount >= 10000) return `¥${(amount / 10000).toFixed(amount >= 100000 ? 1 : 2)}万`; return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount); } @@ -39,26 +41,42 @@ const response = await fetch(path, { ...options, 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(I18N?.t("error.unrecognized_response") || "服务返回了无法识别的结果"); + } + if (!response.ok || !payload.ok) { + const error = payload?.error || {}; + throw new Error(I18N?.errorMessage(error.code, error.message) || error.message || I18N?.t("error.database_read_unavailable") || "数据读取失败"); } - const payload = await response.json(); - if (!response.ok || !payload.ok) throw new Error(payload?.error?.message || "数据读取失败"); return payload.data; } function notify(message) { const node = $("#h5-message"); - node.textContent = message; + node.textContent = I18N?.text(message) || message; node.classList.add("visible"); window.setTimeout(() => node.classList.remove("visible"), 3000); } function reset(message) { ["#h5-rooms", "#h5-revenue", "#h5-nights", "#h5-companies", "#h5-donut-total"].forEach((id) => { $(id).textContent = "—"; }); - $("#h5-ranking").innerHTML = `

${escapeHtml(message)}

`; - $("#h5-matrix").innerHTML = `

${escapeHtml(message)}

`; + const copy = I18N?.text(message) || message; + $("#h5-ranking").innerHTML = `

${escapeHtml(copy)}

`; + $("#h5-matrix").innerHTML = `

${escapeHtml(copy)}

`; $("#h5-legend").innerHTML = ""; $("#h5-donut").style.background = "#d8e0ec"; + $("#h5-data-range").textContent = I18N?.t("bi.data_range", { start: "—", end: "—" }) || "数据范围:—"; + } + + function dataRange(data) { + const start = data.min_arrival_date ? (I18N?.formatDate(data.min_arrival_date) || data.min_arrival_date) : "—"; + const end = data.max_arrival_date ? (I18N?.formatDate(data.max_arrival_date) || data.max_arrival_date) : "—"; + return I18N?.t("bi.data_range", { start, end }) || `数据范围:${start} – ${end}`; } function renderSales(channels) { @@ -77,14 +95,14 @@ ${escapeHtml(item.worksheet)}${total ? (Number(item.totals.total_price || 0) / total * 100).toFixed(1) : "0.0"}%`).join(""); [...document.querySelectorAll("#h5-legend .mobile-legend-item i")].forEach((marker) => { marker.style.backgroundColor = marker.dataset.color; }); $("#h5-ranking").innerHTML = sorted.length ? sorted.map((item) => ` -
${escapeHtml(item.worksheet)}${money(item.totals.total_price)}
`).join("") : '

暂无销售数据

'; +
${escapeHtml(item.worksheet)}${money(item.totals.total_price)}
`).join("") : `

${escapeHtml(I18N?.t("bi.empty_sales") || "暂无销售数据")}

`; [...document.querySelectorAll("#h5-ranking .track i")].forEach((bar) => { bar.style.width = `${Number(bar.dataset.width || 0)}%`; }); } function renderMatrix(data) { const rooms = data.overall.room_types.map((item) => item.room_type); if (!rooms.length || !data.channels.length) { - $("#h5-matrix").innerHTML = '

暂无渠道与房型数据

'; + $("#h5-matrix").innerHTML = `

${escapeHtml(I18N?.t("bi.empty") || "暂无渠道与房型数据")}

`; return; } const rows = data.channels.map((channel) => { @@ -92,16 +110,19 @@ return `${escapeHtml(channel.worksheet)}${rooms.map((room) => `${integer(values.get(room) || 0)}`).join("")}${integer(channel.totals.rooms_sold)}`; }).join(""); const totals = new Map(data.overall.room_types.map((item) => [item.room_type, item.rooms_sold])); - $("#h5-matrix").innerHTML = `${rooms.map((room) => ``).join("")}${rows}${rooms.map((room) => ``).join("")}
渠道${escapeHtml(room)}合计
合计${integer(totals.get(room) || 0)}${integer(data.overall.totals.rooms_sold)}
`; + const channelLabel = I18N?.t("bi.channel") || "渠道"; + const totalLabel = I18N?.t("common.total") || "合计"; + $("#h5-matrix").innerHTML = `${rooms.map((room) => ``).join("")}${rows}${rooms.map((room) => ``).join("")}
${escapeHtml(channelLabel)}${escapeHtml(room)}${escapeHtml(totalLabel)}
${escapeHtml(totalLabel)}${integer(totals.get(room) || 0)}${integer(data.overall.totals.rooms_sold)}
`; } function render(data) { + lastData = data; const totals = data.overall.totals; $("#h5-rooms").textContent = integer(totals.rooms_sold); $("#h5-revenue").textContent = money(totals.total_price); $("#h5-nights").textContent = integer(totals.room_nights); $("#h5-companies").textContent = integer(totals.channel_count); - $("#h5-updated").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`; + $("#h5-data-range").textContent = dataRange(data); renderSales(data.channels); renderMatrix(data); } @@ -111,11 +132,16 @@ try { render(await api(`/api/analytics?month=${encodeURIComponent(month)}`)); } catch (error) { - reset("该月份暂无可用看板数据"); + reset(I18N?.t("bi.empty") || "该月份暂无可用看板数据"); notify(error.message); } } + document.addEventListener("arr:locale-change", () => { + if (lastData) render(lastData); + I18N?.translateDom(); + }); + async function boot() { const connection = $("#h5-connection"); try { @@ -124,16 +150,21 @@ const logout = $("#h5-logout"); if (session.username) { logout.hidden = false; - logout.disabled = false; - logout.title = `${session.username} · 退出登录`; - logout.addEventListener("click", async () => { - logout.disabled = true; + logout.removeAttribute("aria-disabled"); + logout.classList.remove("is-busy"); + logout.title = `${session.username} · ${I18N?.t("auth.logout") || "退出登录"}`; + logout.addEventListener("click", async (event) => { + event.preventDefault(); + if (logout.getAttribute("aria-disabled") === "true") return; + logout.setAttribute("aria-disabled", "true"); + logout.classList.add("is-busy"); try { await api("/api/logout", { method: "POST" }); window.location.replace("/login"); } catch (error) { - notify(error.message || "退出登录失败,请重试"); - logout.disabled = false; + notify(error.message || I18N?.t("auth.logout_failed") || "退出登录失败,请重试"); + logout.removeAttribute("aria-disabled"); + logout.classList.remove("is-busy"); } }); } @@ -142,11 +173,21 @@ } try { const health = await api("/api/health"); - connection.classList.add(health.database_ready ? "ready" : "error"); - connection.lastChild.textContent = health.database_ready ? " 数据库已连接" : " 数据服务未连接"; + const databaseReady = Boolean(health.database_ready); + connection.classList.add(databaseReady ? "ready" : "error"); + connection.lastChild.textContent = databaseReady + ? "" + : ` ${I18N?.t("status.service_disconnected") || "数据服务未连接"}`; + connection.setAttribute( + "aria-label", + databaseReady + ? (I18N?.t("status.service_ready") || "服务正常") + : (I18N?.t("status.service_disconnected") || "数据服务未连接"), + ); } catch (_) { connection.classList.add("error"); - connection.lastChild.textContent = " 数据服务未连接"; + connection.lastChild.textContent = ` ${I18N?.t("status.service_disconnected") || "数据服务未连接"}`; + connection.setAttribute("aria-label", I18N?.t("status.service_disconnected") || "数据服务未连接"); } let months = []; try { months = await api("/api/months"); } catch (_) { /* handled by load */ } diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js new file mode 100644 index 0000000..55f5670 --- /dev/null +++ b/arr_web/static/i18n.js @@ -0,0 +1,804 @@ +(() => { + "use strict"; + + const STORAGE_KEY = "arr:locale:v1"; + const COOKIE_KEY = "arr_locale"; + const DEFAULT_LOCALE = "zh-CN"; + const LOCALES = { + "zh-CN": { label: "中文", intl: "zh-CN-u-ca-gregory-nu-latn" }, + en: { label: "English", intl: "en-GB-u-ca-gregory-nu-latn" }, + th: { label: "ไทย", intl: "th-TH-u-ca-gregory-nu-latn" }, + }; + + // The first value is the current product copy. Keeping the source copy here + // gives the legacy static markup a safe Chinese fallback while all new + // dynamic strings use the semantic key. + const CATALOG = { + "common.language": ["语言", "Language", "ภาษา"], + "common.skip_main": ["跳到主要内容", "Skip to main content", "ข้ามไปยังเนื้อหาหลัก"], + "common.skip_login": ["跳到登录表单", "Skip to login form", "ข้ามไปยังแบบฟอร์มเข้าสู่ระบบ"], + "common.cancel": ["取消", "Cancel", "ยกเลิก"], + "common.close": ["关闭", "Close", "ปิด"], + "common.confirm": ["确认", "Confirm", "ยืนยัน"], + "common.save": ["保存", "Save", "บันทึก"], + "common.delete": ["删除", "Delete", "ลบ"], + "common.select": ["选择", "Select", "เลือก"], + "common.view": ["查看", "View", "ดู"], + "common.download": ["下载", "Download", "ดาวน์โหลด"], + "common.refresh": ["刷新", "Refresh", "รีเฟรช"], + "common.previous": ["上一页", "Previous", "ก่อนหน้า"], + "common.next": ["下一页", "Next", "ถัดไป"], + "common.status": ["状态", "Status", "สถานะ"], + "common.company": ["公司", "Company", "บริษัท"], + "common.room_type": ["房型", "Room type", "ประเภทห้อง"], + "common.quantity": ["数量", "Quantity", "จำนวน"], + "common.operation": ["操作", "Actions", "การดำเนินการ"], + "common.total": ["合计", "Total", "รวม"], + "common.details": ["详情", "Details", "รายละเอียด"], + "common.file": ["文件", "File", "ไฟล์"], + "common.submitted_at": ["提交时间", "Submitted", "เวลาส่ง"], + "common.submitted_at_bangkok": ["提交时间(曼谷)", "Submitted (Bangkok)", "เวลาส่ง (กรุงเทพฯ)"], + "common.generated_at": ["生成时间", "Generated", "เวลาสร้าง"], + "common.none": ["暂无", "None", "ไม่มีข้อมูล"], + "common.unknown": ["未知", "Unknown", "ไม่ทราบ"], + "common.not_selected": ["未选择", "Not selected", "ยังไม่ได้เลือก"], + "common.pending": ["待生成", "Pending", "รอสร้าง"], + "common.no_result": ["无结果", "No result", "ไม่มีผลลัพธ์"], + "common.not_recorded": ["未记录", "Not recorded", "ไม่ได้บันทึก"], + "common.not_available": ["未完成", "Not completed", "ยังไม่เสร็จ"], + "common.year_month_unselected": ["未选择", "Not selected", "ยังไม่ได้เลือก"], + "common.loading": ["正在读取…", "Loading…", "กำลังโหลด…"], + "common.reading": ["正在读取", "Reading", "กำลังอ่าน"], + "common.retrying": ["重试中", "Retrying", "กำลังลองอีกครั้ง"], + "common.choose_file": ["选择文件", "Choose file", "เลือกไฟล์"], + "common.download_short": ["下载", "Download", "ดาวน์โหลด"], + "common.select_month": ["月份", "Month", "เดือน"], + "history.business_month": ["业务月份", "Business month", "เดือนธุรกิจ"], + "history.view_month": ["查看月份", "View month", "เดือนที่ดู"], + "history.generation_month": ["生成月份", "Generation month", "เดือนที่สร้าง"], + "history.generation_month_title": ["选择 C/O 所属的生成月份", "Choose the generation month for C/O", "เลือกเดือนที่สร้างสำหรับ C/O"], + "history.previous_month": ["上一个月", "Previous month", "เดือนก่อนหน้า"], + "history.next_month": ["下一个月", "Next month", "เดือนถัดไป"], + "history.current_month": ["本月", "This month", "เดือนนี้"], + "common.locale_zh": ["中文", "Chinese", "ภาษาจีน"], + "nav.primary": ["主要模块", "Main modules", "โมดูลหลัก"], + "nav.daily": ["日报处理", "Daily processing", "ประมวลผลรายวัน"], + "nav.monthly": ["月报", "Monthly report", "รายงานประจำเดือน"], + "nav.bi": ["渠道BI", "Channel BI", "Channel BI"], + "nav.company_detail": ["公司渠道明细", "Company channel detail", "รายละเอียดช่องทางบริษัท"], + "brand.home": ["ARR Report 首页", "ARR Report home", "หน้าหลัก ARR Report"], + "status.connecting_service": ["正在连接数据服务", "Connecting to data service", "กำลังเชื่อมต่อบริการข้อมูล"], + "status.service_ready": ["服务正常", "Service ready", "บริการพร้อมใช้งาน"], + "status.service_disconnected": ["数据服务未连接", "Data service unavailable", "บริการข้อมูลไม่พร้อมใช้งาน"], + "status.connecting": ["正在连接", "Connecting", "กำลังเชื่อมต่อ"], + "status.received": ["已接收", "Received", "รับแล้ว"], + "status.uploaded": ["已上传", "Uploaded", "อัปโหลดแล้ว"], + "status.waiting_processing": ["等待处理", "Waiting for processing", "รอประมวลผล"], + "status.processing": ["处理中", "Processing", "กำลังประมวลผล"], + "status.waiting_validation": ["等待验收", "Waiting for validation", "รอตรวจสอบผล"], + "status.validating": ["正在验收", "Validating", "กำลังตรวจสอบผล"], + "status.completed": ["已完成", "Completed", "เสร็จแล้ว"], + "status.validation_failed": ["验收失败", "Validation failed", "ตรวจสอบผลไม่ผ่าน"], + "status.failed": ["处理失败", "Processing failed", "ประมวลผลไม่สำเร็จ"], + "status.cancelled": ["已取消", "Cancelled", "ยกเลิกแล้ว"], + "status.generating": ["生成中", "Generating", "กำลังสร้าง"], + "status.validated": ["已验收", "Validated", "ตรวจสอบแล้ว"], + "status.current_version": ["当前版本", "Current version", "เวอร์ชันปัจจุบัน"], + "status.history_version": ["历史版本", "Historical version", "เวอร์ชันประวัติ"], + "status.generation_failed": ["生成失败", "Generation failed", "สร้างไม่สำเร็จ"], + "auth.logout": ["退出登录", "Log out", "ออกจากระบบ"], + "auth.logout_short": ["退出", "Log out", "ออก"], + "auth.logout_failed": ["退出登录失败,请重试", "Could not log out. Please try again.", "ออกจากระบบไม่สำเร็จ โปรดลองอีกครั้ง"], + "auth.session_expired": ["登录状态已失效", "Your session has expired.", "เซสชันหมดอายุแล้ว"], + "auth.session_init_failed": ["页面会话初始化失败", "Could not initialize the page session.", "เริ่มเซสชันของหน้าไม่สำเร็จ"], + "auth.username": ["username", "username", "ชื่อผู้ใช้"], + "auth.password": ["password", "password", "รหัสผ่าน"], + "auth.show": ["显示", "Show", "แสดง"], + "auth.hide": ["隐藏", "Hide", "ซ่อน"], + "auth.show_password": ["显示密码", "Show password", "แสดงรหัสผ่าน"], + "auth.hide_password": ["隐藏密码", "Hide password", "ซ่อนรหัสผ่าน"], + "auth.submit": ["登录 ARR Report", "Log in to ARR Report", "เข้าสู่ระบบ ARR Report"], + "auth.validating": ["正在验证…", "Verifying…", "กำลังตรวจสอบ…"], + "auth.unavailable": ["登录暂时不可用,请稍后重试", "Login is temporarily unavailable. Please try again later.", "การเข้าสู่ระบบไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้งภายหลัง"], + "auth.required_username": ["请输入用户名", "Enter your username.", "กรุณากรอกชื่อผู้ใช้"], + "auth.required_password": ["请输入密码", "Enter your password.", "กรุณากรอกรหัสผ่าน"], + "time.business": ["页面日期时间统一按泰国曼谷(UTC+7)显示。", "All page dates and times use Bangkok time (UTC+7).", "วันที่และเวลาทั้งหมดใช้เวลาที่กรุงเทพฯ (UTC+7)"], + "time.business_short": ["业务时间统一按泰国曼谷(UTC+7)显示", "Business time uses Bangkok time (UTC+7)", "เวลาธุรกิจใช้เวลาที่กรุงเทพฯ (UTC+7)"], + "upload.xml.title": ["上传 ARR.XML", "Upload ARR.XML", "อัปโหลด ARR.XML"], + "upload.xml.drop": ["拖放 XML 文件到这里", "Drop an XML file here", "ลากไฟล์ XML มาที่นี่"], + "upload.xml.choose": ["或点击选择文件,单个文件最大 25 MB", "or click to choose a file; maximum 25 MB", "หรือคลิกเพื่อเลือกไฟล์ ขนาดไม่เกิน 25 MB"], + "upload.file_not_selected": ["尚未选择文件", "No file selected", "ยังไม่ได้เลือกไฟล์"], + "upload.start": ["开始处理", "Start processing", "เริ่มประมวลผล"], + "upload.progress": ["ARR.XML 处理进度", "ARR.XML processing progress", "ความคืบหน้าการประมวลผล ARR.XML"], + "upload.waiting": ["等待开始", "Waiting to start", "รอเริ่ม"], + "upload.uploading": ["正在上传 ARR.XML", "Uploading ARR.XML", "กำลังอัปโหลด ARR.XML"], + "upload.processing": ["正在处理ARR.XML文件", "Processing ARR.XML", "กำลังประมวลผล ARR.XML"], + "upload.processing_detail": ["正在安全上传、运行固定处理器、独立验收并提交数据库。", "Uploading securely, running the fixed processor, validating independently and committing to the database.", "กำลังอัปโหลดอย่างปลอดภัย เรียกใช้ตัวประมวลผล ตรวจสอบผล และบันทึกลงฐานข้อมูล"], + "upload.after_processing": ["处理完成后将立即读取服务端全流程日志…", "The server trace will be read as soon as processing finishes…", "ระบบจะอ่านบันทึกกระบวนการจากเซิร์ฟเวอร์เมื่อเสร็จสิ้น…"], + "upload.creating_task": ["正在创建任务", "Creating task", "กำลังสร้างงาน"], + "upload.processing_done": ["处理完成", "Processing complete", "ประมวลผลเสร็จแล้ว"], + "upload.processing_failed": ["处理失败", "Processing failed", "ประมวลผลไม่สำเร็จ"], + "upload.success": ["ARR.XML 已处理并完成入库", "ARR.XML was processed and committed.", "ประมวลผลและบันทึก ARR.XML แล้ว"], + "upload.failure_log": ["ARR.XML 处理失败,请查看任务日志", "ARR.XML processing failed. Check the task log.", "การประมวลผล ARR.XML ไม่สำเร็จ โปรดดูบันทึกงาน"], + "upload.service_unready": ["文件接收服务尚未完成生产接线。", "The file intake service is not ready for production.", "บริการรับไฟล์ยังไม่พร้อมใช้งานจริง"], + "upload.stage_processor": ["正在运行固定处理器", "Running the fixed processor", "กำลังเรียกใช้ตัวประมวลผล"], + "upload.stage_validate": ["正在独立验收结果", "Independently validating the result", "กำลังตรวจสอบผลอย่างอิสระ"], + "upload.stage_commit": ["正在提交数据库", "Committing to the database", "กำลังบันทึกลงฐานข้อมูล"], + "metric.arrival_date": ["最近成功处理日期", "Latest successful processing date", "วันที่ประมวลผลสำเร็จล่าสุด"], + "metric.duration": ["处理耗时", "Processing time", "เวลาประมวลผล"], + "metric.duration_from_submit": ["从提交至完成", "From submission to completion", "ตั้งแต่ส่งจนเสร็จ"], + "metric.rooms": ["保留记录的售出房数", "Sold rooms in retained records", "จำนวนห้องที่ขายในรายการที่เก็บไว้"], + "daily.history": ["Daily Report", "Daily Report", "Daily Report"], + "daily.refresh": ["刷新", "Refresh", "รีเฟรช"], + "daily.download": ["日报", "Daily report", "รายงานรายวัน"], + "daily.empty": ["本月暂无 Daily Report 记录", "No Daily Report records this month", "เดือนนี้ยังไม่มีรายการ Daily Report"], + "daily.loading": ["正在读取本月记录…", "Loading this month’s records…", "กำลังโหลดรายการของเดือนนี้…"], + "daily.unavailable": ["日报记录暂时无法读取", "Daily records are temporarily unavailable", "ไม่สามารถอ่านรายการรายวันได้ชั่วคราว"], + "daily.no_selected_log": ["请上传文件,或从下方选择一个历史任务。", "Upload a file or select a historical task below.", "อัปโหลดไฟล์หรือเลือกงานเก่าด้านล่าง"], + "daily.overview": ["日报处理概览", "Daily processing overview", "ภาพรวมการประมวลผลรายวัน"], + "daily.pagination_label": ["Daily Report 分页", "Daily Report pagination", "การแบ่งหน้า Daily Report"], + "daily.file": ["文件", "File", "ไฟล์"], + "daily.submitted_at": ["提交时间", "Submitted", "เวลาส่ง"], + "daily.output": ["日报", "Daily report", "รายงานรายวัน"], + "monthly.processing": ["月报处理", "Monthly processing", "ประมวลผลรายงานประจำเดือน"], + "monthly.auto_update": ["自动更新 · 4 秒", "Auto-refresh · 4 sec", "อัปเดตอัตโนมัติ · 4 วินาที"], + "monthly.loading": ["正在读取月报记录…", "Loading monthly report records…", "กำลังโหลดรายการรายงานประจำเดือน…"], + "monthly.empty": ["该月份暂无月报处理记录", "No monthly processing records for this month", "เดือนนี้ยังไม่มีรายการประมวลผลรายเดือน"], + "monthly.updated_to": ["更新至", "Updated through", "อัปเดตถึง"], + "monthly.rows": ["数据行", "Data rows", "แถวข้อมูล"], + "monthly.pagination_label": ["月报记录分页", "Monthly report pagination", "การแบ่งหน้ารายงานประจำเดือน"], + "monthly.unavailable": ["月报记录暂时无法读取", "Monthly records are temporarily unavailable", "ไม่สามารถอ่านรายการรายเดือนได้ชั่วคราว"], + "monthly.new": ["新月报已自动加入列表", "A new monthly report was added automatically", "เพิ่มรายงานประจำเดือนใหม่ในรายการแล้ว"], + "monthly.retry": ["自动更新重试中", "Auto-refresh retrying", "กำลังลองอัปเดตอัตโนมัติอีกครั้ง"], + "bi.title": ["渠道BI", "Channel BI", "Channel BI"], + "bi.month": ["查看月份", "Month", "เดือน"], + "bi.metrics": ["本月核心指标", "This month’s key metrics", "ตัวชี้วัดหลักของเดือนนี้"], + "bi.rooms": ["售出房数", "Rooms sold", "จำนวนห้องที่ขาย"], + "bi.revenue": ["销售金额", "Sales amount", "ยอดขาย"], + "bi.nights": ["间夜", "Room nights", "คืนห้องพัก"], + "bi.companies": ["公司数", "Companies", "จำนวนบริษัท"], + "bi.rooms_total": ["NO. OF ROOM 合计", "Total NO. OF ROOM", "รวม NO. OF ROOM"], + "bi.revenue_total": ["销售金额合计", "Total sales amount", "ยอดขายรวม"], + "bi.nights_formula": ["房数 × 入住晚数", "Rooms × nights", "ห้อง × จำนวนคืน"], + "bi.channel_count": ["月报子表数量", "Monthly report worksheet count", "จำนวนชีตรายงานประจำเดือน"], + "bi.sales_by_company": ["各公司本月销售情况", "Monthly sales by company", "ยอดขายรายเดือนของแต่ละบริษัท"], + "bi.sales_ranking": ["公司销售金额排名", "Company sales ranking", "อันดับยอดขายบริษัท"], + "bi.sales_share": ["公司销售金额占比", "Company sales share", "สัดส่วนยอดขายบริษัท"], + "bi.matrix": ["渠道 × 房型售出房数", "Rooms sold by channel × room type", "จำนวนห้องที่ขายตามช่องทาง × ประเภทห้อง"], + "bi.channel": ["渠道", "Channel", "ช่องทาง"], + "bi.empty": ["暂无渠道与房型数据", "No channel or room-type data", "ไม่มีข้อมูลช่องทางหรือประเภทห้อง"], + "bi.empty_sales": ["暂无公司销售数据", "No company sales data", "ไม่มีข้อมูลยอดขายบริษัท"], + "bi.loading": ["正在读取看板数据…", "Loading dashboard data…", "กำลังโหลดข้อมูลแดชบอร์ด…"], + "company.title": ["公司渠道明细", "Company channel detail", "รายละเอียดช่องทางบริษัท"], + "company.fixed": ["固定生成:LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour", "Fixed scope: LianTai, QBD, DY-AI-Easy-KB, FengRun, HanaTour", "ขอบเขตคงที่: LianTai, QBD, DY-AI-Easy-KB, FengRun, HanaTour"], + "company.refresh_title": ["重新读取服务状态、Excel 来源和生成记录", "Reload service status, Excel source and job history", "โหลดสถานะบริการ แหล่ง Excel และประวัติงานใหม่"], + "company.refresh_jobs": ["刷新任务", "Refresh jobs", "รีเฟรชงาน"], + "company.service_unready": ["公司渠道明细服务暂未就绪", "Company channel detail service is not ready", "บริการรายละเอียดช่องทางบริษัทยังไม่พร้อม"], + "company.service_unready_detail": ["请检查本机数据库和 Excel 生成服务后刷新。", "Check the local database and Excel generation service, then refresh.", "ตรวจสอบฐานข้อมูลและบริการสร้าง Excel ในเครื่อง แล้วรีเฟรช"], + "company.generate": ["生成报表", "Generate reports", "สร้างรายงาน"], + "company.report_month": ["报表月份", "Report month", "เดือนรายงาน"], + "company.month_title": ["选择 C/O 所属报表月份,并筛选下方生成记录", "Choose the report month for C/O and filter the job history below", "เลือกเดือนรายงานของ C/O และกรองประวัติงานด้านล่าง"], + "company.upload_title": ["上传 Excel 报表", "Upload Excel report", "อัปโหลดรายงาน Excel"], + "company.choose_excel": ["选择或拖放 Excel 文件", "Choose or drop an Excel file", "เลือกหรือลากไฟล์ Excel"], + "company.xlsx_limit": [".xlsx 格式,单个文件最大 25 MB", ".xlsx format, maximum 25 MB", "รูปแบบ .xlsx ขนาดไม่เกิน 25 MB"], + "company.extract": ["提取并核对", "Extract and review", "แยกข้อมูลและตรวจสอบ"], + "company.checking": ["核对中", "Checking", "กำลังตรวจสอบ"], + "company.period_release_10": ["周期结束:当月 11 日 00:00(曼谷)", "Period ends: 00:00 on the 11th (Bangkok)", "รอบสิ้นสุด: 00:00 วันที่ 11 (กรุงเทพฯ)"], + "company.period_release_20": ["周期结束:当月 21 日 00:00(曼谷)", "Period ends: 00:00 on the 21st (Bangkok)", "รอบสิ้นสุด: 00:00 วันที่ 21 (กรุงเทพฯ)"], + "company.period_release_month_end": ["周期结束:次月 1 日 00:00(曼谷)", "Period ends: 00:00 on the first day of the next month (Bangkok)", "รอบสิ้นสุด: 00:00 วันที่ 1 ของเดือนถัดไป (กรุงเทพฯ)"], + "company.generate_short": ["生成", "Generate", "สร้าง"], + "company.review_title": ["Booking记录提取", "Booking record extraction", "การแยกรายการ Booking"], + "company.extracted_file": ["提取文件", "Source file", "ไฟล์ต้นทาง"], + "company.review_stats": ["提取统计", "Extraction summary", "สรุปการแยกข้อมูล"], + "company.confirmed": ["已确认", "Confirmed", "ยืนยันแล้ว"], + "company.pending_human": ["待人工", "Needs review", "รอตรวจสอบ"], + "company.deleted": ["已删除", "Deleted", "ลบแล้ว"], + "company.delete_selected": ["删除所选", "Delete selected", "ลบรายการที่เลือก"], + "company.discard": ["放弃本次提取", "Discard this extraction", "ยกเลิกการแยกข้อมูลครั้งนี้"], + "company.activate": ["确认并启用", "Confirm and activate", "ยืนยันและเปิดใช้งาน"], + "company.extract_rooms": ["提取房型记录", "Extracted room records", "รายการประเภทห้องที่แยกได้"], + "company.raw_identifier": ["原始标识", "Raw identifier", "ตัวระบุดิบ"], + "company.empty_review": ["没有可显示的提取记录。", "No extraction records to display.", "ไม่มีรายการแยกข้อมูลให้แสดง"], + "company.task_status": ["TASK STATUS", "TASK STATUS", "TASK STATUS"], + "company.preparing": ["正在准备公司渠道明细任务", "Preparing company channel detail job", "กำลังเตรียมงานรายละเอียดช่องทางบริษัท"], + "company.reading_status": ["正在读取任务状态。", "Reading job status.", "กำลังอ่านสถานะงาน"], + "company.queued": ["已排队", "Queued", "เข้าคิวแล้ว"], + "company.generating": ["生成中", "Generating", "กำลังสร้าง"], + "company.all_success": ["全部成功", "All succeeded", "สำเร็จทั้งหมด"], + "company.partial_success": ["部分成功", "Partially succeeded", "สำเร็จบางส่วน"], + "company.incomplete": ["未完成", "Incomplete", "ยังไม่เสร็จ"], + "company.waiting_generation": ["等待生成", "Waiting to generate", "รอสร้าง"], + "company.success": ["成功", "Success", "สำเร็จ"], + "company.success_review": ["成功 · 待复核", "Success · review needed", "สำเร็จ · ต้องตรวจสอบ"], + "company.failed": ["失败", "Failed", "ล้มเหลว"], + "company.not_generated": ["未生成", "Not generated", "ยังไม่สร้าง"], + "company.warning_count": ["需复核", "Needs review", "ต้องตรวจสอบ"], + "company.cutoff_10": ["截至 10 日", "Through the 10th", "ถึงวันที่ 10"], + "company.cutoff_20": ["截至 20 日", "Through the 20th", "ถึงวันที่ 20"], + "company.cutoff_month_end": ["截至月末", "Through month-end", "ถึงสิ้นเดือน"], + "company.history": ["生成记录", "Generation history", "ประวัติการสร้าง"], + "company.per_page": ["每页 50 条", "50 per page", "หน้าละ 50 รายการ"], + "company.history_loading": ["正在读取公司渠道明细任务…", "Loading company channel detail jobs…", "กำลังโหลดงานรายละเอียดช่องทางบริษัท…"], + "company.history_empty": ["该月份还没有公司渠道明细任务", "No company channel detail jobs for this month", "เดือนนี้ยังไม่มีงานรายละเอียดช่องทางบริษัท"], + "company.processing_title": ["公司渠道明细处理", "Company channel detail processing", "การประมวลผลรายละเอียดช่องทางบริษัท"], + "company.review_pagination": ["提取记录分页", "Extraction record pagination", "การแบ่งหน้ารายการแยกข้อมูล"], + "company.history_pagination": ["生成记录分页", "Generation history pagination", "การแบ่งหน้าประวัติการสร้าง"], + "company.result_caption": ["本次任务中五家公司的生成结果", "Generation results for the five companies in this job", "ผลการสร้างของห้าบริษัทในงานนี้"], + "company.select_all_page": ["选择本页全部记录", "Select all records on this page", "เลือกทุกรายการในหน้านี้"], + "company.generated": ["已生成", "Generated", "สร้างแล้ว"], + "company.as_of": ["截止日期", "As-of date", "วันที่ข้อมูล"], + "company.task_duration": ["任务耗时", "Job duration", "ระยะเวลางาน"], + "company.cutoff_period": ["截止期间", "Cutoff period", "ช่วงเวลาสิ้นสุด"], + "company.review_problem": ["复核 / 异常", "Review / issues", "ตรวจสอบ / ปัญหา"], + "company.warning_short": ["需复核", "Needs review", "ต้องตรวจสอบ"], + "company.report_default_description": ["2026年07月,01-10", "July 2026, 01-10", "กรกฎาคม 2026, 01-10"], + "company.progress_waiting": ["等待生成", "Waiting to generate", "รอสร้าง"], + "company.progress_failed": ["生成未完成", "Generation incomplete", "การสร้างไม่เสร็จ"], + "company.progress_loading_finance": ["正在读取已校验的 finance 数据", "Loading validated finance data", "กำลังโหลดข้อมูล finance ที่ตรวจสอบแล้ว"], + "company.progress_generating_excel": ["正在生成 5 家公司 Excel", "Generating Excel files for 5 companies", "กำลังสร้างไฟล์ Excel สำหรับ 5 บริษัท"], + "company.progress_complete": ["生成完成", "Generation complete", "สร้างเสร็จแล้ว"], + "company.progress_ended": ["生成已结束", "Generation ended", "การสร้างสิ้นสุดแล้ว"], + "company.job_queued_message": ["任务已进入生成队列。", "The job entered the generation queue.", "งานเข้าสู่คิวการสร้างแล้ว"], + "company.job_generating_message": ["正在按公司生成渠道明细表。", "Generating channel detail workbooks by company.", "กำลังสร้างตารางรายละเอียดช่องทางแยกตามบริษัท"], + "company.job_success_message": ["5 家公司渠道明细表已生成。", "Channel detail workbooks for 5 companies were generated.", "สร้างตารางรายละเอียดช่องทางสำหรับ 5 บริษัทแล้ว"], + "company.job_partial_message": ["部分公司已生成,失败公司可按错误代码复核后重跑。", "Some companies were generated. Review failed companies by error code and rerun.", "สร้างบางบริษัทแล้ว ตรวจสอบบริษัทที่ล้มเหลวตามรหัสข้อผิดพลาดแล้วลองใหม่"], + "company.job_execution_failed_message": ["渠道明细生成未完成;未成功发布的公司不会提供下载。", "Channel detail generation did not complete; companies not published successfully will not be available for download.", "การสร้างรายละเอียดช่องทางไม่เสร็จ บริษัทที่เผยแพร่ไม่สำเร็จจะไม่พร้อมให้ดาวน์โหลด"], + "company.discarding": ["正在放弃", "Discarding", "กำลังยกเลิก"], + "company.deleting": ["正在删除", "Deleting", "กำลังลบ"], + "company.confirm_delete_title": ["删除房型记录", "Delete room record", "ลบรายการประเภทห้อง"], + "company.confirm_delete_description": ["确认删除所选房型记录?", "Delete the selected room record?", "ยืนยันการลบรายการประเภทห้องที่เลือกหรือไม่"], + "company.confirm_discard_title": ["放弃本次提取", "Discard this extraction", "ยกเลิกการแยกข้อมูลครั้งนี้"], + "company.confirm_discard_description": ["确认放弃本次提取?", "Discard this extraction?", "ยืนยันการยกเลิกการแยกข้อมูลครั้งนี้หรือไม่"], + "company.confirm_discard_note": ["当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。", "Your edits and deletions will be discarded; the original Booking source will not change.", "การแก้ไขและการลบจะถูกทิ้ง แหล่งข้อมูล Booking เดิมจะไม่เปลี่ยนแปลง"], + "company.confirm_delete_note": ["此操作只影响本次提取结果,确认启用前原数据源不会改变。", "This affects only this extraction; the original source will not change until activation.", "การดำเนินการนี้มีผลเฉพาะผลการแยกครั้งนี้ แหล่งข้อมูลเดิมจะไม่เปลี่ยนจนกว่าจะเปิดใช้งาน"], + "company.confirm_delete": ["确认删除", "Confirm delete", "ยืนยันการลบ"], + "company.confirm_discard": ["确认放弃", "Confirm discard", "ยืนยันการยกเลิก"], + "company.confirm_generate": ["确认生成", "Confirm generation", "ยืนยันการสร้าง"], + "company.generate_five": ["将一次生成五家公司的正式 Excel。", "This will generate the official Excel files for five companies.", "ระบบจะสร้างไฟล์ Excel อย่างเป็นทางการสำหรับห้าบริษัท"], + "company.generate_early": ["周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。", "The period is not complete. The current committed data will be used; later data will not be added automatically. Generate again for a new version.", "รอบยังไม่สิ้นสุด ระบบจะใช้ข้อมูลที่บันทึกแล้ว ข้อมูลใหม่จะไม่ถูกเพิ่มอัตโนมัติ สามารถสร้างเวอร์ชันใหม่ได้"], + "company.no_valid_month": ["请先选择有效的报表月份", "Choose a valid report month first", "เลือกเดือนรายงานที่ถูกต้องก่อน"], + "company.review_open": ["请先确认或放弃当前 Excel 提取结果", "Confirm or discard the current Excel extraction first", "ยืนยันหรือยกเลิกผลการแยก Excel ปัจจุบันก่อน"], + "company.source_required": ["请先提取并启用 Excel 报表", "Extract and activate the Excel report first", "แยกและเปิดใช้งานรายงาน Excel ก่อน"], + "company.service_required": ["公司渠道明细服务尚未就绪", "Company channel detail service is not ready", "บริการรายละเอียดช่องทางบริษัทยังไม่พร้อม"], + "company.future_month": ["未来报表月份暂不可生成", "Future report months cannot be generated yet", "ยังไม่สามารถสร้างเดือนรายงานในอนาคตได้"], + "company.select_month": ["选择月份", "Choose a month", "เลือกเดือน"], + "company.period_not_finished": ["周期未结束", "Period not complete", "รอบยังไม่สิ้นสุด"], + "company.period_finished": ["周期已结束", "Period complete", "รอบสิ้นสุดแล้ว"], + "company.source_error": ["当前 Excel 数据源暂时无法读取", "The current Excel source is temporarily unavailable", "ไม่สามารถอ่านแหล่ง Excel ปัจจุบันได้ชั่วคราว"], + "company.draft_error": ["当前提取结果暂时无法读取", "The current extraction is temporarily unavailable", "ไม่สามารถอ่านผลการแยกปัจจุบันได้ชั่วคราว"], + "company.file_xlsx": ["请选择 .xlsx 格式的 Excel 文件", "Choose an .xlsx Excel file", "เลือกไฟล์ Excel รูปแบบ .xlsx"], + "company.file_empty": ["Excel 文件为空", "The Excel file is empty", "ไฟล์ Excel ว่างเปล่า"], + "company.file_too_large": ["Excel 文件超过 25 MB", "The Excel file exceeds 25 MB", "ไฟล์ Excel มีขนาดเกิน 25 MB"], + "company.file_extract_failed": ["Excel 文件未能完成提取", "The Excel file could not be extracted", "แยกข้อมูลจากไฟล์ Excel ไม่สำเร็จ"], + "company.fill_room_type": ["请填写房型后再保存", "Enter a room type before saving", "กรอกประเภทห้องก่อนบันทึก"], + "company.quantity_invalid": ["房间数量必须是 1 至 9999 的整数", "Room quantity must be an integer from 1 to 9999", "จำนวนห้องต้องเป็นจำนวนเต็มตั้งแต่ 1 ถึง 9999"], + "company.review_saved": ["房型记录已保存", "Room record saved", "บันทึกรายการประเภทห้องแล้ว"], + "company.review_deleted": ["房型记录已删除", "Room record deleted", "ลบรายการประเภทห้องแล้ว"], + "company.review_discarded": ["本次提取已放弃,原数据源未改变", "Extraction discarded; the original source was not changed", "ยกเลิกการแยกแล้ว แหล่งข้อมูลเดิมไม่เปลี่ยนแปลง"], + "company.review_activated": ["人工核对完成,Booking 数据源已启用", "Review complete; the Booking source is active", "ตรวจสอบเสร็จแล้ว เปิดใช้งานแหล่งข้อมูล Booking"], + "company.review_discard_failed": ["本次提取未能放弃", "The extraction could not be discarded", "ยกเลิกการแยกข้อมูลไม่สำเร็จ"], + "company.review_delete_failed": ["房型记录未能删除", "The room record could not be deleted", "ลบรายการประเภทห้องไม่สำเร็จ"], + "company.review_save_failed": ["房型记录未能保存", "The room record could not be saved", "บันทึกรายการประเภทห้องไม่สำเร็จ"], + "company.source_activate_failed": ["Booking 数据源未能启用", "The Booking source could not be activated", "เปิดใช้งานแหล่งข้อมูล Booking ไม่สำเร็จ"], + "company.extract_done": ["提取完成,可以确认并启用", "Extraction complete. Confirm and activate.", "แยกข้อมูลเสร็จแล้ว ยืนยันและเปิดใช้งานได้"], + "company.extract_pending": ["提取完成,{count} 条需要人工确认", "Extraction complete. {count} records need review.", "แยกข้อมูลเสร็จแล้ว ต้องตรวจสอบ {count} รายการ"], + "company.file_name_missing": ["未记录文件名", "File name not recorded", "ไม่ได้บันทึกชื่อไฟล์"], + "company.review_pending_confirm": ["待人工确认", "Pending human review", "รอยืนยันโดยเจ้าหน้าที่"], + "company.review_save": ["保存", "Save", "บันทึก"], + "company.review_delete": ["删除", "Delete", "ลบ"], + "company.review_select": ["选择", "Select", "เลือก"], + "company.review_no_record": ["暂无", "None", "ไม่มีข้อมูล"], + "company.period_pending": ["待生成", "Pending", "รอสร้าง"], + "company.period_waiting": ["等待生成", "Waiting to generate", "รอสร้าง"], + "company.period_none": ["无结果", "No result", "ไม่มีผลลัพธ์"], + "company.period_not_generated": ["未生成", "Not generated", "ยังไม่สร้าง"], + "company.period_not_recorded": ["未记录", "Not recorded", "ไม่ได้บันทึก"], + "company.report_prepare": ["正在准备公司渠道明细任务", "Preparing company channel detail job", "กำลังเตรียมงานรายละเอียดช่องทางบริษัท"], + "company.report_waiting": ["等待生成", "Waiting to generate", "รอสร้าง"], + "company.report_warning_count": ["{count} 项", "{count} items", "{count} รายการ"], + "company.report_no_result": ["暂无", "None", "ไม่มีข้อมูล"], + "company.this_record": ["这条记录", "this record", "รายการนี้"], + "company.job_submitted": ["五家公司渠道明细任务已提交", "The five-company channel detail job was submitted", "ส่งงานรายละเอียดช่องทางของห้าบริษัทแล้ว"], + "company.jobs_refreshed": ["公司渠道明细任务已刷新", "Company channel detail jobs refreshed", "รีเฟรชงานรายละเอียดช่องทางบริษัทแล้ว"], + "company.job_submit_failed": ["公司渠道明细任务未能提交", "The company channel detail job could not be submitted", "ส่งงานรายละเอียดช่องทางบริษัทไม่สำเร็จ"], + "task.title": ["任务日志", "Task log", "บันทึกงาน"], + "task.description": ["显示当前选中的日报处理任务从上传到下游通知的处理日志。", "Shows the selected daily task from upload through downstream notification.", "แสดงบันทึกของงานรายวันที่เลือกตั้งแต่อัปโหลดจนถึงการแจ้งเตือนปลายทาง"], + "task.kicker": ["DAILY JOB / LOG", "DAILY JOB / LOG", "DAILY JOB / LOG"], + "task.raw_log": ["任务原始日志", "Raw task log", "บันทึกงานดิบ"], + "task.refresh": ["刷新日志", "Refresh log", "รีเฟรชบันทึก"], + "task.copy": ["复制全部日志", "Copy all logs", "คัดลอกบันทึกทั้งหมด"], + "task.copying": ["复制中…", "Copying…", "กำลังคัดลอก…"], + "task.copied": ["已复制", "Copied", "คัดลอกแล้ว"], + "task.copy_failed": ["复制失败", "Copy failed", "คัดลอกไม่สำเร็จ"], + "task.copy_none": ["暂无可复制的任务日志", "No task log to copy", "ไม่มีบันทึกงานให้คัดลอก"], + "task.copy_denied": ["无法复制日志,请检查浏览器剪贴板权限", "Could not copy the log. Check browser clipboard permission.", "คัดลอกบันทึกไม่ได้ โปรดตรวจสอบสิทธิ์คลิปบอร์ดของเบราว์เซอร์"], + "task.clipboard_denied": ["浏览器未允许访问剪贴板", "The browser did not allow clipboard access", "เบราว์เซอร์ไม่อนุญาตให้เข้าถึงคลิปบอร์ด"], + "task.reading": ["正在读取任务日志", "Reading task log", "กำลังอ่านบันทึกงาน"], + "task.reading_detail": ["正在聚合数据库中的处理证据…", "Aggregating processing evidence from the database…", "กำลังรวมหลักฐานการประมวลผลจากฐานข้อมูล…"], + "task.unavailable": ["任务日志暂时无法读取", "The task log is temporarily unavailable", "ไม่สามารถอ่านบันทึกงานได้ชั่วคราว"], + "task.no_server_log": ["服务端没有返回可用日志。", "The server returned no usable log.", "เซิร์ฟเวอร์ไม่ส่งบันทึกที่ใช้ได้กลับมา"], + "task.not_selected": ["未选择任务", "No task selected", "ยังไม่ได้เลือกงาน"], + "task.waiting_upload": ["等待上传 ARR.XML", "Waiting for ARR.XML upload", "รอการอัปโหลด ARR.XML"], + "task.after_create": ["新任务创建后,这里会显示服务端保存的全流程日志。", "The server-side trace will appear here after a new task is created.", "บันทึกจากเซิร์ฟเวอร์จะแสดงที่นี่หลังสร้างงานใหม่"], + "task.inline_prompt": ["请上传文件,或从 Daily Report 中选择一个任务。", "Upload a file or select a task from Daily Report.", "อัปโหลดไฟล์หรือเลือกงานจาก Daily Report"], + "task.refresh_failed": ["日志刷新失败", "Log refresh failed", "รีเฟรชบันทึกไม่สำเร็จ"], + "task.auto_refresh_failed": ["自动刷新失败", "Auto-refresh failed", "อัปเดตอัตโนมัติไม่สำเร็จ"], + "task.created": ["任务已登记到 ARR 程序化处理队列。", "The task was registered in the ARR processing queue.", "ลงทะเบียนงานในคิวประมวลผล ARR แล้ว"], + "task.processor_started": ["ARR 已开始处理已登记的源 XML。", "ARR started processing the registered source XML.", "ARR เริ่มประมวลผล XML ต้นทางที่ลงทะเบียนแล้ว"], + "task.processor_failed": ["程序化处理失败", "Programmatic processing failed", "การประมวลผลแบบโปรแกรมไม่สำเร็จ"], + "task.processor_cancelled": ["程序化处理已取消", "Programmatic processing cancelled", "ยกเลิกการประมวลผลแบบโปรแกรมแล้ว"], + "task.processor_complete": ["处理尝试已闭环", "Processing attempt completed", "การประมวลผลเสร็จสิ้น"], + "task.accepted": ["业务任务已完成", "Business task completed", "งานธุรกิจเสร็จสิ้น"], + "task.upload_registered": ["上传已安全登记", "Upload safely registered", "ลงทะเบียนการอัปโหลดอย่างปลอดภัยแล้ว"], + "task.trace_processing": ["处理已结束", "Processing ended", "การประมวลผลสิ้นสุดแล้ว"], + "task.trace_failed": ["处理未能完成", "Processing could not complete", "การประมวลผลไม่เสร็จสมบูรณ์"], + "task.refreshed": ["任务日志已刷新", "Task log refreshed", "รีเฟรชบันทึกงานแล้ว"], + "task.no_job_id": ["任务编号暂不可用,请刷新 Daily Report。", "The task ID is not available yet. Refresh Daily Report.", "รหัสงานยังไม่พร้อม โปรดรีเฟรช Daily Report"], + "task.failed_refresh_hint": ["请刷新 Daily Report,确认是否已登记失败任务。", "Refresh Daily Report to confirm whether a failed task was recorded.", "รีเฟรช Daily Report เพื่อตรวจสอบว่าบันทึกงานที่ล้มเหลวแล้วหรือไม่"], + "task.error_status": ["处理失败", "Processing failed", "ประมวลผลไม่สำเร็จ"], + "task.live_failed": ["日志刷新失败", "Log refresh failed", "รีเฟรชบันทึกไม่สำเร็จ"], + "task.live_auto_failed": ["自动刷新失败", "Auto-refresh failed", "อัปเดตอัตโนมัติไม่สำเร็จ"], + "task.live_reading": ["正在读取", "Reading", "กำลังอ่าน"], + "task.live_unselected": ["未选择任务", "No task selected", "ยังไม่ได้เลือกงาน"], + "task.trace_failed_status": ["FAILED", "FAILED", "FAILED"], + "task.trace_running_status": ["RUNNING / auto refresh {seconds}s", "RUNNING / auto refresh {seconds}s", "RUNNING / อัปเดตทุก {seconds}s"], + "task.trace_succeeded_status": ["SUCCEEDED", "SUCCEEDED", "SUCCEEDED"], + "task.trace_unknown_status": ["UNKNOWN", "UNKNOWN", "UNKNOWN"], + "task.no_events": ["0 events", "0 events", "0 เหตุการณ์"], + "task.events": ["{count} events", "{count} events", "{count} เหตุการณ์"], + "task.raw_log_count": ["0 lines", "0 lines", "0 บรรทัด"], + "task.raw_log_aria": ["任务原始日志", "Raw task log", "บันทึกงานดิบ"], + "task.browser_clipboard": ["浏览器未允许访问剪贴板", "The browser did not allow clipboard access", "เบราว์เซอร์ไม่อนุญาตให้เข้าถึงคลิปบอร์ด"], + "task.none_to_copy": ["暂无可复制的任务日志", "No task log to copy", "ไม่มีบันทึกงานให้คัดลอก"], + "task.copying_lines": ["已复制全部任务日志({count} 行)", "Copied all task logs ({count} lines)", "คัดลอกบันทึกงานทั้งหมดแล้ว ({count} บรรทัด)"], + "task.copy_button": ["复制全部日志", "Copy all logs", "คัดลอกบันทึกทั้งหมด"], + "task.copying_button": ["复制中…", "Copying…", "กำลังคัดลอก…"], + "task.copied_button": ["已复制", "Copied", "คัดลอกแล้ว"], + "task.copy_failed_button": ["复制失败", "Copy failed", "คัดลอกไม่สำเร็จ"], + "task.close_button": ["关闭", "Close", "ปิด"], + "error.generic": ["请求未完成", "The request could not be completed", "ดำเนินการตามคำขอไม่สำเร็จ"], + "error.unrecognized_response": ["服务返回了无法识别的结果", "The service returned an unrecognized response", "บริการส่งผลลัพธ์ที่ไม่รู้จักกลับมา"], + "error.database_unavailable": ["数据库连接失败", "Database connection failed", "เชื่อมต่อฐานข้อมูลไม่สำเร็จ"], + "error.database_read_unavailable": ["数据库读取服务暂不可用", "Database read service is temporarily unavailable", "บริการอ่านฐานข้อมูลไม่พร้อมใช้งานชั่วคราว"], + "error.query_invalid": ["查询参数无效", "Invalid query parameters", "พารามิเตอร์การค้นหาไม่ถูกต้อง"], + "error.auth_required": ["请先登录", "Please log in first", "กรุณาเข้าสู่ระบบก่อน"], + "error.login_failed": ["用户名或密码不正确", "Incorrect username or password", "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"], + "error.login_rate_limited": ["登录尝试过于频繁,请稍后再试", "Too many login attempts. Try again later.", "พยายามเข้าสู่ระบบบ่อยเกินไป โปรดลองอีกครั้งภายหลัง"], + "error.request_invalid": ["请求内容不是有效 JSON", "The request is not valid JSON", "คำขอไม่ใช่ JSON ที่ถูกต้อง"], + "error.request_too_large": ["请求内容过大", "The request is too large", "คำขอมีขนาดใหญ่เกินไป"], + "error.upload_empty": ["XML 文件为空", "The XML file is empty", "ไฟล์ XML ว่างเปล่า"], + "error.upload_too_large": ["XML 文件超过 25 MB", "The XML file exceeds 25 MB", "ไฟล์ XML มีขนาดเกิน 25 MB"], + "error.upload_invalid": ["文件内容不是 XML", "The file content is not XML", "เนื้อหาไฟล์ไม่ใช่ XML"], + "error.upload_filename": ["请选择 XML 文件", "Choose an XML file", "เลือกไฟล์ XML"], + "error.job_id": ["任务编号无效", "Invalid task ID", "รหัสงานไม่ถูกต้อง"], + "error.route_not_found": ["页面或接口不存在", "The page or endpoint does not exist", "ไม่พบหน้าหรือปลายทาง"], + "error.static_not_found": ["页面资源不存在", "The page resource does not exist", "ไม่พบทรัพยากรของหน้า"], + "error.session_invalid": ["页面会话已失效", "The page session is no longer valid", "เซสชันของหน้าไม่ถูกต้องแล้ว"], + "error.download_not_found": ["文件不存在或尚未生成", "The file does not exist or has not been generated", "ไม่พบไฟล์หรือยังไม่ได้สร้าง"], + "error.download_invalid": ["文件身份无效", "Invalid file identity", "ข้อมูลระบุไฟล์ไม่ถูกต้อง"], + "error.download_unavailable": ["文件读取服务暂不可用", "The file read service is temporarily unavailable", "บริการอ่านไฟล์ไม่พร้อมใช้งานชั่วคราว"], + "error.download_integrity": ["文件完整性校验失败", "File integrity check failed", "ตรวจสอบความสมบูรณ์ของไฟล์ไม่ผ่าน"], + "error.company_source_required": ["请先上传并校验 Excel 报表", "Upload and validate the Excel report first", "อัปโหลดและตรวจสอบรายงาน Excel ก่อน"], + "error.company_review_open": ["请先完成或放弃当前 Excel 提取草稿", "Complete or discard the current Excel draft first", "ดำเนินการหรือยกเลิกร่าง Excel ปัจจุบันก่อน"], + "error.company_future": ["未来报表月份暂不可生成", "Future report months cannot be generated yet", "ยังไม่สามารถสร้างเดือนรายงานในอนาคตได้"], + "error.company_running": ["同一月份和期间已有生成任务", "A job already exists for this month and period", "มีงานสำหรับเดือนและช่วงเวลานี้อยู่แล้ว"], + "error.company_unavailable": ["公司渠道明细生成服务暂不可用", "Company channel detail generation is temporarily unavailable", "บริการสร้างรายละเอียดช่องทางบริษัทไม่พร้อมใช้งานชั่วคราว"], + "error.company_download": ["该公司的正式表格尚不可下载", "The official workbook for this company is not ready to download", "ไฟล์อย่างเป็นทางการของบริษัทนี้ยังไม่พร้อมดาวน์โหลด"], + "error.booking_unavailable": ["Excel 数据源服务暂未就绪", "The Excel source service is not ready", "บริการแหล่ง Excel ยังไม่พร้อม"], + "error.booking_extract_failed": ["Excel 文件未能完成提取", "The Excel file could not be extracted", "แยกข้อมูลจากไฟล์ Excel ไม่สำเร็จ"], + "error.processing_failed": ["文件已安全接收,但程序化处理未能完成", "The file was received safely, but processing did not complete", "รับไฟล์อย่างปลอดภัยแล้ว แต่การประมวลผลไม่เสร็จ"], + "error.invalid_request": ["请求字段无效", "Invalid request fields", "ฟิลด์คำขอไม่ถูกต้อง"], + "error.invalid_period": ["期间无效", "Invalid period", "ช่วงเวลาไม่ถูกต้อง"], + "error.invalid_company": ["公司名称无效", "Invalid company name", "ชื่อบริษัทไม่ถูกต้อง"], + "error.not_found": ["任务或数据不存在", "The task or data was not found", "ไม่พบงานหรือข้อมูล"], + "error.service_unavailable": ["服务暂时不可用", "The service is temporarily unavailable", "บริการไม่พร้อมใช้งานชั่วคราว"], + "error.internal": ["服务暂时无法完成请求", "The service could not complete the request", "บริการไม่สามารถดำเนินการตามคำขอได้"], + "error.month_invalid": ["报表月份无效", "Invalid report month", "เดือนรายงานไม่ถูกต้อง"], + "error.monthly_busy": ["该服务正在生成月报", "The monthly report service is already running", "บริการรายงานประจำเดือนกำลังทำงาน"], + "error.monthly_failed": ["月报生成未完成", "Monthly report generation did not complete", "การสร้างรายงานประจำเดือนไม่เสร็จ"], + "error.monthly_not_found": ["找不到该月份的正式月报。", "The published monthly report for this month was not found.", "ไม่พบรายงานประจำเดือนที่เผยแพร่ของเดือนนี้"], + "error.channel_invalid": ["渠道名称无效", "Invalid channel name", "ชื่อช่องทางไม่ถูกต้อง"], + "error.login_request_invalid": ["登录请求格式或字段无效", "The login request is invalid", "คำขอเข้าสู่ระบบไม่ถูกต้อง"], + "error.content_length_required": ["请求长度缺失", "Request length is missing", "ไม่มีความยาวคำขอ"], + "error.booking_filename": ["请选择 .xlsx 格式的 Excel 文件", "Choose an .xlsx Excel file", "เลือกไฟล์ Excel รูปแบบ .xlsx"], + "error.review_request_invalid": ["人工确认请求无效", "The review request is invalid", "คำขอตรวจสอบไม่ถูกต้อง"], + "error.review_item_invalid": ["待确认记录编号无效", "Invalid review item ID", "รหัสรายการตรวจสอบไม่ถูกต้อง"], + "error.company_request_invalid": ["渠道明细请求字段无效", "Invalid company detail request fields", "ฟิลด์คำขอรายละเอียดบริษัทไม่ถูกต้อง"], + "error.company_period_invalid": ["期间必须是 01-10、11-20 或 21-month-end", "Period must be 01-10, 11-20 or 21-month-end", "ช่วงเวลาต้องเป็น 01-10, 11-20 หรือ 21-month-end"], + "error.company_job_not_found": ["任务不存在", "The job does not exist", "ไม่พบงาน"], + "error.download_too_large": ["文件超过下载限制", "The file exceeds the download limit", "ไฟล์มีขนาดเกินขีดจำกัดการดาวน์โหลด"], + "problem.multi_price": ["同房型存在不同静态价格,需人工复核", "The same room type has different static prices; review required", "ประเภทห้องเดียวกันมีราคาคงที่ต่างกัน ต้องตรวจสอบ"], + "problem.source_missing": ["数据源版本缺失", "Source version is missing", "ไม่มีเวอร์ชันแหล่งข้อมูล"], + "problem.group_missing": ["Group Code 缺失", "Group Code is missing", "ไม่มี Group Code"], + "problem.group_not_found": ["Group Code 未找到", "Group Code not found", "ไม่พบ Group Code"], + "problem.booking_parse": ["预订解析异常", "Booking parsing error", "เกิดข้อผิดพลาดในการแยก Booking"], + "problem.room_items_missing": ["房型明细缺失", "Room details are missing", "ไม่มีรายละเอียดประเภทห้อง"], + "problem.stay_date": ["住宿日期异常", "Invalid stay date", "วันที่เข้าพักไม่ถูกต้อง"], + "problem.nights_conflict": ["间夜数据冲突", "Room-night data conflict", "ข้อมูลคืนห้องพักขัดแย้งกัน"], + "problem.total_price": ["静态价格异常", "Invalid static price", "ราคาคงที่ไม่ถูกต้อง"], + "problem.output_validation": ["Excel 输出校验失败", "Excel output validation failed", "ตรวจสอบผลลัพธ์ Excel ไม่ผ่าน"], + "problem.publish_failed": ["正式表格发布失败", "Official workbook publication failed", "เผยแพร่ไฟล์อย่างเป็นทางการไม่สำเร็จ"], + "problem.internal": ["处理服务异常", "Processing service error", "บริการประมวลผลขัดข้อง"], + "problem.execution": ["任务执行未完成", "Job execution did not complete", "การทำงานยังไม่เสร็จ"], + "problem.interrupted": ["服务重启中断任务", "The service restart interrupted the job", "การรีสตาร์ตบริการขัดจังหวะงาน"], + "problem.review_result": ["需复核处理结果", "Review the processing result", "โปรดตรวจสอบผลการประมวลผล"], + "units.second": ["秒", "sec", "วินาที"], + "units.seconds": ["秒", "sec", "วินาที"], + "units.minute": ["分", "min", "นาที"], + "units.minutes": ["分", "min", "นาที"], + "units.rows": ["条", "records", "รายการ"], + "units.items": ["项", "items", "รายการ"], + "pagination.zero": ["共 0 条", "0 records", "0 รายการ"], + "pagination.summary": ["共 {total} 条 · 本页 {start} 至 {end}", "{total} records · Showing {start}–{end}", "{total} รายการ · แสดง {start}–{end}"], + "pagination.page": ["第 {page} / {pages} 页", "Page {page} of {pages}", "หน้า {page} จาก {pages}"], + "history.pagination": ["{month} · {summary}", "{month} · {summary}", "{month} · {summary}"], + "history.daily_empty": ["{month} · 暂无 Daily Report 记录", "{month} · No Daily Report records", "{month} · ไม่มีรายการ Daily Report"], + "history.monthly_empty": ["{month} · 暂无月报处理记录", "{month} · No monthly processing records", "{month} · ไม่มีรายการประมวลผลรายเดือน"], + "history.company_empty": ["{month} · 暂无公司渠道明细任务", "{month} · No company channel detail jobs", "{month} · ไม่มีงานรายละเอียดช่องทางบริษัท"], + "history.view_recent": ["查看 {month}", "View {month}", "ดู {month}"], + "progress.auto_update": ["自动更新 · {seconds} 秒", "Auto-refresh · {seconds} sec", "อัปเดตอัตโนมัติ · {seconds} วินาที"], + "period.release": ["周期结束:{date} 00:00(曼谷)", "Period ends: {date} 00:00 (Bangkok)", "รอบสิ้นสุด: {date} 00:00 (กรุงเทพฯ)"], + "period.release_hint": ["周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 {date} 00:00", "The period is not complete; current committed data can be used. The Bangkok completion time is {date} 00:00.", "รอบยังไม่สิ้นสุด ใช้ข้อมูลที่บันทึกแล้วสร้างได้ เวลาสิ้นสุดที่กรุงเทพฯ คือ {date} 00:00"], + "company.month_end": ["21-月末", "21-month-end", "21-สิ้นเดือน"], + "company.range_month_end": ["21-{day}", "21-{day}", "21-{day}"], + "company.range_co": ["C/O:{range}", "C/O:{range}", "C/O:{range}"], + "company.month_label": ["{year}年{month}月", "{monthName} {year}", "{monthName} {year}"], + "company.at_period": ["{month},{period}", "{month}, {period}", "{month}, {period}"], + "company.selected_count": ["已选 {count} 条", "{count} selected", "เลือก {count} รายการ"], + "company.deleted_count": ["已删除 {count} 条房型记录", "Deleted {count} room records", "ลบรายการประเภทห้อง {count} รายการแล้ว"], + "company.delete_count": ["删除 {count} 条", "Delete {count}", "ลบ {count} รายการ"], + "company.confirm_single_delete": ["确认删除 {tourCode} 的这条房型记录?", "Delete this room record for {tourCode}?", "ลบรายการประเภทห้องของ {tourCode} หรือไม่"], + "company.confirm_multi_delete": ["确认删除已选择的 {count} 条房型记录?", "Delete the {count} selected room records?", "ลบรายการประเภทห้องที่เลือก {count} รายการหรือไม่"], + "company.selected_file": ["{name} · {size} KB", "{name} · {size} KB", "{name} · {size} KB"], + "task.selected_job": ["任务 {jobId}", "Task {jobId}", "งาน {jobId}"], + "task.view_job": ["查看任务 {jobId} 的全流程日志", "View the full trace for task {jobId}", "ดูบันทึกทั้งหมดของงาน {jobId}"], + "task.copied_lines": ["已复制全部任务日志({count} 行)", "Copied all task logs ({count} lines)", "คัดลอกบันทึกงานทั้งหมดแล้ว ({count} บรรทัด)"], + "bi.data_range": ["数据范围:{start} – {end}", "Data coverage: {start} – {end}", "ช่วงข้อมูล: {start} – {end}"], + "company.warning_items": ["{count} 项", "{count} items", "{count} รายการ"], + "company.period_rows": ["✓ {count} 条", "✓ {count} records", "✓ {count} รายการ"], + "company.period_end_display": ["周期结束:{date} 00:00(曼谷)", "Period ends: {date} 00:00 (Bangkok)", "รอบสิ้นสุด: {date} 00:00 (กรุงเทพฯ)"], + "trace.events": ["0 events", "0 events", "0 เหตุการณ์"], + "trace.events_count": ["{count} events", "{count} events", "{count} เหตุการณ์"], + "trace.failed": ["ERROR", "ERROR", "ERROR"], + "trace.loading": ["LOADING", "LOADING", "LOADING"], + "trace.idle": ["IDLE", "IDLE", "IDLE"], + "trace.failed_status": ["FAILED / {stage} / {code}", "FAILED / {stage} / {code}", "FAILED / {stage} / {code}"], + "trace.running_status": ["RUNNING / auto refresh {seconds}s", "RUNNING / auto refresh {seconds}s", "RUNNING / อัปเดตทุก {seconds}s"], + "trace.succeeded_status": ["SUCCEEDED", "SUCCEEDED", "SUCCEEDED"], + "trace.unknown_status": ["{status}", "{status}", "{status}"], + "h5.snapshot": ["基于已发布月报快照", "Based on the published monthly snapshot", "อ้างอิงสแนปช็อตรายเดือนที่เผยแพร่แล้ว"], + "h5.nights_formula": ["房数 × 晚数", "Rooms × nights", "ห้อง × จำนวนคืน"], + "h5.monthly_sheet": ["月报子表", "Monthly worksheets", "ชีตรายงานประจำเดือน"], + "h5.loading": ["正在读取数据…", "Loading data…", "กำลังโหลดข้อมูล…"], + "h5.login_title": ["ARR Report 登录", "ARR Report login", "เข้าสู่ระบบ ARR Report"], + "login.title": ["登录 · ARR Report", "Log in · ARR Report", "เข้าสู่ระบบ · ARR Report"], + "h5.title": ["渠道BI · ARR Report", "Channel BI · ARR Report", "Channel BI · ARR Report"], + }; + + const ERROR_KEYS = { + LOGIN_FAILED: "error.login_failed", + LOGIN_RATE_LIMITED: "error.login_rate_limited", + AUTH_REQUIRED: "error.auth_required", + SESSION_INVALID: "error.session_invalid", + REQUEST_JSON_INVALID: "error.request_invalid", + REQUEST_TOO_LARGE: "error.request_too_large", + UPLOAD_FILENAME_INVALID: "error.upload_filename", + UPLOAD_EMPTY: "error.upload_empty", + UPLOAD_TOO_LARGE: "error.upload_too_large", + UPLOAD_CONTENT_INVALID: "error.upload_invalid", + JOB_ID_INVALID: "error.job_id", + ROUTE_NOT_FOUND: "error.route_not_found", + STATIC_NOT_FOUND: "error.static_not_found", + QUERY_INVALID: "error.query_invalid", + DATABASE_UNAVAILABLE: "error.database_unavailable", + DATABASE_DRIVER_UNAVAILABLE: "error.database_read_unavailable", + DATABASE_CONFIG_MISSING: "error.database_read_unavailable", + DATABASE_TARGET_INVALID: "error.database_read_unavailable", + DATABASE_QUERY_FAILED: "error.database_read_unavailable", + DOWNLOAD_NOT_FOUND: "error.download_not_found", + DOWNLOAD_REQUEST_INVALID: "error.download_invalid", + DOWNLOAD_REFERENCE_INVALID: "error.download_invalid", + DOWNLOAD_UNAVAILABLE: "error.download_unavailable", + DOWNLOAD_IDENTITY_MISMATCH: "error.download_integrity", + BOOKING_EXCEL_SERVICE_UNAVAILABLE: "error.booking_unavailable", + BOOKING_EXCEL_PROCESSING_FAILED: "error.booking_extract_failed", + BOOKING_EXCEL_REVIEW_OPEN: "error.company_review_open", + BOOKING_EXCEL_SOURCE_REQUIRED: "error.company_source_required", + COMPANY_REPORT_MONTH_IN_FUTURE: "error.company_future", + COMPANY_REPORT_ALREADY_RUNNING: "error.company_running", + COMPANY_REPORT_GENERATOR_UNAVAILABLE: "error.company_unavailable", + COMPANY_REPORT_DOWNLOAD_NOT_FOUND: "error.company_download", + PROGRAMMATIC_PROCESSING_FAILED: "error.processing_failed", + MONTH_INVALID: "error.month_invalid", + MONTH_KEY_INVALID: "error.month_invalid", + CHANNEL_INVALID: "error.channel_invalid", + LOGIN_REQUEST_INVALID: "error.login_request_invalid", + CONTENT_LENGTH_REQUIRED: "error.content_length_required", + INTERNAL_ERROR: "error.internal", + PROCESSING_GATEWAY_UNAVAILABLE: "error.service_unavailable", + MONTHLY_GENERATOR_UNAVAILABLE: "error.service_unavailable", + MONTHLY_REQUEST_INVALID: "error.invalid_request", + MONTHLY_ALREADY_RUNNING: "error.monthly_busy", + MONTHLY_GENERATION_FAILED: "error.monthly_failed", + MONTHLY_NOT_FOUND: "error.monthly_not_found", + BOOKING_EXCEL_FILENAME_INVALID: "error.booking_filename", + BOOKING_EXCEL_REVIEW_REQUEST_INVALID: "error.review_request_invalid", + BOOKING_EXCEL_REVIEW_ITEM_INVALID: "error.review_item_invalid", + COMPANY_REPORT_REQUEST_INVALID: "error.company_request_invalid", + COMPANY_REPORT_PERIOD_INVALID: "error.company_period_invalid", + COMPANY_REPORT_COMPANY_INVALID: "error.invalid_company", + COMPANY_REPORT_JOB_NOT_FOUND: "error.company_job_not_found", + COMPANY_REPORT_DOWNLOAD_INVALID: "error.download_invalid", + DOWNLOAD_TOO_LARGE: "error.download_too_large", + }; + + const sourceToKey = new Map(); + Object.entries(CATALOG).forEach(([key, values]) => { + if (!sourceToKey.has(values[0])) sourceToKey.set(values[0], key); + }); + + let locale = DEFAULT_LOCALE; + let translating = false; + const sourceTextValues = new WeakMap(); + const sourceAttributeValues = new WeakMap(); + + function validLocale(value) { + return Object.prototype.hasOwnProperty.call(LOCALES, value) ? value : null; + } + + function readCookie(name) { + const match = document.cookie.match(new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : ""; + } + + function writeLocaleCookie(value) { + document.cookie = `${COOKIE_KEY}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Strict`; + } + + function resolveLocale() { + let candidate = null; + try { candidate = validLocale(window.localStorage.getItem(STORAGE_KEY)); } catch (_) { /* private mode */ } + return candidate || validLocale(readCookie(COOKIE_KEY)) || DEFAULT_LOCALE; + } + + function currentMeta() { + return LOCALES[locale] || LOCALES[DEFAULT_LOCALE]; + } + + function interpolate(value, params = {}) { + return String(value).replace(/\{([A-Za-z0-9_]+)\}/g, (_, name) => String(params[name] ?? "")); + } + + function t(key, params = {}) { + const entry = CATALOG[key]; + if (!entry) return interpolate(key, params); + const index = locale === "en" ? 1 : locale === "th" ? 2 : 0; + return interpolate(entry[index] || entry[0], params); + } + + function keyForSource(value) { + const normalized = String(value ?? "").trim(); + return sourceToKey.get(normalized) || null; + } + + function patternText(value) { + const source = String(value ?? ""); + const trimmed = source.trim(); + const exact = keyForSource(trimmed); + if (exact) return t(exact); + + let match = /^共\s*([\d,]+)\s*条\s*·\s*本页\s*([\d,]+)\s*至\s*([\d,]+)$/.exec(trimmed); + if (match) return t("pagination.summary", { total: match[1], start: match[2], end: match[3] }); + match = /^第\s*([\d,]+)\s*\/\s*([\d,]+)\s*页$/.exec(trimmed); + if (match) return t("pagination.page", { page: match[1], pages: match[2] }); + match = /^已选\s*([\d,]+)\s*条$/.exec(trimmed); + if (match) return t("company.selected_count", { count: match[1] }); + match = /^已删除\s*([\d,]+)\s*条房型记录$/.exec(trimmed); + if (match) return t("company.deleted_count", { count: match[1] }); + match = /^删除\s*([\d,]+)\s*条$/.exec(trimmed); + if (match) return t("company.delete_count", { count: match[1] }); + match = /^提取完成,\s*([\d,]+)\s*条需要人工确认$/.exec(trimmed); + if (match) return t("company.extract_pending", { count: match[1] }); + match = /^✓\s*([\d,]+)\s*条$/.exec(trimmed); + if (match) return t("company.period_rows", { count: match[1] }); + match = /^([\d,]+)\s*项$/.exec(trimmed); + if (match) return t("company.warning_items", { count: match[1] }); + match = /^自动更新\s*·\s*([\d,]+)\s*秒$/.exec(trimmed); + if (match) return t("progress.auto_update", { seconds: match[1] }); + match = /^周期结束:(.+?)\s*00:00(曼谷)$/.exec(trimmed); + if (match) return t("company.period_end_display", { date: match[1] }); + match = /^周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷\s*(.+?)\s*00:00$/.exec(trimmed); + if (match) return t("period.release_hint", { date: match[1] }); + match = /^(\d{4})年(\d{2})月,(.+)$/.exec(trimmed); + if (match) return t("company.at_period", { month: `${match[1]}年${match[2]}月`, period: match[3] }); + match = /^(\d{4})年(\d{2})月$/.exec(trimmed); + if (match) return t("company.month_label", { year: match[1], month: match[2], monthName: `${match[1]}年${match[2]}月` }); + match = /^任务\s+(.+)$/.exec(trimmed); + if (match) return t("task.selected_job", { jobId: match[1] }); + match = /^查看任务\s+(.+?)\s+的全流程日志$/.exec(trimmed); + if (match) return t("task.view_job", { jobId: match[1] }); + match = /^([\d,]+)\s*秒$/.exec(trimmed); + if (match) return `${match[1]} ${t("units.seconds")}`; + match = /^([\d,]+)\s*分\s*([\d,]+)\s*秒$/.exec(trimmed); + if (match) return `${match[1]} ${t("units.minutes")} ${match[2]} ${t("units.seconds")}`; + return source; + } + + function translateValue(value) { + if (typeof value !== "string") return value; + if (value.includes("\n")) { + return value.split("\n").map((line) => patternText(line)).join("\n"); + } + const leading = value.match(/^\s*/)?.[0] || ""; + const trailing = value.match(/\s*$/)?.[0] || ""; + return `${leading}${patternText(value)}${trailing}`; + } + + function translateAttribute(value) { + const translated = translateValue(value); + if (translated !== value) return translated; + let match = /^ARR Report 首页$/.exec(value); + if (match) return t("brand.home"); + match = /^查看任务\s+(.+?)\s+的全流程日志$/.exec(value); + if (match) return t("task.view_job", { jobId: match[1] }); + match = /^选择\s+(.+)$/.exec(value); + if (match) return `${t("common.select")} ${match[1]}`; + match = /^(.+?)\s+房型$/.exec(value); + if (match) return `${match[1]} ${t("common.room_type")}`; + match = /^(.+?)\s+数量$/.exec(value); + if (match) return `${match[1]} ${t("common.quantity")}`; + match = /^(.+?)\s*·\s*退出登录$/.exec(value); + if (match) return `${match[1]} · ${t("auth.logout")}`; + return value; + } + + function translateDom(root = document) { + if (translating || !root) return; + translating = true; + try { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes = []; + let node; + while ((node = walker.nextNode())) { + const parent = node.parentElement; + if (!parent || ["SCRIPT", "STYLE"].includes(parent.tagName)) continue; + nodes.push(node); + } + nodes.forEach((textNode) => { + const source = sourceTextValues.has(textNode) ? sourceTextValues.get(textNode) : textNode.nodeValue; + sourceTextValues.set(textNode, source); + const value = translateValue(source); + if (value !== textNode.nodeValue) textNode.nodeValue = value; + }); + root.querySelectorAll?.("[aria-label], [title], [placeholder], [aria-valuetext]").forEach((element) => { + ["aria-label", "title", "placeholder", "aria-valuetext"].forEach((attribute) => { + if (!element.hasAttribute(attribute)) return; + const values = sourceAttributeValues.get(element) || new Map(); + const current = element.getAttribute(attribute); + const source = values.has(attribute) ? values.get(attribute) : current; + values.set(attribute, source); + sourceAttributeValues.set(element, values); + const next = translateAttribute(source); + if (next !== current) element.setAttribute(attribute, next); + }); + }); + root.querySelectorAll?.("[data-arr-locale-selector]").forEach((select) => { + select.value = locale; + select.setAttribute("aria-label", t("common.language")); + }); + } finally { + translating = false; + } + } + + function formatInteger(value) { + return new Intl.NumberFormat(currentMeta().intl, { maximumFractionDigits: 0 }).format(Number(value || 0)); + } + + function formatMoney(value, compact = false) { + const amount = Number(value || 0); + const formatter = new Intl.NumberFormat(currentMeta().intl, compact + ? { notation: "compact", maximumFractionDigits: 2 } + : { maximumFractionDigits: 0 }); + return `¥${formatter.format(amount)}`; + } + + function formatDate(value, withTime = false) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return translateValue(String(value)); + return new Intl.DateTimeFormat(currentMeta().intl, withTime + ? { timeZone: "Asia/Bangkok", calendar: "gregory", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" } + : { timeZone: "Asia/Bangkok", calendar: "gregory", year: "numeric", month: "2-digit", day: "2-digit" } + ).format(date); + } + + function formatMonth(value) { + const match = /^(\d{4})-(\d{2})$/.exec(value || ""); + if (!match) return t("common.year_month_unselected"); + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, 1)); + return new Intl.DateTimeFormat(currentMeta().intl, { timeZone: "UTC", calendar: "gregory", year: "numeric", month: "long" }).format(date); + } + + function formatDurationSeconds(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds)) return t("common.none"); + if (seconds < 60) return `${Math.max(1, Math.round(seconds))} ${t("units.seconds")}`; + return `${Math.floor(seconds / 60)} ${t("units.minutes")} ${Math.round(seconds % 60)} ${t("units.seconds")}`; + } + + function errorMessage(code, fallback = "") { + const normalizedCode = String(code || ""); + const key = ERROR_KEYS[normalizedCode] || ( + normalizedCode.endsWith("_NOT_FOUND") ? "error.not_found" + : normalizedCode.includes("UNAVAILABLE") || normalizedCode.endsWith("_TIMEOUT") ? "error.service_unavailable" + : normalizedCode.endsWith("_INVALID") || normalizedCode.includes("REQUEST") ? "error.invalid_request" + : normalizedCode.endsWith("_FAILED") || normalizedCode.endsWith("_ERROR") ? "error.generic" + : null + ); + if (key) return t(key); + const translated = fallback ? translateValue(fallback) : ""; + const containsCjk = /[\u4e00-\u9fff]/.test(translated); + const containsThai = /[\u0e00-\u0e7f]/.test(translated); + if (translated && locale === DEFAULT_LOCALE) return translated; + if (translated && locale === "en" && !containsCjk && !containsThai) return translated; + if (translated && locale === "th" && !containsCjk && containsThai) return translated; + return t("error.generic"); + } + + function setLocale(next) { + const resolved = validLocale(next) || DEFAULT_LOCALE; + locale = resolved; + try { window.localStorage.setItem(STORAGE_KEY, resolved); } catch (_) { /* private mode */ } + writeLocaleCookie(resolved); + document.documentElement.lang = resolved; + translateDom(); + document.dispatchEvent(new CustomEvent("arr:locale-change", { detail: { locale: resolved } })); + } + + function bindSelectors() { + document.querySelectorAll("[data-arr-locale-selector]").forEach((select) => { + select.value = locale; + select.addEventListener("change", (event) => setLocale(event.currentTarget.value)); + }); + } + + function init() { + locale = resolveLocale(); + document.documentElement.lang = locale; + bindSelectors(); + translateDom(); + const observer = new MutationObserver((mutations) => { + if (locale === DEFAULT_LOCALE || translating) return; + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) translateDom(node); + else if (node.nodeType === Node.TEXT_NODE) { + sourceTextValues.set(node, node.nodeValue); + node.nodeValue = translateValue(node.nodeValue); + } + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + window.ARRI18n = { + CATALOG, + LOCALES, + get locale() { return locale; }, + t, + text: translateValue, + translateDom, + errorMessage, + formatInteger, + formatMoney, + formatDate, + formatMonth, + formatDurationSeconds, + setLocale, + }; + + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true }); + else init(); +})(); diff --git a/arr_web/static/index.html b/arr_web/static/index.html index 6740d1c..f685f5b 100644 --- a/arr_web/static/index.html +++ b/arr_web/static/index.html @@ -6,6 +6,7 @@ ARR Report + @@ -16,9 +17,17 @@ ARR Report
+ 正在连接数据服务 - - + 任务日志 +
@@ -87,7 +96,16 @@

Daily Report

- +
+
+ 业务月份 + + + + +
+ +
@@ -110,7 +128,16 @@

月报处理

- 自动更新 · 4 秒 +
+
+ 查看月份 + + + + +
+ 自动更新 · 4 秒 +
@@ -130,18 +157,13 @@
diff --git a/arr_web/static/login.css b/arr_web/static/login.css index 1e97a18..260b380 100644 --- a/arr_web/static/login.css +++ b/arr_web/static/login.css @@ -33,7 +33,7 @@ body { radial-gradient(circle at 9% 12%, rgba(37, 99, 235, .11), transparent 26rem), radial-gradient(circle at 92% 88%, rgba(16, 28, 46, .08), transparent 30rem), var(--page); - font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans Thai", "Leelawadee UI", Tahoma, "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; @@ -71,6 +71,10 @@ button { color: inherit; } animation: shell-in .42s ease-out both; } +.login-locale-switcher { position: absolute; z-index: 5; top: 18px; right: 18px; } +.login-locale-switcher select { min-height: 34px; padding: 0 26px 0 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: rgba(255, 255, 255, .94); font-size: 12px; font-weight: 650; outline: none; } +.login-locale-switcher select:focus-visible { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37, 99, 235, .12); } + .login-context { position: relative; min-width: 0; @@ -238,6 +242,8 @@ button { color: inherit; } .context-copy h1 { margin-top: 12px; font-size: 42px; } .login-panel { min-height: calc(100dvh - 176px); padding: 48px 24px calc(32px + env(safe-area-inset-bottom)); } .form-wrap { margin-top: 0; } + .login-locale-switcher { top: calc(10px + env(safe-area-inset-top)); right: 14px; } + .login-locale-switcher select { min-height: 31px; font-size: 10px; } } @media (prefers-reduced-motion: reduce) { diff --git a/arr_web/static/login.html b/arr_web/static/login.html index cafaf47..7a793a3 100644 --- a/arr_web/static/login.html +++ b/arr_web/static/login.html @@ -6,12 +6,21 @@ 登录 · ARR Report +
+