From ea63cd9931aba77bd3052d1365424a1c9ef2d6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Mon, 21 Sep 2026 14:13:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 10 +- DEPLOY.md | 117 +++++++++++++++- docker-compose.yml | 7 +- entrypoint.sh | 13 +- frontend/nginx.conf | 4 +- .../src/components/business/StatusBadge.vue | 2 +- frontend/src/stores/logs.js | 16 ++- frontend/src/stores/logs.test.js | 66 +++++++++ frontend/src/utils/datetime.js | 28 ++++ frontend/src/utils/datetime.test.js | 73 ++++++++++ frontend/src/views/Application.vue | 11 +- frontend/src/views/Approval.test.js | 91 +++++++++++++ frontend/src/views/Approval.vue | 17 ++- frontend/src/views/Inbound.vue | 14 +- frontend/src/views/Logs.vue | 45 +++---- frontend/src/views/Materials.vue | 24 ++-- frontend/src/views/Outbound.vue | 8 +- frontend/src/views/Stock.test.js | 125 +++++++++++++++++ frontend/src/views/Stock.vue | 16 +-- frontend/src/views/Users.vue | 13 +- .../views/inventory/WarehouseSettings.test.js | 111 +++++++++++++++ .../src/views/inventory/WarehouseSettings.vue | 11 +- .../master-data/MasterDataSettings.test.js | 119 ++++++++++++++++- .../views/master-data/MasterDataSettings.vue | 17 ++- .../views/organizations/Organizations.test.js | 126 ++++++++++++++++++ .../src/views/organizations/Organizations.vue | 11 +- .../src/views/organizations/Roles.test.js | 89 ++++++++++++- frontend/src/views/organizations/Roles.vue | 3 +- .../src/views/partners/PartnerList.test.js | 125 +++++++++++++++++ frontend/src/views/partners/PartnerList.vue | 11 +- internal/model/model.go | 54 ++++---- internal/repository/application.go | 14 +- internal/repository/inbound.go | 2 + internal/repository/outbound.go | 2 + internal/service/application.go | 7 +- internal/service/material_log.go | 89 ++++++++++++- internal/service/material_log_test.go | 91 +++++++++++++ 37 files changed, 1428 insertions(+), 154 deletions(-) create mode 100644 frontend/src/stores/logs.test.js create mode 100644 frontend/src/utils/datetime.js create mode 100644 frontend/src/utils/datetime.test.js create mode 100644 frontend/src/views/Approval.test.js create mode 100644 frontend/src/views/Stock.test.js create mode 100644 frontend/src/views/inventory/WarehouseSettings.test.js create mode 100644 frontend/src/views/organizations/Organizations.test.js create mode 100644 frontend/src/views/partners/PartnerList.test.js create mode 100644 internal/service/material_log_test.go diff --git a/.dockerignore b/.dockerignore index 3bd323f6..1b79b5ff 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,9 +10,17 @@ # Build outputs (rebuilt inside container) frontend/dist +server.exe + +# Runtime data (mounted as a volume, must not be baked into the image) +uploads + +# Editor history snapshots +.history # Dependencies (reinstalled inside container) -node_modules +# 用 **/ 前缀:不带斜杠的模式只匹配根级目录,拦不住 frontend/node_modules。 +**/node_modules vendor # Recovery files diff --git a/DEPLOY.md b/DEPLOY.md index 1f7454eb..f7a1d629 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -31,9 +31,46 @@ docker-compose ps ### 访问应用 - 前端地址: http://localhost -- 后端API: http://localhost:8082/api +- 后端API: http://localhost:8082/api (绑定在 `127.0.0.1`,仅限本机) - 健康检查: http://localhost:8082/api/health +### 用手机 / 局域网其他设备访问 + +手机连上**与电脑同一个 WiFi**,浏览器打开 `http://<电脑的局域网IP>`,用下节账号登录即可,**不需要带端口号**(80 是 HTTP 的默认端口,浏览器会自动使用)。 + +先查看电脑的局域网 IP —— 取 `WLAN` 那一行的地址,忽略 `vEthernet (WSL)`、`VMware Network Adapter` 这些虚拟网卡: + +```bash +ipconfig +``` + +例如地址是 `192.168.31.76`,手机就访问 `http://192.168.31.76`。 + +**为什么不需要改配置**:前端请求走相对路径 `/api`,手机访问时会自动打到 `http://<电脑IP>/api`,再由 Nginx 反代给后端容器。前后端都没有硬编码 `localhost`,所以换任何 IP 访问都能正常工作。 + +#### 端口绑定策略 + +| 服务 | 绑定地址 | 局域网可访问 | 原因 | +|------|----------|--------------|------| +| frontend | `0.0.0.0:80` | ✅ 是 | 手机、其他设备要打开网页 | +| backend | `127.0.0.1:8082` | ❌ 否 | 前端经 Nginx 走**容器内部网络**反代(`proxy_pass http://backend:8082`),不经过宿主机端口 | +| db | `127.0.0.1:3307` | ❌ 否 | 数据库不应暴露;本机仍可用客户端连 3307 调试 | + +后端和数据库绑定在 `127.0.0.1` 是**有意的安全设置**,只允许本机访问。若你临时需要让别的机器直连后端 API 调试,把 `docker-compose.yml` 里对应那行改回 `"8082:8082"`,再执行 `docker compose up -d`(改完记得改回去)。 + +#### 手机打不开时怎么查 + +1. **确认同一个 WiFi**:手机和电脑必须是同一个 SSID。 +2. **WiFi 的客户端隔离(AP 隔离)**:单位、公共场所的网络常开启此功能,导致设备之间无法互访。这属于网络策略,与本项目无关 —— 用手机开热点让电脑连上试一次即可确认。 +3. **Windows 防火墙**:需放行 80 端口入站。Docker Desktop 安装时会按其网络类别自动添加放行规则,若仍不通,用管理员 PowerShell 临时加一条: + ```powershell + New-NetFirewallRule -DisplayName "WMMS 80" -Direction Inbound -LocalPort 80 -Protocol TCP -Action Allow + # 测试完删除:Remove-NetFirewallRule -DisplayName "WMMS 80" + ``` +4. **IP 会变**:`192.168.x.x` 由 DHCP 分配,路由器重启后可能变化。想长期使用,建议在路由器上给这台电脑做静态 IP 绑定。 + +> ⚠️ 数据库使用默认密码 `wmms1234`,后端 API 也没有额外的访问控制,因此**不要把 8082 / 3307 暴露到公网**。 + ### 默认用户 | 用户名 | 密码 | 角色 | @@ -65,6 +102,54 @@ docker-compose down -v - **MySQL数据**: `mysql_data` Docker volume - **上传文件**: `uploads_data` Docker volume +### ⚠️ 数据卷复用会导致表结构落后于代码 + +MySQL 官方镜像**只在数据目录为空时**才执行 `/docker-entrypoint-initdb.d/` 下的脚本。也就是说 `scripts/init.sql` **只在 `mysql_data` 卷第一次创建时执行一次**;只要卷还在,后续 `docker compose up -d` 一律直接复用旧数据,`init.sql` 完全不执行。 + +`docker compose down` **不带 `-v` 时卷会保留**,所以改过 schema 之后重新部署,新代码会跑在旧表结构上。典型症状是**容器全部 healthy、日志干净,但部分接口返回 500**: + +``` +Error 1054 (42S22): Unknown column 'inbound_no' in 'field list' +``` + +**部署前先确认卷的年龄**: + +```bash +docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}' +``` + +把结果和最近一次改 schema 的提交时间对比:卷比代码新就没问题,卷比代码旧就要按下节处理。 + +**方式一:清空重建(推荐,最简单可靠)** + +```bash +docker compose down -v && docker compose up -d --build +``` + +`-v` 会删除 `mysql_data` 和 `uploads_data` 两个卷,**库中数据全部丢失**。执行前务必确认没有需要保留的业务数据,必要时先备份: + +```bash +docker compose exec db mysqldump -uroot -pwmms1234 --default-character-set=utf8mb4 wmms > backup.sql +``` + +**方式二:跑增量脚本(需要保留数据时)** + +`scripts/` 下有三个手工迁移脚本,按需执行: + +| 脚本 | 作用 | 是否破坏数据 | +|------|------|--------------| +| `add_order_display_fields.sql` | 出入库单补 `inbound_no` / `outbound_no` / `operator` / `remark` | 否,仅加列并回填单号 | +| `update_material_categories.sql` | 重建分类树并写入编码规则 | **是**,会清空物料、单据、库存、流水 | +| `update_warning_stock_zero.sql` | 清理阈值为 0 的历史预警规则 | **是**,会删除这些规则行 | + +执行方式: + +```bash +docker compose exec -T db mysql -uroot -pwmms1234 --default-character-set=utf8mb4 wmms < scripts/xxx.sql +``` + +执行前请先备份,并阅读脚本头部的说明。 + ## 环境变量 | 变量 | 默认值 | 说明 | @@ -109,6 +194,36 @@ docker-compose logs db # 等待数据库启动完成(约10秒) ``` +### 接口返回 500:Unknown column 'xxx' + +**症状**:容器状态全部 healthy,`docker compose logs` 也看不到异常,但某些接口返回 500,例如: + +``` +Error 1054 (42S22): Unknown column 'description' in 'field list' +``` + +**原因**:`mysql_data` 卷是旧的,`init.sql` 没有执行,表结构落后于当前代码。详见上文《数据持久化 → ⚠️ 数据卷复用会导致表结构落后于代码》。 + +**确认**: + +```bash +docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}' +``` + +如果卷的创建时间早于最近一次改 schema 的提交,就是这个原因。 + +**解决**:确认库里无业务数据后执行 `docker compose down -v && docker compose up -d --build` 重建;需要保留数据则改跑 `scripts/` 下的增量脚本。 + +### 其他排查技巧 + +**哪些表结构会不一致?** 直接在容器里对比: + +```bash +# 看当前库到底建了哪些表和列 +docker compose exec db mysql -uroot -pwmms1234 -e "SHOW TABLES;" wmms +docker compose exec db mysql -uroot -pwmms1234 -e "SHOW COLUMNS FROM inbound_orders;" wmms +``` + ### 查看容器内部 ```bash diff --git a/docker-compose.yml b/docker-compose.yml index eb704717..49387a51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,8 @@ services: MYSQL_ROOT_PASSWORD: wmms1234 MYSQL_DATABASE: wmms ports: - - "3307:3306" + # 只绑本机回环:数据库不对局域网暴露。容器之间仍走内部网络,互不影响。 + - "127.0.0.1:3307:3306" volumes: - mysql_data:/var/lib/mysql - ./scripts/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro @@ -30,7 +31,9 @@ services: container_name: wmms-backend restart: unless-stopped ports: - - "8082:8082" + # 只绑本机回环:后端 API 不对局域网暴露。前端经 Nginx 走容器内部网络 + # 反代(proxy_pass http://backend:8082),不经过宿主机端口,故不受影响。 + - "127.0.0.1:8082:8082" environment: DB_DSN: "root:wmms1234@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true" GIN_MODE: release diff --git a/entrypoint.sh b/entrypoint.sh index f5d09d91..99049cc3 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,17 +7,8 @@ until mysql --default-character-set=utf8mb4 -h db -uroot -pwmms1234 --silent 2>/ done echo "MySQL is ready." -# Only run init scripts if they haven't been run yet -if [ ! -f /app/.init_done ]; then - echo "Running init scripts..." - for f in $(ls /docker-entrypoint-initdb.d/*.sql 2>/dev/null | sort); do - echo "Executing: $f" - mysql --default-character-set=utf8mb4 -h db -uroot -pwmms1234 wmms < "$f" && echo "OK: $(basename $f)" || { echo "FAILED: $(basename $f)"; cat /tmp/mysql_err.log 2>/dev/null; } - done - touch /app/.init_done -else - echo "Init already done, skipping." -fi +# 建表和初始数据由 db 容器在数据卷为空时执行 /docker-entrypoint-initdb.d/001-init.sql 完成。 +# 该目录只挂载在 db 容器上,本容器内不存在,故此处不再重复执行初始化脚本。 echo "Starting application..." exec "$@" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a983dc2a..fb0dfd4b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,9 +4,9 @@ server { root /usr/share/nginx/html; index index.html; - # Force UTF-8 for all text + # 统一以 UTF-8 返回文本。字符集由 charset 指令加到各资源自身的 MIME 类型上, + # 不能再 add_header Content-Type,否则会把 js/css 的 MIME 也覆盖成 text/html。 charset utf-8; - add_header Content-Type "text/html; charset=utf-8"; # Gzip compression gzip on; diff --git a/frontend/src/components/business/StatusBadge.vue b/frontend/src/components/business/StatusBadge.vue index 36d135a8..7459aa11 100644 --- a/frontend/src/components/business/StatusBadge.vue +++ b/frontend/src/components/business/StatusBadge.vue @@ -22,7 +22,7 @@ const statusMap = { inbound: { type: 'success', label: '入库' }, outbound: { type: 'warning', label: '出库' }, stock_check: { type: 'info', label: '盘点' }, - INSERT: { type: 'success', label: '新增' }, + CREATE: { type: 'success', label: '新增' }, UPDATE: { type: 'primary', label: '更新' }, DELETE: { type: 'danger', label: '删除' } } diff --git a/frontend/src/stores/logs.js b/frontend/src/stores/logs.js index 4e923650..7fb0c3f5 100644 --- a/frontend/src/stores/logs.js +++ b/frontend/src/stores/logs.js @@ -13,7 +13,21 @@ export const useLogsStore = defineStore('logs', () => { try { const pageSize = page.value.pageSize const offset = (pageNum - 1) * pageSize - const params = { limit: pageSize, offset, ...filters.value } + const params = { limit: pageSize, offset } + // 后端只认 snake_case 的查询参数,这里是前后端字段名唯一的转换点。 + // 空值不下发,避免拼出 operation_type= 这类无意义参数(后端按空串跳过条件, + // 但空参数会让日志和抓包难以阅读)。 + const queryParams = { + operation_type: filters.value.operationType, + start_date: filters.value.startDate, + end_date: filters.value.endDate, + material_id: filters.value.materialId + } + for (const [key, value] of Object.entries(queryParams)) { + if (value !== '' && value !== null && value !== undefined) { + params[key] = value + } + } const res = await logsApi.getAll(params) logs.value = res.data?.items || res.data || [] page.value.total = res.data?.total || logs.value.length diff --git a/frontend/src/stores/logs.test.js b/frontend/src/stores/logs.test.js new file mode 100644 index 00000000..5101651f --- /dev/null +++ b/frontend/src/stores/logs.test.js @@ -0,0 +1,66 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logsApi } from '../api/logs' +import { useLogsStore } from './logs' + +vi.mock('../api/logs', () => ({ + logsApi: { getAll: vi.fn() } +})) + +describe('logs store 查询参数', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + logsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + }) + + // 后端按 snake_case 读查询参数(operation_type / material_id / start_date / end_date), + // 前端内部用 camelCase。这里少转一个字段,对应的筛选器就会静默失效—— + // 不报错,只是点了查询没反应,所以要有测试盯着。 + it('把筛选条件转成后端认的 snake_case 参数', async () => { + const store = useLogsStore() + store.setFilters({ + operationType: 'CREATE', + startDate: '2026-09-01', + endDate: '2026-09-21', + materialId: 7 + }) + + await store.fetchLogs() + + expect(logsApi.getAll).toHaveBeenCalledWith({ + limit: 20, + offset: 0, + operation_type: 'CREATE', + start_date: '2026-09-01', + end_date: '2026-09-21', + material_id: 7 + }) + }) + + it('筛选条件为空时不下发对应参数', async () => { + const store = useLogsStore() + + await store.fetchLogs() + + expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 }) + }) + + it('翻页时按页大小算出 offset', async () => { + const store = useLogsStore() + + await store.fetchLogs(3) + + expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 40 }) + }) + + it('重置筛选条件后不再下发任何筛选项', async () => { + const store = useLogsStore() + store.setFilters({ operationType: 'DELETE', materialId: 7 }) + + store.resetFilters() + await store.fetchLogs() + + expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 }) + }) +}) diff --git a/frontend/src/utils/datetime.js b/frontend/src/utils/datetime.js new file mode 100644 index 00000000..815f9dd2 --- /dev/null +++ b/frontend/src/utils/datetime.js @@ -0,0 +1,28 @@ +// 需要按「无」处理的时间前缀: +// - 0001-01-01 是 Go time.Time 的零值,JSON 序列化后为 0001-01-01T00:00:00Z。 +// encoding/json 的 omitempty 对 time.Time 这类结构体不生效,字段会照常输出, +// 不拦的话界面上会渲染成 0001/1/1。 +// - 0000-00-00 是 MySQL 的零日期。 +const ZERO_DATE_PREFIXES = ['0001-01-01', '0000-00-00'] + +// 格式化时间用于界面展示:空值、零值时间和无法解析的值统一返回 '-'。 +export function formatDate(value, options) { + // 只把 null / undefined / 空串当空值:0 是合法时间戳(1970-01-01),用 !value 会误伤 + if (value === null || value === undefined || value === '') return '-' + if (typeof value === 'string' && ZERO_DATE_PREFIXES.some((prefix) => value.startsWith(prefix))) return '-' + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return '-' + return date.toLocaleString('zh-CN', options) +} + +// 把日期对象转成查询参数用的 YYYY-MM-DD。 +// 不能用 toISOString() 裁前 10 位:它按 UTC 取日期,东八区选的 9/21 00:00 +// 会被算成 9/20,查询范围整整偏一天。日期结束时刻由后端的 normalizeEndDate +// 统一补成当天 23:59:59,前端只传日期即可。 +export function toDateParam(value) { + if (!value) return '' + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return '' + const pad = (num) => String(num).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} diff --git a/frontend/src/utils/datetime.test.js b/frontend/src/utils/datetime.test.js new file mode 100644 index 00000000..2a8dbfe6 --- /dev/null +++ b/frontend/src/utils/datetime.test.js @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest' +import { formatDate, toDateParam } from './datetime' + +describe('时间展示格式化', () => { + test('空值显示为 -', () => { + expect(formatDate(null)).toBe('-') + expect(formatDate(undefined)).toBe('-') + expect(formatDate('')).toBe('-') + }) + + test('Go 的零值时间显示为 -', () => { + // 后端 time.Time 零值序列化后的形态,不能落成 0001/1/1 + expect(formatDate('0001-01-01T00:00:00Z')).toBe('-') + expect(formatDate('0001-01-01 00:00:00')).toBe('-') + }) + + test('MySQL 的零日期显示为 -', () => { + expect(formatDate('0000-00-00 00:00:00')).toBe('-') + expect(formatDate('0000-00-00')).toBe('-') + }) + + test('无法解析的值显示为 -', () => { + expect(formatDate('not-a-date')).toBe('-') + }) + + test('正常值按本地时区展示', () => { + const value = '2026-09-21T10:30:00Z' + expect(formatDate(value)).toBe(new Date(value).toLocaleString('zh-CN')) + }) + + test('传入自定义格式选项时按选项展示', () => { + const value = '2026-09-21T10:30:00Z' + const options = { year: 'numeric', month: '2-digit', day: '2-digit' } + expect(formatDate(value, options)).toBe(new Date(value).toLocaleString('zh-CN', options)) + }) + + test('接受 Date 对象', () => { + const value = new Date('2026-09-21T10:30:00Z') + expect(formatDate(value)).toBe(value.toLocaleString('zh-CN')) + }) + + test('时间戳为 0 时仍按正常时间展示,不当作空值', () => { + // 0 是合法时间戳(1970-01-01),只有 falsy 才兜底会误伤它 + const value = new Date(0) + expect(formatDate(0)).toBe(value.toLocaleString('zh-CN')) + }) +}) + +describe('查询参数日期格式化', () => { + test('空值返回空串', () => { + expect(toDateParam(null)).toBe('') + expect(toDateParam(undefined)).toBe('') + expect(toDateParam('')).toBe('') + }) + + test('无法解析的值返回空串', () => { + expect(toDateParam('not-a-date')).toBe('') + }) + + test('按本地时区取日期,不因 UTC 换算偏一天', () => { + // 本地时间当天 00:00。东八区这一刻的 UTC 还是前一天 16:00, + // 若改用 toISOString() 裁前 10 位会得到 9/20,查询范围整整偏一天。 + expect(toDateParam(new Date(2026, 8, 21))).toBe('2026-09-21') + }) + + test('月日补零成两位', () => { + expect(toDateParam(new Date(2026, 0, 5))).toBe('2026-01-05') + }) + + test('接受日期字符串', () => { + expect(toDateParam(new Date(2026, 8, 21).toISOString())).toBe('2026-09-21') + }) +}) diff --git a/frontend/src/views/Application.vue b/frontend/src/views/Application.vue index aef3914a..c85827b9 100644 --- a/frontend/src/views/Application.vue +++ b/frontend/src/views/Application.vue @@ -3,8 +3,8 @@ - - + +
@@ -64,7 +64,6 @@ :total="applicationStore.myPage.total" @page-change="handlePageChange" > -