Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 337a358d54 | |||
| c98a90472b | |||
| 207a9a8343 | |||
| d8c0bd74d2 | |||
| 056ae914ce | |||
| 022443476c | |||
| 93f4f87e44 | |||
| 20e7d07be3 | |||
| 1834740676 | |||
| 90a194fc00 | |||
| fba8b0a917 | |||
| c001f0e8e6 | |||
| f039bd0c61 | |||
| f4beb90769 | |||
| 6f0b273950 | |||
| b43601b4fd | |||
| 3aa63d6575 | |||
| a3f9f091b4 | |||
| b3f5a0f083 | |||
| 0af92e1732 | |||
| ea5526944b | |||
| c2f8bda4f3 | |||
| 7f6584ab64 | |||
| 23de243598 | |||
| 65e2021138 | |||
| d94c6b109b | |||
| 75e3b934bc | |||
| ec7596e272 | |||
| 9fc379f33a | |||
| ee826eca7f | |||
| 51863dcbd3 | |||
| eda7d37b64 | |||
| 471b980179 | |||
| 3d1fc0d4ba | |||
| b6223b3816 | |||
| 117cec431b | |||
| d8295d14da | |||
| 5282eb57c9 | |||
| f59bdf056f | |||
| 01946282ed | |||
| 0d4c9cced2 | |||
| e9a6543a8b | |||
| 5f2248001d | |||
| 2dbc89b902 | |||
| 44ffedb56a | |||
| 93878511e5 | |||
| 503ee7372e | |||
| 62dc54fe1c |
@@ -0,0 +1,76 @@
|
||||
执行本地发版流程,版本号为 $ARGUMENTS。
|
||||
|
||||
按以下步骤顺序执行,任意步骤失败立即停止并报告错误。
|
||||
|
||||
## 1. 检查工作区
|
||||
|
||||
检查是否有未提交的改动(`git status`)。如果有,列出文件并询问用户是否继续。
|
||||
|
||||
## 2. 本地构建
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd backend && go build ./...
|
||||
```
|
||||
|
||||
## 3. 运行测试
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd backend && go test ./...
|
||||
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
```
|
||||
|
||||
## 4. 更新 CHANGELOG.md
|
||||
|
||||
检查 CHANGELOG.md 中是否已有 `## [$ARGUMENTS]` 版本节。
|
||||
|
||||
- **已有**:直接使用,不修改。
|
||||
- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入到文件顶部(位于已有版本节之前),格式如下:
|
||||
|
||||
```
|
||||
## [$ARGUMENTS] - <今天日期>
|
||||
|
||||
### 新功能
|
||||
- ...
|
||||
|
||||
### 改进
|
||||
- ...
|
||||
|
||||
### 修复
|
||||
- ...
|
||||
```
|
||||
|
||||
只保留有内容的分类(新功能/改进/修复),根据 commit message 的类型(feat/fix/refactor 等)归类。
|
||||
|
||||
**CHANGELOG 撰写原则:**
|
||||
- 站在**用户视角**总结,描述对使用体验的实际影响,而非技术实现
|
||||
- **忽略**以下类型的提交(产品感知弱):
|
||||
- 发版工具、CI/CD、部署脚本相关(chore: release、ci:、devops:)
|
||||
- 内部文档、开发规范、CLAUDE.md 更新(docs(claude):、docs(internal):)
|
||||
- 代码格式、lint、重构(style:、refactor: 纯内部)
|
||||
- 依赖升级、版本号 bump(chore: bump、chore: upgrade)
|
||||
- **保留并重点描述**:feat、fix、perf 类,以及用户能感知到的 refactor(如界面改版、交互优化)
|
||||
- 每条描述用一句话说清楚"用户能做什么"或"修了什么问题",不堆砌技术术语
|
||||
|
||||
## 5. 提交代码
|
||||
|
||||
将所有改动(包括 CHANGELOG.md)用以下格式提交到 main:
|
||||
|
||||
```
|
||||
chore: release v$ARGUMENTS
|
||||
```
|
||||
|
||||
## 6. 打 tag
|
||||
|
||||
```bash
|
||||
git tag v$ARGUMENTS
|
||||
```
|
||||
|
||||
## 7. 推送
|
||||
|
||||
```bash
|
||||
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main v$ARGUMENTS
|
||||
```
|
||||
|
||||
推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。
|
||||
@@ -1,8 +1,9 @@
|
||||
name: DB Backup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *' # UTC 18:00 = 北京时间 02:00
|
||||
# 定时备份已暂停(保留手动触发)。恢复时取消下面 schedule 的注释即可。
|
||||
# schedule:
|
||||
# - cron: '0 18 * * *' # UTC 18:00 = 北京时间 02:00
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Go tests
|
||||
working-directory: backend
|
||||
run: go test ./...
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Flutter tests
|
||||
working-directory: client
|
||||
run: flutter test
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- 'v[0-9]*.[0-9]*.[0-9]*'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
build-linux-web:
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
@@ -16,9 +16,95 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compile
|
||||
- name: Provision (idempotent — auto-runs if host not initialized)
|
||||
run: sh scripts/ci/provision-mac.sh
|
||||
|
||||
- name: Compile (Linux backend + Flutter Web)
|
||||
run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload linux-web artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux-web
|
||||
path: dist/
|
||||
|
||||
build-macos:
|
||||
# Same physical runner as build-linux-web (label: mac, capacity 1). Run them
|
||||
# serially via needs so the second job doesn't sit queued and time out.
|
||||
needs: build-linux-web
|
||||
runs-on: mac
|
||||
env:
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Provision (idempotent — auto-runs if host not initialized)
|
||||
run: sh scripts/ci/provision-mac.sh
|
||||
|
||||
- name: Compile (Flutter macOS)
|
||||
run: sh scripts/ci/compile-macos.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload macos artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: macos
|
||||
path: dist/
|
||||
|
||||
build-windows:
|
||||
runs-on: windows
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Provision (idempotent — auto-runs if host not initialized)
|
||||
shell: bash
|
||||
run: sh scripts/ci/provision-windows.sh
|
||||
|
||||
- name: Compile (Flutter Windows)
|
||||
shell: bash
|
||||
run: sh scripts/ci/compile-windows.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload windows artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/
|
||||
|
||||
release-deploy:
|
||||
needs: [build-linux-web, build-macos, build-windows]
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download linux-web artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: linux-web
|
||||
path: dist/
|
||||
|
||||
- name: Download macos artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: macos
|
||||
path: dist/
|
||||
|
||||
- name: Download windows artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/
|
||||
|
||||
- name: Test
|
||||
run: sh scripts/ci/test.sh
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ client/devtools_options.yaml
|
||||
|
||||
# Flutter 构建产物
|
||||
client/build/
|
||||
|
||||
# CI 构建产物
|
||||
dist/
|
||||
|
||||
# 营销站构建产物
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
client/.dart_tool/
|
||||
client/.flutter-plugins
|
||||
client/.flutter-plugins-dependencies
|
||||
|
||||
@@ -5,6 +5,91 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.16] - 2026-06-05
|
||||
|
||||
### 新功能
|
||||
- 应用内更新:Windows/macOS 点「立即更新」直接在应用内下载并自动安装/重启(带进度条),规避浏览器 SmartScreen 下载拦截;Web 为刷新页面
|
||||
- Windows 安装程序改为中文向导
|
||||
|
||||
### 改进
|
||||
- 「检查更新」按钮增加反馈提示(正在检查 / 已是最新 / 发现新版本 / 检查失败)
|
||||
- 更新提示文案优化:横幅显示「建议立即更新以获得最新功能与修复」,不再展示冗长更新内容
|
||||
|
||||
## [1.0.15] - 2026-06-05
|
||||
|
||||
### 修复
|
||||
- 修复 Windows 桌面端打印卡死:打印字体由 OpenType/CFF 改为 TrueType(glyf),dart_pdf 渲染中文不再抛错(此前点「打印」后卡死)
|
||||
|
||||
### 改进
|
||||
- 「关于我们」内容改为真实信息(服务商:岩美技术有限公司;网站:jiu.yanmei.com;邮箱:yammy2023@163.com),且统一从配置文件读取,不再硬编码
|
||||
- Windows 桌面端窗口标题由 jiu_client 改为「岩美酒库」
|
||||
|
||||
## [1.0.13] - 2026-06-04
|
||||
|
||||
### 新功能
|
||||
- Windows 客户端改为提供安装程序(Inno Setup 打包的 `jiu-windows-x64-setup.exe`,含安装向导、桌面/开始菜单快捷方式、卸载项),不再是解压版 zip
|
||||
|
||||
### 修复
|
||||
- 修复客户端「无法安全下载」:下载地址从内网 HTTP Forgejo(`http://192.168.3.200:3000`,公网不可达且被浏览器拦截)改为同源 HTTPS `https://jiu.51yanmei.com/downloads/...`,Windows、macOS 均生效
|
||||
- 部署时将最新安装包发布到 web 服务(`/opt/jiu/downloads/`,nginx `/downloads/` 提供),仅保留最新版本(清旧放新)
|
||||
|
||||
## [1.0.9] - 2026-06-04
|
||||
|
||||
### 修复
|
||||
- 适配 nginx 容器化部署:nginx 已迁入 `pangolin-edge` 容器(host 网络),deploy 改为将 jiu.conf 写入 pangolin 挂载目录并 `docker exec pangolin-edge nginx -s reload`,不再操作已废弃的主机 nginx
|
||||
|
||||
## [1.0.8] - 2026-06-03
|
||||
|
||||
### 修复
|
||||
- 修复 EC2 部署最后一步 nginx reload 失败(`invalid PID number`):改用 `systemctl reload-or-restart nginx`(经 systemd MAINPID 发 HUP,不依赖空的 /run/nginx.pid),并 `enable nginx` 确保重启后自启
|
||||
|
||||
## [1.0.7] - 2026-06-03
|
||||
|
||||
### 修复
|
||||
- 修复 Windows 构建 `PathExistsException`(errno 183):复制 client/ 到 C:\jiu-build 后,先清除 cp 复制成真实目录的插件软链(windows/flutter/ephemeral)及 .dart_tool/build,再交给 flutter 干净重建
|
||||
|
||||
## [1.0.6] - 2026-06-03
|
||||
|
||||
### 改进
|
||||
- CI 运行环境自愈预置:mac/windows runner 首次构建自动 `flutter precache` 并预热 pub/go/npm 缓存(stamp 文件按 Flutter 版本短路),后续构建不再运行时下载 engine
|
||||
- 发版流水线同一 mac runner 上的任务改为串行(build-macos 依赖 build-linux-web),避免排队等待超时失败
|
||||
- 暂停定时数据库备份(保留手动触发)
|
||||
|
||||
## [1.0.5] - 2026-06-03
|
||||
|
||||
### 修复
|
||||
- 修复 Windows CI 构建失败:放弃脆弱的 `subst W:` 路径映射,改为将 client/ 复制到 System32 之外的干净短路径(C:\jiu-build)构建,规避 WOW64 文件系统重定向
|
||||
|
||||
## [1.0.4] - 2026-05-30
|
||||
|
||||
### 新功能
|
||||
- 支持 macOS 桌面客户端下载,下载页 macOS 卡片已激活,下载 zip 解压即用
|
||||
|
||||
## [1.0.3] - 2026-05-28
|
||||
|
||||
### 新功能
|
||||
- 新增用户自助注册功能,在官网 /register/ 填写门店信息即可完成注册,系统自动生成门店编码(S000001 格式)
|
||||
- 注册账号默认设为超级管理员权限
|
||||
- 支持 Windows 桌面客户端构建与发布
|
||||
|
||||
### 修复
|
||||
- 修复新门店首次登录时库存、仓库、用户等列表加载崩溃的问题
|
||||
- 修复库存管理页在无数据时页面崩溃(RangeError)的问题
|
||||
- 商品标签打印改为横向 38mm×20mm 布局,修复标签内容错位问题
|
||||
- 修复数据导入中移除不必要的"商品编码"导入项
|
||||
|
||||
## [1.0.2] - 2026-05-25
|
||||
|
||||
### 修复
|
||||
- 打印入库单/出库单/商品标签时,若浏览器拦截了弹窗,现在会给出明确提示,不再静默失败
|
||||
|
||||
## [1.0.1] - 2026-05-25
|
||||
|
||||
### 改进
|
||||
- 库存搜索支持拼音和拼音首字母(例:搜"mt"或"maotai"可匹配"茅台")
|
||||
- 库存搜索改为回车/点击触发,减少无效请求
|
||||
- 新增异常自动上报机制,线上错误可实时追踪
|
||||
|
||||
## [1.0.0] - 2026-05-24
|
||||
|
||||
初始版本发布,完成核心库存管理功能。
|
||||
|
||||
@@ -150,6 +150,45 @@ security: 修复入库单多租户隔离漏洞
|
||||
|
||||
---
|
||||
|
||||
## 开发完成标准(Definition of Done)
|
||||
|
||||
**任何功能或修复,必须满足以下全部条件才算完成:**
|
||||
|
||||
### 后端变更
|
||||
|
||||
```bash
|
||||
# 1. 编译通过
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd backend && go build ./...
|
||||
|
||||
# 2. 所有测试通过(不得有 FAIL)
|
||||
cd backend && go test ./...
|
||||
|
||||
# 3. 无编译警告(vet 检查)
|
||||
cd backend && go vet ./...
|
||||
```
|
||||
|
||||
### 前端变更
|
||||
|
||||
```bash
|
||||
# 1. 静态分析无 error(warning/info 允许存在)
|
||||
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
|
||||
# 2. 所有测试通过
|
||||
cd client && flutter test
|
||||
```
|
||||
|
||||
### 通用要求
|
||||
|
||||
- [ ] 代码已按 Git 提交规范提交(feat/fix/test/...)
|
||||
- [ ] 涉及新接口:已在对应测试文件中覆盖核心路径
|
||||
- [ ] 涉及数据库变更:schema.sql 和 model 同步更新
|
||||
- [ ] 多租户隔离未被破坏(所有查询含 shop_id 条件)
|
||||
|
||||
**测试未通过前,禁止提交代码、禁止打 tag 发版。**
|
||||
|
||||
---
|
||||
|
||||
## 代码质量门禁
|
||||
|
||||
以下情况**不得提交**:
|
||||
@@ -212,3 +251,55 @@ security: 修复入库单多租户隔离漏洞
|
||||
tx.Set("gorm:query_option", "FOR UPDATE").Where(...).First(&model)
|
||||
```
|
||||
- 适用场景:单号生成(`number_rules`)、库存扣减(`inventories`)、任何 check-then-act 模式
|
||||
|
||||
### 发版流程
|
||||
|
||||
使用 `/release <version>` slash command:
|
||||
|
||||
```
|
||||
/release 1.0.2
|
||||
```
|
||||
|
||||
执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。
|
||||
CI/CD(Forgejo)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
|
||||
|
||||
**CHANGELOG 格式**(Keep a Changelog):
|
||||
```markdown
|
||||
## [1.0.2] - YYYY-MM-DD
|
||||
|
||||
### 新功能
|
||||
- ...
|
||||
|
||||
### 改进
|
||||
- ...
|
||||
|
||||
### 修复
|
||||
- ...
|
||||
```
|
||||
只保留有内容的分类。`/release` 会检查 CHANGELOG 是否已有该版本节,没有则从 git log 自动生成。
|
||||
|
||||
### 异常上报
|
||||
|
||||
客户端异常已在两处统一捕获,**无需**在每个业务层重复处理:
|
||||
- `main.dart`:`FlutterError.onError` + `runZonedGuarded` 捕获所有未处理异常
|
||||
- `api_client.dart`:Dio 拦截器自动上报所有 HTTP 5xx 错误
|
||||
|
||||
需要**手动调用** `reportError(e, st)` 的场景:
|
||||
- 技术性异常(JSON 解析失败、第三方 SDK 崩溃等)
|
||||
- **不需要**上报:`AppException` 及其子类(已知业务错误)
|
||||
|
||||
```dart
|
||||
} catch (e, st) {
|
||||
reportError(e, st); // 一行,fire-and-forget
|
||||
rethrow;
|
||||
}
|
||||
```
|
||||
|
||||
### 搜索实现(拼音)
|
||||
|
||||
商品名搜索同时支持汉字、全拼、首字母:
|
||||
- `products` 表有 `name_pinyin`(全拼)和 `name_initials`(首字母)两列
|
||||
- Create / Update / FindOrCreate 写入时自动调用 `util.ToPinyin()` 生成,**无需手动维护**
|
||||
- 启动时对 `name_pinyin = ''` 的存量数据自动回填(`backfillPinyin`)
|
||||
- 库存搜索 SQL 同时 LIKE 匹配 name、code、name_pinyin、name_initials
|
||||
- 新增其他需要拼音搜索的实体时,复用 `backend/internal/util/pinyin.go` 的 `ToPinyin()`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "1.0.0"
|
||||
build_number: 1
|
||||
version: "1.0.3"
|
||||
build_number: 4
|
||||
force_update: false
|
||||
release_notes: "初始版本发布,完成核心库存管理功能。"
|
||||
release_notes: "新增用户自助注册功能;商品标签打印修复竖向 20mm×38mm 布局"
|
||||
download_urls:
|
||||
macos: ""
|
||||
windows: ""
|
||||
|
||||
@@ -44,6 +44,7 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-pinyin v0.21.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
||||
@@ -73,6 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
|
||||
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
||||
@@ -49,6 +49,23 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Register POST /api/v1/public/register
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req service.RegisterInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.Register(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// Refresh POST /api/v1/auth/refresh
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ErrorReportHandler struct{ db *gorm.DB }
|
||||
|
||||
func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
|
||||
return &ErrorReportHandler{db: db}
|
||||
}
|
||||
|
||||
type submitErrorRequest struct {
|
||||
ErrorType string `json:"error_type" binding:"required"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Platform string `json:"platform"`
|
||||
Username string `json:"username"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
ShopNo string `json:"shop_no"`
|
||||
Role string `json:"role"`
|
||||
ErrorMsg string `json:"error_msg" binding:"required"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
|
||||
}
|
||||
|
||||
// Submit POST /api/v1/public/errors
|
||||
func (h *ErrorReportHandler) Submit(c *gin.Context) {
|
||||
var req submitErrorRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
occurredAt := time.Now()
|
||||
if req.OccurredAt > 0 {
|
||||
occurredAt = time.UnixMilli(req.OccurredAt)
|
||||
}
|
||||
|
||||
stack := req.StackTrace
|
||||
const maxStack = 4000
|
||||
if len(stack) > maxStack {
|
||||
stack = stack[:maxStack]
|
||||
}
|
||||
|
||||
report := model.ErrorReport{
|
||||
ErrorType: req.ErrorType,
|
||||
AppVersion: req.AppVersion,
|
||||
Platform: req.Platform,
|
||||
Username: req.Username,
|
||||
ShopID: req.ShopID,
|
||||
ShopNo: req.ShopNo,
|
||||
Role: req.Role,
|
||||
ClientIP: c.ClientIP(),
|
||||
ErrorMsg: req.ErrorMsg,
|
||||
StackTrace: stack,
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&report).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": report.ID})
|
||||
}
|
||||
|
||||
// List GET /api/v1/admin/errors (superadmin only)
|
||||
func (h *ErrorReportHandler) List(c *gin.Context) {
|
||||
var q struct {
|
||||
ErrorType string `form:"error_type"`
|
||||
Username string `form:"username"`
|
||||
Platform string `form:"platform"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=50"`
|
||||
}
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if q.PageSize <= 0 || q.PageSize > 200 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
|
||||
db := h.db.Model(&model.ErrorReport{})
|
||||
if q.ErrorType != "" {
|
||||
db = db.Where("error_type = ?", q.ErrorType)
|
||||
}
|
||||
if q.Username != "" {
|
||||
db = db.Where("username LIKE ?", "%"+q.Username+"%")
|
||||
}
|
||||
if q.Platform != "" {
|
||||
db = db.Where("platform = ?", q.Platform)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var reports []model.ErrorReport
|
||||
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&reports)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": reports,
|
||||
"total": total,
|
||||
"page": q.Page,
|
||||
"page_size": q.PageSize,
|
||||
})
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
|
||||
var total int64
|
||||
base.Count(&total)
|
||||
|
||||
var records []model.FinanceRecord
|
||||
records := make([]model.FinanceRecord, 0)
|
||||
offset := (q.Page - 1) * q.PageSize
|
||||
base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
inStock := c.Query("in_stock")
|
||||
|
||||
if keyword != "" {
|
||||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
|
||||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like)
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if warehouseIDStr != "" {
|
||||
baseWhere += " AND inv.warehouse_id = ?"
|
||||
@@ -138,7 +138,7 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var logs []model.InventoryLog
|
||||
logs := make([]model.InventoryLog, 0)
|
||||
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
|
||||
@@ -32,7 +32,7 @@ var defaultRules = []struct {
|
||||
// List GET /api/v1/number-rules
|
||||
func (h *NumberRuleHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var rules []model.NumberRule
|
||||
rules := make([]model.NumberRule, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id").Find(&rules)
|
||||
|
||||
if len(rules) == 0 {
|
||||
|
||||
@@ -38,7 +38,7 @@ func (h *PartnerHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var partners []model.Partner
|
||||
partners := make([]model.Partner, 0)
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&partners)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": partners, "total": total, "page": page, "page_size": pageSize})
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
type ProductHandler struct {
|
||||
@@ -47,7 +48,7 @@ func (h *ProductHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var products []model.Product
|
||||
products := make([]model.Product, 0)
|
||||
offset := (page - 1) * pageSize
|
||||
query.Preload("Category").Offset(offset).Limit(pageSize).
|
||||
Order("id DESC").Find(&products)
|
||||
@@ -70,6 +71,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
|
||||
}
|
||||
product.ShopID = shopID
|
||||
product.PublicID = uuid.New().String()
|
||||
product.NamePinyin, product.NameInitials = util.ToPinyin(product.Name)
|
||||
|
||||
// Auto-generate product code if not provided (e.g. P001, P002)
|
||||
// Retry up to 5 times on duplicate key to handle concurrent creates
|
||||
@@ -122,6 +124,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
namePinyin, nameInitials := util.ToPinyin(req.Name)
|
||||
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
|
||||
if err := h.db.Model(&product).Updates(map[string]interface{}{
|
||||
"code": req.Code,
|
||||
@@ -138,6 +141,8 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
"description": req.Description,
|
||||
"remark": req.Remark,
|
||||
"custom_fields": req.CustomFields,
|
||||
"name_pinyin": namePinyin,
|
||||
"name_initials": nameInitials,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -226,13 +231,16 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
||||
|
||||
var count int64
|
||||
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
|
||||
namePinyin, nameInitials := util.ToPinyin(req.Name)
|
||||
product = model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Series: req.Series,
|
||||
Spec: req.Spec,
|
||||
Code: fmt.Sprintf("P%03d", count+1),
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Series: req.Series,
|
||||
Spec: req.Spec,
|
||||
Code: fmt.Sprintf("P%03d", count+1),
|
||||
NamePinyin: namePinyin,
|
||||
NameInitials: nameInitials,
|
||||
}
|
||||
if createErr := h.db.Create(&product).Error; createErr != nil {
|
||||
// Race condition: try to find the record created by another request
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler {
|
||||
|
||||
func (h *ProductOptionHandler) ListNames(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductNameOption
|
||||
items := make([]model.ProductNameOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
|
||||
|
||||
func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductSeriesOption
|
||||
items := make([]model.ProductSeriesOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
|
||||
|
||||
func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductSpecOption
|
||||
items := make([]model.ProductSpecOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var orders []model.StockInOrder
|
||||
orders := make([]model.StockInOrder, 0)
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("id DESC").Find(&orders)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var orders []model.StockOutOrder
|
||||
orders := make([]model.StockOutOrder, 0)
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("id DESC").Find(&orders)
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
// List GET /api/v1/users
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var users []model.User
|
||||
users := make([]model.User, 0)
|
||||
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).
|
||||
Order("id ASC").Find(&users)
|
||||
c.JSON(http.StatusOK, gin.H{"data": users})
|
||||
|
||||
@@ -20,7 +20,7 @@ func NewWarehouseHandler(db *gorm.DB) *WarehouseHandler {
|
||||
|
||||
func (h *WarehouseHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var warehouses []model.Warehouse
|
||||
warehouses := make([]model.Warehouse, 0)
|
||||
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
|
||||
c.JSON(http.StatusOK, gin.H{"data": warehouses})
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func JWT() gin.HandlerFunc {
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(CtxRole)
|
||||
if role != "admin" {
|
||||
if role != "admin" && role != "superadmin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
|
||||
type ErrorReport struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
|
||||
AppVersion string `gorm:"size:30" json:"app_version"`
|
||||
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
|
||||
Username string `gorm:"size:50;index" json:"username"`
|
||||
ShopID uint64 `gorm:"index" json:"shop_id"`
|
||||
ShopNo string `gorm:"size:50" json:"shop_no"`
|
||||
Role string `gorm:"size:20" json:"role"`
|
||||
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
|
||||
StackTrace string `gorm:"type:text" json:"stack_trace"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type Product struct {
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
NamePinyin string `gorm:"size:400;index" json:"-"`
|
||||
NameInitials string `gorm:"size:100;index" json:"-"`
|
||||
|
||||
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
||||
|
||||
@@ -4,6 +4,7 @@ type Shop struct {
|
||||
Base
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Code string `gorm:"size:50;uniqueIndex" json:"code"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Address string `gorm:"size:255" json:"address"`
|
||||
Phone string `gorm:"size:30" json:"phone"`
|
||||
BusinessHours string `gorm:"size:100" json:"business_hours"`
|
||||
|
||||
@@ -33,6 +33,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
publicH := handler.NewPublicHandler(db)
|
||||
adminH := handler.NewAdminHandler(db)
|
||||
shopH := handler.NewShopHandler(db)
|
||||
errorReportH := handler.NewErrorReportHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -56,6 +57,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
public.GET("/products/:public_id", publicH.GetProduct)
|
||||
public.GET("/release", publicH.GetRelease)
|
||||
public.POST("/errors", errorReportH.Submit)
|
||||
public.POST("/register", authH.Register)
|
||||
}
|
||||
|
||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||
@@ -213,6 +216,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
superAdmin.Use(middleware.SuperAdminOnly())
|
||||
{
|
||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||
superAdmin.GET("/errors", errorReportH.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -61,6 +63,76 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
|
||||
return pair, &user, nil
|
||||
}
|
||||
|
||||
// RegisterInput 注册新门店所需参数
|
||||
type RegisterInput struct {
|
||||
ShopName string `json:"shop_name" binding:"required"`
|
||||
Address string `json:"address" binding:"required"`
|
||||
ManagerName string `json:"manager_name" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// RegisterResult 注册成功后返回的数据
|
||||
type RegisterResult struct {
|
||||
ShopCode string `json:"shop_code"`
|
||||
ShopName string `json:"shop_name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Register 自助注册新门店(公开接口,无需认证)
|
||||
func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result RegisterResult
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先用 UUID 占位创建门店,得到真实 ID
|
||||
shop := model.Shop{
|
||||
Name: in.ShopName,
|
||||
Code: uuid.New().String(),
|
||||
Address: in.Address,
|
||||
Phone: in.Phone,
|
||||
ManagerName: in.ManagerName,
|
||||
Description: in.Description,
|
||||
}
|
||||
if err := tx.Create(&shop).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 用自增 ID 生成正式门店编码
|
||||
shop.Code = fmt.Sprintf("S%06d", shop.ID)
|
||||
if err := tx.Model(&shop).Update("code", shop.Code).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建管理员用户
|
||||
user := model.User{
|
||||
ShopID: shop.ID,
|
||||
Username: in.Username,
|
||||
PasswordHash: string(hash),
|
||||
RealName: in.ManagerName,
|
||||
Phone: in.Phone,
|
||||
Role: "superadmin",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result = RegisterResult{
|
||||
ShopCode: shop.Code,
|
||||
ShopName: shop.Name,
|
||||
Username: user.Username,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return &result, err
|
||||
}
|
||||
|
||||
// HashPassword 生成 bcrypt 哈希
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
gp "github.com/mozillazg/go-pinyin"
|
||||
)
|
||||
|
||||
var (
|
||||
fullArgs = func() gp.Args {
|
||||
a := gp.NewArgs()
|
||||
a.Style = gp.Normal
|
||||
a.Fallback = func(r rune, _ gp.Args) []string {
|
||||
return []string{strings.ToLower(string(r))}
|
||||
}
|
||||
return a
|
||||
}()
|
||||
initialArgs = func() gp.Args {
|
||||
a := gp.NewArgs()
|
||||
a.Style = gp.FirstLetter
|
||||
a.Fallback = func(r rune, _ gp.Args) []string {
|
||||
s := strings.ToLower(string(r))
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{string(s[0])}
|
||||
}
|
||||
return a
|
||||
}()
|
||||
)
|
||||
|
||||
// ToPinyin converts a Chinese name to full pinyin and initials.
|
||||
// Non-Chinese characters are kept as-is (lowercased).
|
||||
// Example: "茅台酒" → ("maotaijiu", "mtj")
|
||||
func ToPinyin(s string) (full, initials string) {
|
||||
var fb, ib strings.Builder
|
||||
for _, row := range gp.Pinyin(s, fullArgs) {
|
||||
if len(row) > 0 {
|
||||
fb.WriteString(row[0])
|
||||
}
|
||||
}
|
||||
for _, row := range gp.Pinyin(s, initialArgs) {
|
||||
if len(row) > 0 {
|
||||
ib.WriteString(row[0])
|
||||
}
|
||||
}
|
||||
return fb.String(), ib.String()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/router"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -27,6 +28,9 @@ func main() {
|
||||
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
|
||||
autoMigrate(db)
|
||||
|
||||
// 回填存量商品的拼音索引(一次性,已有值的跳过)
|
||||
backfillPinyin(db)
|
||||
|
||||
// 启动 Gin
|
||||
gin.SetMode(config.C.Server.Mode)
|
||||
r := gin.New()
|
||||
@@ -104,9 +108,26 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.ProductSeriesOption{},
|
||||
&model.ProductSpecOption{},
|
||||
&model.ProductImage{},
|
||||
&model.ErrorReport{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
log.Println("AutoMigrate completed")
|
||||
}
|
||||
|
||||
func backfillPinyin(db *gorm.DB) {
|
||||
var products []model.Product
|
||||
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
|
||||
if len(products) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range products {
|
||||
full, initials := util.ToPinyin(products[i].Name)
|
||||
db.Model(&products[i]).Updates(map[string]interface{}{
|
||||
"name_pinyin": full,
|
||||
"name_initials": initials,
|
||||
})
|
||||
}
|
||||
log.Printf("backfillPinyin: updated %d products", len(products))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` (
|
||||
`address` VARCHAR(255) DEFAULT NULL,
|
||||
`phone` VARCHAR(30) DEFAULT NULL,
|
||||
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
|
||||
`description` TEXT DEFAULT NULL COMMENT '店铺简介',
|
||||
`logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL',
|
||||
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
|
||||
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
|
||||
|
||||
@@ -53,6 +53,7 @@ func SetupTestDB() *gorm.DB {
|
||||
deleted_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT UNIQUE,
|
||||
description TEXT,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
manager_name TEXT,
|
||||
@@ -121,7 +122,9 @@ func SetupTestDB() *gorm.DB {
|
||||
min_stock INTEGER DEFAULT 0,
|
||||
description TEXT,
|
||||
custom_fields TEXT,
|
||||
remark TEXT
|
||||
remark TEXT,
|
||||
name_pinyin TEXT DEFAULT '',
|
||||
name_initials TEXT DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS warehouses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ migration:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: web
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.yanmei.jiu"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.yanmei.jiu"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="jiu_client"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.yanmei.jiu
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"provider": "岩美技术有限公司",
|
||||
"website": "https://jiu.yanmei.com",
|
||||
"email": "yammy2023@163.com"
|
||||
}
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
@@ -74,6 +75,16 @@ class ApiClient {
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
// 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报)
|
||||
final statusCode = e.response?.statusCode ?? 0;
|
||||
if (statusCode >= 500) {
|
||||
reportError(
|
||||
'[${e.requestOptions.method}] ${e.requestOptions.path} → $statusCode',
|
||||
e.stackTrace,
|
||||
errorType: ErrorType.apiError,
|
||||
);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 应用「关于我们」等品牌信息,从配置文件 assets/config/app_info.json 读取,
|
||||
/// 不硬编码、不走环境变量。在 main() 启动时调用 [load] 加载一次。
|
||||
class AppInfo {
|
||||
const AppInfo._();
|
||||
|
||||
static const _assetPath = 'assets/config/app_info.json';
|
||||
|
||||
static String provider = '';
|
||||
static String website = '';
|
||||
static String email = '';
|
||||
|
||||
/// 加载配置文件。失败时保留为空(不影响应用启动)。
|
||||
static Future<void> load() async {
|
||||
try {
|
||||
final raw = await rootBundle.loadString(_assetPath);
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
provider = (map['provider'] as String?) ?? '';
|
||||
website = (map['website'] as String?) ?? '';
|
||||
email = (map['email'] as String?) ?? '';
|
||||
} catch (e) {
|
||||
debugPrint('AppInfo.load failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class ErrorType {
|
||||
static const flutterError = 'flutter_error';
|
||||
static const zoneError = 'zone_error';
|
||||
static const caughtException = 'caught_exception';
|
||||
static const apiError = 'api_error';
|
||||
}
|
||||
|
||||
class ErrorReporter {
|
||||
ErrorReporter._();
|
||||
static final ErrorReporter instance = ErrorReporter._();
|
||||
|
||||
static const _dedupWindowMs = 60 * 1000;
|
||||
static const _maxStackLength = 3000;
|
||||
|
||||
final Map<String, int> _recentMessages = {};
|
||||
Dio? _dio;
|
||||
String? _cachedVersion;
|
||||
|
||||
Dio get _client {
|
||||
_dio ??= Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
return _dio!;
|
||||
}
|
||||
|
||||
Future<String> _getVersion() async {
|
||||
if (_cachedVersion != null) return _cachedVersion!;
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_cachedVersion = info.version;
|
||||
} catch (_) {
|
||||
_cachedVersion = 'unknown';
|
||||
}
|
||||
return _cachedVersion!;
|
||||
}
|
||||
|
||||
String _getPlatform() {
|
||||
if (kIsWeb) return 'web';
|
||||
try {
|
||||
if (Platform.isIOS) return 'ios';
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isMacOS) return 'macos';
|
||||
if (Platform.isWindows) return 'windows';
|
||||
if (Platform.isLinux) return 'linux';
|
||||
} catch (_) {}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _getUserFields() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return {
|
||||
'username': prefs.getString('username') ?? '',
|
||||
'shop_id': int.tryParse(prefs.getString('shop_id') ?? '') ?? 0,
|
||||
'shop_no': prefs.getString('shop_no') ?? '',
|
||||
'role': prefs.getString('role') ?? '',
|
||||
};
|
||||
} catch (_) {
|
||||
return {'username': '', 'shop_id': 0, 'shop_no': '', 'role': ''};
|
||||
}
|
||||
}
|
||||
|
||||
bool _isDuplicate(String message) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final lastTime = _recentMessages[message];
|
||||
if (lastTime != null && now - lastTime < _dedupWindowMs) return true;
|
||||
_recentMessages[message] = now;
|
||||
if (_recentMessages.length > 100) {
|
||||
_recentMessages.removeWhere((_, t) => now - t >= _dedupWindowMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void report({
|
||||
required String errorType,
|
||||
required String errorMsg,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (_isDuplicate(errorMsg)) return;
|
||||
unawaited(_doReport(
|
||||
errorType: errorType,
|
||||
errorMsg: errorMsg,
|
||||
stackTrace: stackTrace,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _doReport({
|
||||
required String errorType,
|
||||
required String errorMsg,
|
||||
StackTrace? stackTrace,
|
||||
}) async {
|
||||
try {
|
||||
final version = await _getVersion();
|
||||
final userFields = await _getUserFields();
|
||||
|
||||
String? stack;
|
||||
if (stackTrace != null) {
|
||||
final raw = stackTrace.toString();
|
||||
stack = raw.length > _maxStackLength ? raw.substring(0, _maxStackLength) : raw;
|
||||
}
|
||||
|
||||
await _client.post('/public/errors', data: {
|
||||
'error_type': errorType,
|
||||
'app_version': version,
|
||||
'platform': _getPlatform(),
|
||||
'username': userFields['username'],
|
||||
'shop_id': userFields['shop_id'],
|
||||
'shop_no': userFields['shop_no'],
|
||||
'role': userFields['role'],
|
||||
'error_msg': errorMsg,
|
||||
'stack_trace': stack ?? '',
|
||||
'occurred_at': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
} catch (_) {
|
||||
// 上报失败静默处理,不影响用户
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷顶层函数,供 catch 块一行调用
|
||||
void reportError(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
String errorType = ErrorType.caughtException,
|
||||
}) {
|
||||
ErrorReporter.instance.report(
|
||||
errorType: errorType,
|
||||
errorMsg: error.toString(),
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class PageResult<T> {
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
return PageResult(
|
||||
data: (json['data'] as List)
|
||||
data: ((json['data'] as List?) ?? [])
|
||||
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import 'app_updater_io.dart'
|
||||
if (dart.library.js_interop) 'app_updater_web.dart' as impl;
|
||||
|
||||
/// 应用内更新统一入口。
|
||||
/// - Web:刷新页面。
|
||||
/// - Windows/macOS:应用内下载安装包并自动安装/重启(带进度对话框)。
|
||||
/// - 其它平台 / 无对应下载地址:回退浏览器下载(launchUpdateUrl)。
|
||||
Future<void> startInAppUpdate(BuildContext context, AppUpdateInfo info) async {
|
||||
if (impl.isWeb) {
|
||||
impl.reloadForUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
final url = impl.platformDownloadUrl(info.downloadUrls);
|
||||
if (!impl.isInAppSupported || url == null || url.isEmpty) {
|
||||
await launchUpdateUrl(info.downloadUrls); // 兜底:开浏览器/商店
|
||||
return;
|
||||
}
|
||||
|
||||
await _runWithProgress(context, info, url);
|
||||
}
|
||||
|
||||
Future<void> _runWithProgress(
|
||||
BuildContext context, AppUpdateInfo info, String url) async {
|
||||
final progress = ValueNotifier<double>(0);
|
||||
|
||||
// 不可关闭的进度对话框;下载完成后 impl 会让进程退出。
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Text('正在更新到 v${info.latestVersion}'),
|
||||
content: ValueListenableBuilder<double>(
|
||||
valueListenable: progress,
|
||||
builder: (_, p, __) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LinearProgressIndicator(value: p > 0 ? p : null),
|
||||
const SizedBox(height: 12),
|
||||
Text(p >= 1
|
||||
? '下载完成,正在安装,应用即将重启…'
|
||||
: '正在下载… ${(p * 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await impl.downloadAndInstall(url, (p) => progress.value = p);
|
||||
// 桌面端到此通常已 exit(0),不会继续执行。
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context, rootNavigator: true).pop(); // 关进度对话框
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('更新失败'),
|
||||
content: Text('$e\n\n可改用浏览器下载更新。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
launchUpdateUrl(info.downloadUrls);
|
||||
},
|
||||
child: const Text('浏览器下载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// 桌面/原生平台的应用内更新实现。Web 版见 app_updater_web.dart。
|
||||
|
||||
bool get isWeb => false;
|
||||
|
||||
/// 仅 Windows / macOS 支持「下载并自动安装」。其它平台回退到浏览器下载。
|
||||
bool get isInAppSupported => Platform.isWindows || Platform.isMacOS;
|
||||
|
||||
String? platformDownloadUrl(Map<String, String> urls) {
|
||||
if (Platform.isWindows) return urls['windows'];
|
||||
if (Platform.isMacOS) return urls['macos'];
|
||||
if (Platform.isAndroid) return urls['android'];
|
||||
if (Platform.isIOS) return urls['ios'];
|
||||
return urls['web'];
|
||||
}
|
||||
|
||||
void reloadForUpdate() {/* 桌面无意义 */}
|
||||
|
||||
/// 用 Dio 下载安装包(不经浏览器,无 MOTW / quarantine),再启动安装。
|
||||
/// 成功后进程会自行退出,调用方的后续代码可能不会执行。
|
||||
Future<void> downloadAndInstall(
|
||||
String url, void Function(double) onProgress) async {
|
||||
final tmp = await getTemporaryDirectory();
|
||||
final dio = Dio();
|
||||
void prog(int r, int t) {
|
||||
if (t > 0) onProgress(r / t);
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
final exe = '${tmp.path}\\jiu-update-setup.exe';
|
||||
await dio.download(url, exe, onReceiveProgress: prog);
|
||||
// 启动 Inno Setup 安装包后退出本应用,让其覆盖安装。
|
||||
await Process.start(exe, const [], mode: ProcessStartMode.detached);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final zip = '${tmp.path}/jiu-update.zip';
|
||||
await dio.download(url, zip, onReceiveProgress: prog);
|
||||
|
||||
final extractDir = '${tmp.path}/jiu-update';
|
||||
await Process.run('/bin/rm', ['-rf', extractDir]);
|
||||
await Directory(extractDir).create(recursive: true);
|
||||
// ditto 正确处理 .app 包(保留权限/符号链接)。
|
||||
final unzip = await Process.run('/usr/bin/ditto', ['-x', '-k', zip, extractDir]);
|
||||
if (unzip.exitCode != 0) {
|
||||
throw Exception('解压失败:${unzip.stderr}');
|
||||
}
|
||||
final newApp = Directory(extractDir)
|
||||
.listSync()
|
||||
.map((e) => e.path)
|
||||
.firstWhere((p) => p.endsWith('.app'),
|
||||
orElse: () => throw Exception('更新包中未找到 .app'));
|
||||
|
||||
// 当前运行的 app bundle:.../Xxx.app/Contents/MacOS/exe
|
||||
final resolved = Platform.resolvedExecutable;
|
||||
final idx = resolved.indexOf('.app');
|
||||
if (idx < 0) {
|
||||
throw Exception('无法定位当前 app 路径(非 .app 运行)');
|
||||
}
|
||||
final appPath = resolved.substring(0, idx + 4);
|
||||
|
||||
// 等本进程退出后替换并重启;替换失败(权限)则在访达中高亮让用户手动拖入。
|
||||
final script = '${tmp.path}/jiu-update-swap.sh';
|
||||
await File(script).writeAsString('''#!/bin/sh
|
||||
while kill -0 $pid 2>/dev/null; do sleep 0.5; done
|
||||
if rm -rf "$appPath" && ditto "$newApp" "$appPath"; then
|
||||
open "$appPath"
|
||||
else
|
||||
open -R "$newApp"
|
||||
fi
|
||||
''');
|
||||
await Process.start('/bin/sh', [script], mode: ProcessStartMode.detached);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
exit(0);
|
||||
}
|
||||
|
||||
throw Exception('当前平台不支持应用内更新');
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
// Web 平台:没有"安装",更新即刷新页面拿最新资源。
|
||||
|
||||
bool get isWeb => true;
|
||||
bool get isInAppSupported => true;
|
||||
|
||||
String? platformDownloadUrl(Map<String, String> urls) => urls['web'];
|
||||
|
||||
void reloadForUpdate() => web.window.location.reload();
|
||||
|
||||
Future<void> downloadAndInstall(
|
||||
String url, void Function(double) onProgress) async {
|
||||
reloadForUpdate();
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'print_util_stub.dart'
|
||||
if (dart.library.js_interop) 'print_util_web.dart';
|
||||
|
||||
@@ -36,3 +38,18 @@ Future<void> printStockInOrder(StockInOrder order) =>
|
||||
|
||||
Future<void> printStockOutOrder(StockOutOrder order) =>
|
||||
printStockOutOrderImpl(order);
|
||||
|
||||
/// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。
|
||||
/// 用法:await safePrint(context, () => printStockInOrder(order));
|
||||
Future<void> safePrint(BuildContext context, Future<void> Function() fn) async {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('打印失败:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
@@ -53,6 +54,7 @@ Future<void> printProductLabelImpl({
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
}) async {
|
||||
try {
|
||||
final font = await _loadFont();
|
||||
final doc = pw.Document();
|
||||
final qrImage = pw.MemoryImage(qrBytes);
|
||||
@@ -189,7 +191,11 @@ Future<void> printProductLabelImpl({
|
||||
),
|
||||
));
|
||||
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
}
|
||||
}
|
||||
|
||||
pw.Widget _label2ColRow(
|
||||
@@ -377,6 +383,7 @@ pw.Widget _sig(pw.Font font, pw.Font bold, String label) {
|
||||
}
|
||||
|
||||
Future<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
try {
|
||||
final font = await _loadFont();
|
||||
final bold = await _loadBoldFont();
|
||||
|
||||
@@ -424,7 +431,11 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
),
|
||||
],
|
||||
));
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
@@ -474,5 +485,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
),
|
||||
],
|
||||
));
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
try {
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import 'dart:typed_data';
|
||||
import 'package:web/web.dart' as web;
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
|
||||
void _openPrintWindow(String html) {
|
||||
final win = web.window.open('', '_blank');
|
||||
if (win != null) {
|
||||
win.document.write(html.toJS);
|
||||
win.document.close();
|
||||
if (win == null) {
|
||||
throw Exception('浏览器已拦截弹窗,请在地址栏允许弹窗后重试');
|
||||
}
|
||||
win.document.write(html.toJS);
|
||||
win.document.close();
|
||||
}
|
||||
|
||||
Future<void> printProductLabelImpl({
|
||||
@@ -28,111 +30,93 @@ Future<void> printProductLabelImpl({
|
||||
}) async {
|
||||
final base64Img = base64Encode(qrBytes);
|
||||
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '—';
|
||||
final seriesVal = (series ?? '').isNotEmpty ? series! : '—';
|
||||
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '—';
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '—';
|
||||
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '—';
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
|
||||
: '—';
|
||||
|
||||
final footerContact = [
|
||||
if (shopAddress.isNotEmpty) shopAddress,
|
||||
final contactLine = [
|
||||
if (shopPhone.isNotEmpty) shopPhone,
|
||||
].join(' · ');
|
||||
|
||||
// 标签生成时间
|
||||
final now = DateTime.now();
|
||||
final genTime =
|
||||
'${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}'
|
||||
' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}';
|
||||
|
||||
final remarkRow = (remark ?? '').isNotEmpty
|
||||
? '''<div class="spec-row">
|
||||
<span class="lbl">备 注</span>
|
||||
<span class="val-full">$remark</span>
|
||||
</div>'''
|
||||
: '';
|
||||
if (shopAddress.isNotEmpty) shopAddress,
|
||||
].join(' ');
|
||||
|
||||
final html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: 4in 2in; margin: 0; }
|
||||
/* 标签尺寸:38mm × 20mm,横向 */
|
||||
@page { size: 38mm 20mm; margin: 0; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
|
||||
body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
|
||||
.label {
|
||||
width: 4in; height: 2in;
|
||||
display: grid; grid-template-rows: 42px 1fr 18px;
|
||||
width: 38mm; height: 20mm;
|
||||
display: flex; flex-direction: column;
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ── Header:酒行名称 ── */
|
||||
/* ── Header:酒行名称 3.5mm ── */
|
||||
.hdr {
|
||||
background: #1f2a3a; color: #f4ecd8;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 15px; overflow: hidden;
|
||||
height: 3.5mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1.5mm; overflow: hidden;
|
||||
}
|
||||
.hdr-name {
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-weight: 700; font-size: 17px; letter-spacing: 0.08em;
|
||||
font-size: 5.5pt; font-weight: 700; letter-spacing: 0.08em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.hdr-tag {
|
||||
font-size: 6.5px; letter-spacing: 0.2em; opacity: 0.8;
|
||||
font-style: italic; white-space: nowrap; flex-shrink: 0; padding-left: 10px;
|
||||
|
||||
/* ── 中部:左侧商品信息 + 右侧二维码 ── */
|
||||
.middle {
|
||||
flex: 1; display: flex; flex-direction: row; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.body {
|
||||
background: #fff; padding: 9px 15px 8px;
|
||||
display: grid; grid-template-columns: 1fr 76px; gap: 10px;
|
||||
align-items: center;
|
||||
/* 左:商品名 + 规格批次 */
|
||||
.info {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
justify-content: center; padding: 0.8mm 1mm 0.5mm 1.5mm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 商品名 */
|
||||
.product-name {
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-weight: 700; font-size: 13px; letter-spacing: 0.04em;
|
||||
margin-bottom: 7px;
|
||||
font-size: 6.5pt; font-weight: 700; line-height: 1.2;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
margin-bottom: 0.8mm;
|
||||
}
|
||||
.specs {
|
||||
display: flex; flex-direction: column; gap: 0.5mm;
|
||||
}
|
||||
|
||||
/* 规格行 */
|
||||
.spec-row {
|
||||
display: flex; gap: 6px; align-items: baseline;
|
||||
font-size: 6.5px; margin-bottom: 3.5px; flex-wrap: nowrap;
|
||||
display: flex; gap: 0.8mm; align-items: baseline;
|
||||
font-size: 4pt; white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.lbl { color: #888; letter-spacing: 0.1em; white-space: nowrap; flex-shrink: 0; }
|
||||
.lbl { color: #999; flex-shrink: 0; }
|
||||
.val { font-family: "IBM Plex Mono", monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.val-full { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: #444; font-size: 6px; }
|
||||
overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 同行双字段 */
|
||||
.spec-row-2 {
|
||||
display: grid; grid-template-columns: 28px 1fr 28px 1fr;
|
||||
gap: 0 7px; font-size: 6.5px; margin-bottom: 3.5px;
|
||||
align-items: baseline;
|
||||
/* 右:二维码 */
|
||||
.qr-wrap {
|
||||
width: 14mm; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 0.3mm;
|
||||
padding: 0.5mm 0.5mm 0.5mm 0;
|
||||
}
|
||||
.qr-wrap img { width: 12mm; height: 12mm; object-fit: contain; }
|
||||
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
|
||||
|
||||
/* QR */
|
||||
.qr-col { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.qr-col img { width: 68px; height: 68px; object-fit: contain; display: block; }
|
||||
.qr-cap { font-size: 5px; letter-spacing: 0.2em; color: #666; text-align: center; }
|
||||
|
||||
/* ── Footer:地址 + 生成时间 ── */
|
||||
/* ── Footer:联系方式 3mm ── */
|
||||
.ftr {
|
||||
background: #f4ecd8;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 5px; color: #5a4e35; letter-spacing: 0.04em; overflow: hidden;
|
||||
background: #f4ecd8; height: 3mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1.5mm;
|
||||
font-size: 3.5pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -141,40 +125,24 @@ body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
|
||||
|
||||
<div class="hdr">
|
||||
<span class="hdr-name">$shopName</span>
|
||||
<span class="hdr-tag">Certificate of Authenticity</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div>
|
||||
<div class="middle">
|
||||
<div class="info">
|
||||
<div class="product-name">$name</div>
|
||||
|
||||
<div class="spec-row-2">
|
||||
<span class="lbl">规 格</span>
|
||||
<span class="val">$specVal</span>
|
||||
<span class="lbl">系 列</span>
|
||||
<span class="val">$seriesVal</span>
|
||||
<div class="specs">
|
||||
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
|
||||
</div>
|
||||
|
||||
<div class="spec-row-2">
|
||||
<span class="lbl">批 号</span>
|
||||
<span class="val">$batchVal</span>
|
||||
<span class="lbl">生产日期</span>
|
||||
<span class="val">$dateVal</span>
|
||||
</div>
|
||||
|
||||
$remarkRow
|
||||
</div>
|
||||
|
||||
<div class="qr-col">
|
||||
<div class="qr-wrap">
|
||||
<img src="data:image/png;base64,$base64Img" />
|
||||
<div class="qr-cap">扫码溯源 · TRACE</div>
|
||||
<div class="qr-cap">扫码溯源</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftr">
|
||||
<span>${footerContact.isNotEmpty ? footerContact : shopName}</span>
|
||||
<span>$genTime</span>
|
||||
</div>
|
||||
<div class="ftr">${contactLine.isNotEmpty ? contactLine : shopName}</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
@@ -187,7 +155,12 @@ body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
|
||||
</body>
|
||||
</html>''';
|
||||
|
||||
_openPrintWindow(html);
|
||||
try {
|
||||
_openPrintWindow(html);
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 入库单 ──────────────────────────────────────────────────────────────────
|
||||
@@ -293,7 +266,12 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
</body>
|
||||
</html>''';
|
||||
|
||||
_openPrintWindow(html);
|
||||
try {
|
||||
_openPrintWindow(html);
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 出库单 ──────────────────────────────────────────────────────────────────
|
||||
@@ -396,5 +374,10 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
</body>
|
||||
</html>''';
|
||||
|
||||
_openPrintWindow(html);
|
||||
try {
|
||||
_openPrintWindow(html);
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -4,6 +4,8 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'core/auth/auth_state.dart';
|
||||
import 'core/config/app_info.dart';
|
||||
import 'core/errors/error_reporter.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'providers/connectivity_provider.dart';
|
||||
@@ -15,14 +17,28 @@ void main() {
|
||||
debugPrint('═══ FlutterError ════════════════════════════');
|
||||
debugPrint(details.exceptionAsString());
|
||||
debugPrint(details.stack.toString());
|
||||
ErrorReporter.instance.report(
|
||||
errorType: ErrorType.flutterError,
|
||||
errorMsg: details.exceptionAsString(),
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
runZonedGuarded(
|
||||
() => runApp(const ProviderScope(child: JiuApp())),
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AppInfo.load(); // 从配置文件加载「关于我们」品牌信息
|
||||
runApp(const ProviderScope(child: JiuApp()));
|
||||
},
|
||||
(error, stack) {
|
||||
debugPrint('═══ Zone Error ═══════════════════════════════');
|
||||
debugPrint(error.toString());
|
||||
debugPrint(stack.toString());
|
||||
ErrorReporter.instance.report(
|
||||
errorType: ErrorType.zoneError,
|
||||
errorMsg: error.toString(),
|
||||
stackTrace: stack,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class NumberRuleRepository {
|
||||
Future<List<NumberRule>> list() async {
|
||||
try {
|
||||
final resp = await _client.get('/number-rules');
|
||||
final data = resp.data['data'] as List;
|
||||
final data = (resp.data['data'] as List?) ?? [];
|
||||
return data
|
||||
.map((e) => NumberRule.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
@@ -7,7 +7,7 @@ class UserRepository {
|
||||
|
||||
Future<List<AppUser>> list() async {
|
||||
final resp = await _client.get('/users');
|
||||
final data = resp.data['data'] as List;
|
||||
final data = (resp.data['data'] as List?) ?? [];
|
||||
return data.map((e) => AppUser.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class WarehouseRepository {
|
||||
try {
|
||||
final resp = await _client.get('/warehouses');
|
||||
final body = resp.data as Map<String, dynamic>;
|
||||
final raw = body['data'] as List;
|
||||
final raw = (body['data'] as List?) ?? [];
|
||||
return raw
|
||||
.map((e) => Warehouse.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/storage/login_history.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
@@ -484,6 +486,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
letterSpacing: 2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse('${AppConfig.publicBaseUrl}/register/'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
child: const Text(
|
||||
'还没有门店账号?前往官网注册',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -28,21 +28,16 @@ class InventoryListScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
Timer? _debounce;
|
||||
Set<String> _filterWarehouse = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
ref.read(inventoryListProvider.notifier).setKeyword(value);
|
||||
});
|
||||
void _triggerSearch() {
|
||||
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
||||
}
|
||||
|
||||
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
||||
@@ -333,15 +328,20 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索商品名/编码',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: '名称/编码/拼音,回车搜索',
|
||||
prefixIcon: const Icon(Icons.search, size: 16),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
tooltip: '搜索',
|
||||
onPressed: _triggerSearch,
|
||||
),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: (_) => _triggerSearch(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -386,7 +386,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: filteredItems
|
||||
@@ -485,12 +484,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
item.productId != null
|
||||
? TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final qrBytes = await ref
|
||||
.read(productRepositoryProvider)
|
||||
.getQRCodeBytes(item.productId!);
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
await printProductLabel(
|
||||
final qrBytes = await ref
|
||||
.read(productRepositoryProvider)
|
||||
.getQRCodeBytes(item.productId!);
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printProductLabel(
|
||||
qrBytes: qrBytes,
|
||||
name: item.productName,
|
||||
code: item.productCode,
|
||||
@@ -502,14 +501,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('打印失败:$e'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
));
|
||||
}
|
||||
},
|
||||
child: const Text('打标签',
|
||||
|
||||
@@ -528,10 +528,10 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
|
||||
Future<void> _print() async {
|
||||
if (_bytes == null || _printing) return;
|
||||
setState(() { _printing = true; _printStatus = '正在打印...'; });
|
||||
final p = widget.product;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
try {
|
||||
final p = widget.product;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
await printProductLabel(
|
||||
await safePrint(context, () => printProductLabel(
|
||||
qrBytes: _bytes!,
|
||||
name: p.name,
|
||||
code: p.code,
|
||||
@@ -540,13 +540,7 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('打印失败:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
));
|
||||
} finally {
|
||||
if (mounted) setState(() { _printing = false; _printStatus = ''; });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/number_rule.dart';
|
||||
import '../../models/user.dart';
|
||||
@@ -728,7 +730,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
value: 'v${updateInfo.latestVersion}',
|
||||
valueColor: AppTheme.success,
|
||||
trailing: TextButton(
|
||||
onPressed: () => launchUpdateUrl(updateInfo.downloadUrls),
|
||||
onPressed: () => startInAppUpdate(context, updateInfo),
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
)
|
||||
@@ -738,8 +740,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
value: updateInfo != null ? '已是最新' : '检查中…',
|
||||
valueColor: AppTheme.textSecondary,
|
||||
trailing: TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(updateProvider.notifier).forceCheck(),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('正在检查更新…')));
|
||||
await ref.read(updateProvider.notifier).forceCheck();
|
||||
if (!context.mounted) return;
|
||||
final r = ref.read(updateProvider).valueOrNull;
|
||||
messenger.hideCurrentSnackBar();
|
||||
final String msg;
|
||||
if (r == null) {
|
||||
msg = '检查更新失败,请检查网络';
|
||||
} else if (r.hasUpdate) {
|
||||
msg = '发现新版本 v${r.latestVersion}';
|
||||
} else {
|
||||
msg = '已是最新版本(v${r.latestVersion})';
|
||||
}
|
||||
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||||
},
|
||||
child: const Text('检查更新'),
|
||||
),
|
||||
),
|
||||
@@ -828,9 +846,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_AboutSection(
|
||||
title: '关于我们',
|
||||
children: [
|
||||
const _AboutRow(label: '开发商', value: '酒库科技有限公司'),
|
||||
const _AboutRow(label: '官方网站', value: 'https://jiu.example.com'),
|
||||
const _AboutRow(label: '联系邮箱', value: 'support@jiu.example.com'),
|
||||
_AboutRow(label: '服务商', value: AppInfo.provider),
|
||||
_AboutRow(label: '官方网站', value: AppInfo.website),
|
||||
_AboutRow(label: '联系邮箱', value: AppInfo.email),
|
||||
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
@@ -838,8 +856,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse('mailto:support@jiu.example.com'
|
||||
'?subject=酒库管理系统咨询');
|
||||
final uri = Uri.parse(
|
||||
'mailto:${AppInfo.email}?subject=酒库管理系统咨询');
|
||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||
},
|
||||
icon: const Icon(Icons.email_outlined, size: 16),
|
||||
@@ -887,17 +905,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('续费 / 升级授权'),
|
||||
content: const Column(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('请联系我们获取续费报价:'),
|
||||
SizedBox(height: 12),
|
||||
SelectableText('📧 support@jiu.example.com',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
SizedBox(height: 6),
|
||||
SelectableText('📞 400-000-0000',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
const Text('请联系我们获取续费报价:'),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText('📧 ${AppInfo.email}',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -908,7 +923,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(
|
||||
const ClipboardData(text: 'support@jiu.example.com'));
|
||||
ClipboardData(text: AppInfo.email));
|
||||
if (ctx.mounted) {
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -952,7 +967,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议');
|
||||
final body = Uri.encodeComponent(ctrl.text);
|
||||
final uri = Uri.parse(
|
||||
'mailto:support@jiu.example.com?subject=$subject&body=$body');
|
||||
'mailto:${AppInfo.email}?subject=$subject&body=$body');
|
||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
@@ -1578,8 +1593,6 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
'格式:选项编号 | 选项名称 | 备注'),
|
||||
_ImportSlot('商品规格', '/import/product-specs',
|
||||
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
||||
_ImportSlot('商品编码', '/import/product-codes',
|
||||
'格式:商品编码:xxxx'),
|
||||
_ImportSlot('库存', '/import/inventory',
|
||||
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
@@ -41,22 +42,11 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
Text('发现新版本'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('当前版本需要更新至 v${info.latestVersion} 才能继续使用。'),
|
||||
if (info.releaseNotes.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(info.releaseNotes,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
content: Text('发现新版本 v${info.latestVersion},需更新后才能继续使用,'
|
||||
'更新以获得最新功能与修复。'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => launchUpdateUrl(info.downloadUrls),
|
||||
onPressed: () => startInAppUpdate(context, info),
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
],
|
||||
@@ -228,8 +218,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'发现新版本 v${updateInfo.latestVersion}'
|
||||
'${updateInfo.releaseNotes.isNotEmpty ? " · ${updateInfo.releaseNotes}" : ""}',
|
||||
'发现新版本 v${updateInfo.latestVersion},'
|
||||
'建议立即更新以获得最新功能与修复',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF5D4037),
|
||||
fontSize: 13),
|
||||
@@ -237,8 +227,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => launchUpdateUrl(
|
||||
updateInfo.downloadUrls),
|
||||
onPressed: () =>
|
||||
startInAppUpdate(context, updateInfo),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
const Color(0xFFF57F17)),
|
||||
|
||||
@@ -137,7 +137,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
|
||||
Future<void> _printOrder() async {
|
||||
if (_loadedOrder == null) return;
|
||||
await printStockInOrder(_loadedOrder!);
|
||||
await safePrint(context, () => printStockInOrder(_loadedOrder!));
|
||||
}
|
||||
|
||||
Future<void> _submit(bool asDraft) async {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import '../../core/utils/dialog_util.dart';
|
||||
import '../../core/errors/error_reporter.dart';
|
||||
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, printProductLabel;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -248,7 +250,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
await printStockInOrder(order);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockInOrder(order));
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
@@ -690,7 +694,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||
tooltip: '打印',
|
||||
onPressed: () => printStockInOrder(_loadedOrder!),
|
||||
onPressed: () => safePrint(context, () => printStockInOrder(_loadedOrder!)),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
@@ -948,7 +952,8 @@ class _LabelPrintDialogState extends State<_LabelPrintDialog> {
|
||||
);
|
||||
done++;
|
||||
if (mounted) setState(() => _status = '已打印 $done 张...');
|
||||
} catch (e) {
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
if (mounted) setState(() => _status = '第${i + 1}行打印失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
|
||||
Future<void> _printOrder() async {
|
||||
if (_loadedOrder == null) return;
|
||||
await printStockOutOrder(_loadedOrder!);
|
||||
await safePrint(context, () => printStockOutOrder(_loadedOrder!));
|
||||
}
|
||||
|
||||
Future<void> _submit(bool asDraft) async {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import '../../repositories/product_repository.dart';
|
||||
import '../../core/utils/dialog_util.dart';
|
||||
import '../../core/errors/error_reporter.dart';
|
||||
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder, printProductLabel;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -255,7 +257,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
await printStockOutOrder(order);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockOutOrder(order));
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
@@ -682,7 +686,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||
tooltip: '打印',
|
||||
onPressed: () => printStockOutOrder(_loadedOrder!),
|
||||
onPressed: () => safePrint(context, () => printStockOutOrder(_loadedOrder!)),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
@@ -942,10 +946,9 @@ class _LabelPrintDialogState extends State<_LabelPrintDialog> {
|
||||
);
|
||||
done++;
|
||||
if (mounted) setState(() => _status = '已打印 $done 张...');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _status = '第${i + 1}行打印失败:$e');
|
||||
}
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
if (mounted) setState(() => _status = '第${i + 1}行打印失败:$e');
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
|
||||
+12
-28
@@ -69,10 +69,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -205,14 +205,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -225,10 +217,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -373,22 +365,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -694,10 +678,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -734,10 +718,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -758,10 +742,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
name: jiu_client
|
||||
description: 酒库管理系统 - 酒店仓库管理
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
version: 1.0.3+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -37,3 +37,4 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/fonts/NotoSansSC-Regular.ttf
|
||||
- assets/config/app_info.json
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
// 回归测试:打印用的中文字体必须是 TrueType(glyf) 轮廓。
|
||||
// 若误用 OpenType/CFF 字体(如 Noto 的 .otf),dart_pdf 渲染中文会抛
|
||||
// "This font does not support Unicode characters",标签打印会在原生 onLayout
|
||||
// 回调里抛异常导致 Windows 打印卡死。此测试确保打印 PDF 能正常生成中文。
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('打印字体能渲染中文(防止退回 CFF 字体导致打印卡死)', () async {
|
||||
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
|
||||
final font = pw.Font.ttf(data);
|
||||
|
||||
final doc = pw.Document();
|
||||
doc.addPage(pw.Page(
|
||||
build: (_) => pw.Text('入库单 茅台飞天 53度 500ml 扫码溯源',
|
||||
style: pw.TextStyle(font: font, fontSize: 12)),
|
||||
));
|
||||
|
||||
// 若字体是 CFF,会在此抛 Latin1/Unicode 异常。
|
||||
final bytes = await doc.save();
|
||||
expect(bytes.isNotEmpty, true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
flutter/ephemeral/
|
||||
|
||||
# Visual Studio user-specific files.
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Visual Studio build-related files.
|
||||
x64/
|
||||
x86/
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
@@ -0,0 +1,108 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(jiu_client LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "jiu_client")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(VERSION 3.14...3.25)
|
||||
|
||||
# Define build configuration option.
|
||||
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
|
||||
if(IS_MULTICONFIG)
|
||||
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
|
||||
CACHE STRING "" FORCE)
|
||||
else()
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
endif()
|
||||
# Define settings for the Profile build mode.
|
||||
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
|
||||
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
|
||||
|
||||
# Use Unicode for all projects.
|
||||
add_definitions(-DUNICODE -D_UNICODE)
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_17)
|
||||
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
|
||||
target_compile_options(${TARGET} PRIVATE /EHsc)
|
||||
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# Support files are copied into place next to the executable, so that it can
|
||||
# run in place. This is done instead of making a separate bundle (as on Linux)
|
||||
# so that building and running from within Visual Studio will work.
|
||||
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
|
||||
# Make the "install" step default, as it's required to run.
|
||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
if(PLUGIN_BUNDLED_LIBRARIES)
|
||||
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
CONFIGURATIONS Profile;Release
|
||||
COMPONENT Runtime)
|
||||
@@ -0,0 +1,109 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
|
||||
|
||||
# Set fallback configurations for older versions of the flutter tool.
|
||||
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
|
||||
set(FLUTTER_TARGET_PLATFORM "windows-x64")
|
||||
endif()
|
||||
|
||||
# === Flutter Library ===
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"flutter_export.h"
|
||||
"flutter_windows.h"
|
||||
"flutter_messenger.h"
|
||||
"flutter_plugin_registrar.h"
|
||||
"flutter_texture_registrar.h"
|
||||
)
|
||||
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Wrapper ===
|
||||
list(APPEND CPP_WRAPPER_SOURCES_CORE
|
||||
"core_implementations.cc"
|
||||
"standard_codec.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
|
||||
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
|
||||
"plugin_registrar.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
|
||||
list(APPEND CPP_WRAPPER_SOURCES_APP
|
||||
"flutter_engine.cc"
|
||||
"flutter_view_controller.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
|
||||
|
||||
# Wrapper sources needed for a plugin.
|
||||
add_library(flutter_wrapper_plugin STATIC
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
)
|
||||
apply_standard_settings(flutter_wrapper_plugin)
|
||||
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON)
|
||||
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
|
||||
target_include_directories(flutter_wrapper_plugin PUBLIC
|
||||
"${WRAPPER_ROOT}/include"
|
||||
)
|
||||
add_dependencies(flutter_wrapper_plugin flutter_assemble)
|
||||
|
||||
# Wrapper sources needed for the runner.
|
||||
add_library(flutter_wrapper_app STATIC
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
)
|
||||
apply_standard_settings(flutter_wrapper_app)
|
||||
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
|
||||
target_include_directories(flutter_wrapper_app PUBLIC
|
||||
"${WRAPPER_ROOT}/include"
|
||||
)
|
||||
add_dependencies(flutter_wrapper_app flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
|
||||
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
${PHONY_OUTPUT}
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
|
||||
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter/plugin_registry.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
@@ -0,0 +1,26 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
printing
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
@@ -0,0 +1,40 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME} WIN32
|
||||
"flutter_window.cpp"
|
||||
"main.cpp"
|
||||
"utils.cpp"
|
||||
"win32_window.cpp"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
"Runner.rc"
|
||||
"runner.exe.manifest"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add preprocessor definitions for the build version.
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
|
||||
|
||||
# Disable Windows macros that collide with C++ standard library functions.
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
||||
|
||||
# Add dependency libraries and include directories. Add any application-specific
|
||||
# dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
@@ -0,0 +1,121 @@
|
||||
// Microsoft Visual C++ generated resource script.
|
||||
//
|
||||
#pragma code_page(65001)
|
||||
#include "resource.h"
|
||||
|
||||
#define APSTUDIO_READONLY_SYMBOLS
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Generated from the TEXTINCLUDE 2 resource.
|
||||
//
|
||||
#include "winres.h"
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
#undef APSTUDIO_READONLY_SYMBOLS
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// English (United States) resources
|
||||
|
||||
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
|
||||
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
|
||||
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// TEXTINCLUDE
|
||||
//
|
||||
|
||||
1 TEXTINCLUDE
|
||||
BEGIN
|
||||
"resource.h\0"
|
||||
END
|
||||
|
||||
2 TEXTINCLUDE
|
||||
BEGIN
|
||||
"#include ""winres.h""\r\n"
|
||||
"\0"
|
||||
END
|
||||
|
||||
3 TEXTINCLUDE
|
||||
BEGIN
|
||||
"\r\n"
|
||||
"\0"
|
||||
END
|
||||
|
||||
#endif // APSTUDIO_INVOKED
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Icon
|
||||
//
|
||||
|
||||
// Icon with lowest ID value placed first to ensure application icon
|
||||
// remains consistent on all systems.
|
||||
IDI_APP_ICON ICON "resources\\app_icon.ico"
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Version
|
||||
//
|
||||
|
||||
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
|
||||
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
|
||||
#else
|
||||
#define VERSION_AS_NUMBER 1,0,0,0
|
||||
#endif
|
||||
|
||||
#if defined(FLUTTER_VERSION)
|
||||
#define VERSION_AS_STRING FLUTTER_VERSION
|
||||
#else
|
||||
#define VERSION_AS_STRING "1.0.0"
|
||||
#endif
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION VERSION_AS_NUMBER
|
||||
PRODUCTVERSION VERSION_AS_NUMBER
|
||||
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||
#ifdef _DEBUG
|
||||
FILEFLAGS VS_FF_DEBUG
|
||||
#else
|
||||
FILEFLAGS 0x0L
|
||||
#endif
|
||||
FILEOS VOS__WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "com.yanmei" "\0"
|
||||
VALUE "FileDescription", "jiu_client" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "jiu_client" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.yanmei. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "jiu_client.exe" "\0"
|
||||
VALUE "ProductName", "jiu_client" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1252
|
||||
END
|
||||
END
|
||||
|
||||
#endif // English (United States) resources
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
#ifndef APSTUDIO_INVOKED
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Generated from the TEXTINCLUDE 3 resource.
|
||||
//
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
#endif // not APSTUDIO_INVOKED
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "flutter_window.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
FlutterWindow::~FlutterWindow() {}
|
||||
|
||||
bool FlutterWindow::OnCreate() {
|
||||
if (!Win32Window::OnCreate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
// The size here must match the window dimensions to avoid unnecessary surface
|
||||
// creation / destruction in the startup path.
|
||||
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
|
||||
frame.right - frame.left, frame.bottom - frame.top, project_);
|
||||
// Ensure that basic setup of the controller was successful.
|
||||
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
|
||||
return false;
|
||||
}
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
this->Show();
|
||||
});
|
||||
|
||||
// Flutter can complete the first frame before the "show window" callback is
|
||||
// registered. The following call ensures a frame is pending to ensure the
|
||||
// window is shown. It is a no-op if the first frame hasn't completed yet.
|
||||
flutter_controller_->ForceRedraw();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
}
|
||||
|
||||
Win32Window::OnDestroy();
|
||||
}
|
||||
|
||||
LRESULT
|
||||
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||
if (flutter_controller_) {
|
||||
std::optional<LRESULT> result =
|
||||
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
|
||||
lparam);
|
||||
if (result) {
|
||||
return *result;
|
||||
}
|
||||
}
|
||||
|
||||
switch (message) {
|
||||
case WM_FONTCHANGE:
|
||||
flutter_controller_->engine()->ReloadSystemFonts();
|
||||
break;
|
||||
}
|
||||
|
||||
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#ifndef RUNNER_FLUTTER_WINDOW_H_
|
||||
#define RUNNER_FLUTTER_WINDOW_H_
|
||||
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "win32_window.h"
|
||||
|
||||
// A window that does nothing but host a Flutter view.
|
||||
class FlutterWindow : public Win32Window {
|
||||
public:
|
||||
// Creates a new FlutterWindow hosting a Flutter view running |project|.
|
||||
explicit FlutterWindow(const flutter::DartProject& project);
|
||||
virtual ~FlutterWindow();
|
||||
|
||||
protected:
|
||||
// Win32Window:
|
||||
bool OnCreate() override;
|
||||
void OnDestroy() override;
|
||||
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept override;
|
||||
|
||||
private:
|
||||
// The project to run.
|
||||
flutter::DartProject project_;
|
||||
|
||||
// The Flutter instance hosted by this window.
|
||||
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||
};
|
||||
|
||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||
@@ -0,0 +1,57 @@
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "flutter_window.h"
|
||||
#include "utils.h"
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
// Attach to console when present (e.g., 'flutter run') or create a
|
||||
// new console when running with a debugger.
|
||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||
CreateAndAttachConsole();
|
||||
}
|
||||
|
||||
// Initialize COM, so that it is available for use in the library and/or
|
||||
// plugins.
|
||||
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
||||
flutter::DartProject project(L"data");
|
||||
|
||||
std::vector<std::string> command_line_arguments =
|
||||
GetCommandLineArguments();
|
||||
|
||||
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
// Window title as UTF-8 bytes (hex-escaped to keep this source pure ASCII and
|
||||
// avoid MSVC C4819 under GBK code page 936). Decoded to UTF-16 for Win32.
|
||||
// Bytes below are the UTF-8 encoding of the Chinese app title.
|
||||
const std::string title_utf8 =
|
||||
"\xE5\xB2\xA9\xE7\xBE\x8E\xE9\x85\x92\xE5\xBA\x93";
|
||||
int title_len = ::MultiByteToWideChar(CP_UTF8, 0, title_utf8.c_str(), -1,
|
||||
nullptr, 0);
|
||||
std::wstring window_title(title_len > 0 ? title_len - 1 : 0, L'\0');
|
||||
if (title_len > 0) {
|
||||
::MultiByteToWideChar(CP_UTF8, 0, title_utf8.c_str(), -1, &window_title[0],
|
||||
title_len);
|
||||
}
|
||||
if (!window.Create(window_title, origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
::MSG msg;
|
||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||
::TranslateMessage(&msg);
|
||||
::DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
::CoUninitialize();
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//{{NO_DEPENDENCIES}}
|
||||
// Microsoft Visual C++ generated include file.
|
||||
// Used by Runner.rc
|
||||
//
|
||||
#define IDI_APP_ICON 101
|
||||
|
||||
// Next default values for new objects
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 102
|
||||
#define _APS_NEXT_COMMAND_VALUE 40001
|
||||
#define _APS_NEXT_CONTROL_VALUE 1001
|
||||
#define _APS_NEXT_SYMED_VALUE 101
|
||||
#endif
|
||||
#endif
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "utils.h"
|
||||
|
||||
#include <flutter_windows.h>
|
||||
#include <io.h>
|
||||
#include <stdio.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
void CreateAndAttachConsole() {
|
||||
if (::AllocConsole()) {
|
||||
FILE *unused;
|
||||
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
|
||||
_dup2(_fileno(stdout), 1);
|
||||
}
|
||||
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
|
||||
_dup2(_fileno(stdout), 2);
|
||||
}
|
||||
std::ios::sync_with_stdio();
|
||||
FlutterDesktopResyncOutputStreams();
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> GetCommandLineArguments() {
|
||||
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
|
||||
int argc;
|
||||
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
|
||||
if (argv == nullptr) {
|
||||
return std::vector<std::string>();
|
||||
}
|
||||
|
||||
std::vector<std::string> command_line_arguments;
|
||||
|
||||
// Skip the first argument as it's the binary name.
|
||||
for (int i = 1; i < argc; i++) {
|
||||
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
|
||||
}
|
||||
|
||||
::LocalFree(argv);
|
||||
|
||||
return command_line_arguments;
|
||||
}
|
||||
|
||||
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
|
||||
if (utf16_string == nullptr) {
|
||||
return std::string();
|
||||
}
|
||||
unsigned int target_length = ::WideCharToMultiByte(
|
||||
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||
-1, nullptr, 0, nullptr, nullptr)
|
||||
-1; // remove the trailing null character
|
||||
int input_length = (int)wcslen(utf16_string);
|
||||
std::string utf8_string;
|
||||
if (target_length == 0 || target_length > utf8_string.max_size()) {
|
||||
return utf8_string;
|
||||
}
|
||||
utf8_string.resize(target_length);
|
||||
int converted_length = ::WideCharToMultiByte(
|
||||
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||
input_length, utf8_string.data(), target_length, nullptr, nullptr);
|
||||
if (converted_length == 0) {
|
||||
return std::string();
|
||||
}
|
||||
return utf8_string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef RUNNER_UTILS_H_
|
||||
#define RUNNER_UTILS_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Creates a console for the process, and redirects stdout and stderr to
|
||||
// it for both the runner and the Flutter library.
|
||||
void CreateAndAttachConsole();
|
||||
|
||||
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
|
||||
// encoded in UTF-8. Returns an empty std::string on failure.
|
||||
std::string Utf8FromUtf16(const wchar_t* utf16_string);
|
||||
|
||||
// Gets the command line arguments passed in as a std::vector<std::string>,
|
||||
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
|
||||
std::vector<std::string> GetCommandLineArguments();
|
||||
|
||||
#endif // RUNNER_UTILS_H_
|
||||
@@ -0,0 +1,288 @@
|
||||
#include "win32_window.h"
|
||||
|
||||
#include <dwmapi.h>
|
||||
#include <flutter_windows.h>
|
||||
|
||||
#include "resource.h"
|
||||
|
||||
namespace {
|
||||
|
||||
/// Window attribute that enables dark mode window decorations.
|
||||
///
|
||||
/// Redefined in case the developer's machine has a Windows SDK older than
|
||||
/// version 10.0.22000.0.
|
||||
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||
#endif
|
||||
|
||||
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
|
||||
|
||||
/// Registry key for app theme preference.
|
||||
///
|
||||
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
|
||||
/// value indicates apps should use light mode.
|
||||
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
|
||||
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
|
||||
|
||||
// The number of Win32Window objects that currently exist.
|
||||
static int g_active_window_count = 0;
|
||||
|
||||
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
|
||||
|
||||
// Scale helper to convert logical scaler values to physical using passed in
|
||||
// scale factor
|
||||
int Scale(int source, double scale_factor) {
|
||||
return static_cast<int>(source * scale_factor);
|
||||
}
|
||||
|
||||
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
|
||||
// This API is only needed for PerMonitor V1 awareness mode.
|
||||
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
|
||||
HMODULE user32_module = LoadLibraryA("User32.dll");
|
||||
if (!user32_module) {
|
||||
return;
|
||||
}
|
||||
auto enable_non_client_dpi_scaling =
|
||||
reinterpret_cast<EnableNonClientDpiScaling*>(
|
||||
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
|
||||
if (enable_non_client_dpi_scaling != nullptr) {
|
||||
enable_non_client_dpi_scaling(hwnd);
|
||||
}
|
||||
FreeLibrary(user32_module);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Manages the Win32Window's window class registration.
|
||||
class WindowClassRegistrar {
|
||||
public:
|
||||
~WindowClassRegistrar() = default;
|
||||
|
||||
// Returns the singleton registrar instance.
|
||||
static WindowClassRegistrar* GetInstance() {
|
||||
if (!instance_) {
|
||||
instance_ = new WindowClassRegistrar();
|
||||
}
|
||||
return instance_;
|
||||
}
|
||||
|
||||
// Returns the name of the window class, registering the class if it hasn't
|
||||
// previously been registered.
|
||||
const wchar_t* GetWindowClass();
|
||||
|
||||
// Unregisters the window class. Should only be called if there are no
|
||||
// instances of the window.
|
||||
void UnregisterWindowClass();
|
||||
|
||||
private:
|
||||
WindowClassRegistrar() = default;
|
||||
|
||||
static WindowClassRegistrar* instance_;
|
||||
|
||||
bool class_registered_ = false;
|
||||
};
|
||||
|
||||
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
|
||||
|
||||
const wchar_t* WindowClassRegistrar::GetWindowClass() {
|
||||
if (!class_registered_) {
|
||||
WNDCLASS window_class{};
|
||||
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||
window_class.lpszClassName = kWindowClassName;
|
||||
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
||||
window_class.cbClsExtra = 0;
|
||||
window_class.cbWndExtra = 0;
|
||||
window_class.hInstance = GetModuleHandle(nullptr);
|
||||
window_class.hIcon =
|
||||
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
|
||||
window_class.hbrBackground = 0;
|
||||
window_class.lpszMenuName = nullptr;
|
||||
window_class.lpfnWndProc = Win32Window::WndProc;
|
||||
RegisterClass(&window_class);
|
||||
class_registered_ = true;
|
||||
}
|
||||
return kWindowClassName;
|
||||
}
|
||||
|
||||
void WindowClassRegistrar::UnregisterWindowClass() {
|
||||
UnregisterClass(kWindowClassName, nullptr);
|
||||
class_registered_ = false;
|
||||
}
|
||||
|
||||
Win32Window::Win32Window() {
|
||||
++g_active_window_count;
|
||||
}
|
||||
|
||||
Win32Window::~Win32Window() {
|
||||
--g_active_window_count;
|
||||
Destroy();
|
||||
}
|
||||
|
||||
bool Win32Window::Create(const std::wstring& title,
|
||||
const Point& origin,
|
||||
const Size& size) {
|
||||
Destroy();
|
||||
|
||||
const wchar_t* window_class =
|
||||
WindowClassRegistrar::GetInstance()->GetWindowClass();
|
||||
|
||||
const POINT target_point = {static_cast<LONG>(origin.x),
|
||||
static_cast<LONG>(origin.y)};
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
HWND window = CreateWindow(
|
||||
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
|
||||
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
|
||||
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
|
||||
nullptr, nullptr, GetModuleHandle(nullptr), this);
|
||||
|
||||
if (!window) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateTheme(window);
|
||||
|
||||
return OnCreate();
|
||||
}
|
||||
|
||||
bool Win32Window::Show() {
|
||||
return ShowWindow(window_handle_, SW_SHOWNORMAL);
|
||||
}
|
||||
|
||||
// static
|
||||
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
if (message == WM_NCCREATE) {
|
||||
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
|
||||
SetWindowLongPtr(window, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||
|
||||
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
|
||||
EnableFullDpiSupportIfAvailable(window);
|
||||
that->window_handle_ = window;
|
||||
} else if (Win32Window* that = GetThisFromHandle(window)) {
|
||||
return that->MessageHandler(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
return DefWindowProc(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
LRESULT
|
||||
Win32Window::MessageHandler(HWND hwnd,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
switch (message) {
|
||||
case WM_DESTROY:
|
||||
window_handle_ = nullptr;
|
||||
Destroy();
|
||||
if (quit_on_close_) {
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DPICHANGED: {
|
||||
auto newRectSize = reinterpret_cast<RECT*>(lparam);
|
||||
LONG newWidth = newRectSize->right - newRectSize->left;
|
||||
LONG newHeight = newRectSize->bottom - newRectSize->top;
|
||||
|
||||
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
|
||||
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
return 0;
|
||||
}
|
||||
case WM_SIZE: {
|
||||
RECT rect = GetClientArea();
|
||||
if (child_content_ != nullptr) {
|
||||
// Size and position the child window.
|
||||
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
|
||||
rect.bottom - rect.top, TRUE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_ACTIVATE:
|
||||
if (child_content_ != nullptr) {
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DWMCOLORIZATIONCOLORCHANGED:
|
||||
UpdateTheme(hwnd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DefWindowProc(window_handle_, message, wparam, lparam);
|
||||
}
|
||||
|
||||
void Win32Window::Destroy() {
|
||||
OnDestroy();
|
||||
|
||||
if (window_handle_) {
|
||||
DestroyWindow(window_handle_);
|
||||
window_handle_ = nullptr;
|
||||
}
|
||||
if (g_active_window_count == 0) {
|
||||
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
|
||||
}
|
||||
}
|
||||
|
||||
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
|
||||
return reinterpret_cast<Win32Window*>(
|
||||
GetWindowLongPtr(window, GWLP_USERDATA));
|
||||
}
|
||||
|
||||
void Win32Window::SetChildContent(HWND content) {
|
||||
child_content_ = content;
|
||||
SetParent(content, window_handle_);
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
|
||||
frame.bottom - frame.top, true);
|
||||
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
|
||||
RECT Win32Window::GetClientArea() {
|
||||
RECT frame;
|
||||
GetClientRect(window_handle_, &frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
HWND Win32Window::GetHandle() {
|
||||
return window_handle_;
|
||||
}
|
||||
|
||||
void Win32Window::SetQuitOnClose(bool quit_on_close) {
|
||||
quit_on_close_ = quit_on_close;
|
||||
}
|
||||
|
||||
bool Win32Window::OnCreate() {
|
||||
// No-op; provided for subclasses.
|
||||
return true;
|
||||
}
|
||||
|
||||
void Win32Window::OnDestroy() {
|
||||
// No-op; provided for subclasses.
|
||||
}
|
||||
|
||||
void Win32Window::UpdateTheme(HWND const window) {
|
||||
DWORD light_mode;
|
||||
DWORD light_mode_size = sizeof(light_mode);
|
||||
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
|
||||
kGetPreferredBrightnessRegValue,
|
||||
RRF_RT_REG_DWORD, nullptr, &light_mode,
|
||||
&light_mode_size);
|
||||
|
||||
if (result == ERROR_SUCCESS) {
|
||||
BOOL enable_dark_mode = light_mode == 0;
|
||||
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
&enable_dark_mode, sizeof(enable_dark_mode));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#ifndef RUNNER_WIN32_WINDOW_H_
|
||||
#define RUNNER_WIN32_WINDOW_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
|
||||
// inherited from by classes that wish to specialize with custom
|
||||
// rendering and input handling
|
||||
class Win32Window {
|
||||
public:
|
||||
struct Point {
|
||||
unsigned int x;
|
||||
unsigned int y;
|
||||
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
|
||||
};
|
||||
|
||||
struct Size {
|
||||
unsigned int width;
|
||||
unsigned int height;
|
||||
Size(unsigned int width, unsigned int height)
|
||||
: width(width), height(height) {}
|
||||
};
|
||||
|
||||
Win32Window();
|
||||
virtual ~Win32Window();
|
||||
|
||||
// Creates a win32 window with |title| that is positioned and sized using
|
||||
// |origin| and |size|. New windows are created on the default monitor. Window
|
||||
// sizes are specified to the OS in physical pixels, hence to ensure a
|
||||
// consistent size this function will scale the inputted width and height as
|
||||
// as appropriate for the default monitor. The window is invisible until
|
||||
// |Show| is called. Returns true if the window was created successfully.
|
||||
bool Create(const std::wstring& title, const Point& origin, const Size& size);
|
||||
|
||||
// Show the current window. Returns true if the window was successfully shown.
|
||||
bool Show();
|
||||
|
||||
// Release OS resources associated with window.
|
||||
void Destroy();
|
||||
|
||||
// Inserts |content| into the window tree.
|
||||
void SetChildContent(HWND content);
|
||||
|
||||
// Returns the backing Window handle to enable clients to set icon and other
|
||||
// window properties. Returns nullptr if the window has been destroyed.
|
||||
HWND GetHandle();
|
||||
|
||||
// If true, closing this window will quit the application.
|
||||
void SetQuitOnClose(bool quit_on_close);
|
||||
|
||||
// Return a RECT representing the bounds of the current client area.
|
||||
RECT GetClientArea();
|
||||
|
||||
protected:
|
||||
// Processes and route salient window messages for mouse handling,
|
||||
// size change and DPI. Delegates handling of these to member overloads that
|
||||
// inheriting classes can handle.
|
||||
virtual LRESULT MessageHandler(HWND window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept;
|
||||
|
||||
// Called when CreateAndShow is called, allowing subclass window-related
|
||||
// setup. Subclasses should return false if setup fails.
|
||||
virtual bool OnCreate();
|
||||
|
||||
// Called when Destroy is called.
|
||||
virtual void OnDestroy();
|
||||
|
||||
private:
|
||||
friend class WindowClassRegistrar;
|
||||
|
||||
// OS callback called by message pump. Handles the WM_NCCREATE message which
|
||||
// is passed when the non-client area is being created and enables automatic
|
||||
// non-client DPI scaling so that the non-client area automatically
|
||||
// responds to changes in DPI. All other messages are handled by
|
||||
// MessageHandler.
|
||||
static LRESULT CALLBACK WndProc(HWND const window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept;
|
||||
|
||||
// Retrieves a class instance pointer for |window|
|
||||
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
|
||||
|
||||
// Update the window frame's theme to match the system theme.
|
||||
static void UpdateTheme(HWND const window);
|
||||
|
||||
bool quit_on_close_ = false;
|
||||
|
||||
// window handle for top level window.
|
||||
HWND window_handle_ = nullptr;
|
||||
|
||||
// window handle for hosted content.
|
||||
HWND child_content_ = nullptr;
|
||||
};
|
||||
|
||||
#endif // RUNNER_WIN32_WINDOW_H_
|
||||
@@ -59,6 +59,14 @@ server {
|
||||
try_files /scan.html =404;
|
||||
}
|
||||
|
||||
# 桌面客户端安装包下载(Windows .exe / macOS .zip)
|
||||
location /downloads/ {
|
||||
alias /opt/jiu/downloads/;
|
||||
add_header Content-Disposition "attachment";
|
||||
add_header Cache-Control "no-cache";
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# 营销站点兜底(根路径及所有其他路径)
|
||||
location / {
|
||||
root /opt/jiu/marketing;
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
|
||||
;
|
||||
; To download user-contributed translations of this file, go to:
|
||||
; https://jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Note: When translating this text, do not add periods (.) to the end of
|
||||
; messages that didn't have them already, because on those messages Inno
|
||||
; Setup adds the periods automatically (appending a period would result in
|
||||
; two periods being displayed).
|
||||
;
|
||||
; Maintained by Zhenghan Yang
|
||||
; Email: 847320916@QQ.com
|
||||
; Translation based on network resource
|
||||
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
;
|
||||
|
||||
[LangOptions]
|
||||
; The following three entries are very important. Be sure to read and
|
||||
; understand the '[LangOptions] section' topic in the help file.
|
||||
LanguageName=简体中文
|
||||
; If Language Name display incorrect, uncomment next line
|
||||
; LanguageName=<7B80><4F53><4E2D><6587>
|
||||
; About LanguageID, to reference link:
|
||||
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
|
||||
LanguageID=$0804
|
||||
; About CodePage, to reference link:
|
||||
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
|
||||
LanguageCodePage=936
|
||||
; If the language you are translating to requires special font faces or
|
||||
; sizes, uncomment any of the following entries and change them accordingly.
|
||||
;DialogFontName=
|
||||
;DialogFontSize=9
|
||||
;DialogFontBaseScaleWidth=7
|
||||
;DialogFontBaseScaleHeight=15
|
||||
;WelcomeFontName=Segoe UI
|
||||
;WelcomeFontSize=14
|
||||
|
||||
[Messages]
|
||||
|
||||
; *** 应用程序标题
|
||||
SetupAppTitle=安装
|
||||
SetupWindowTitle=安装 - %1
|
||||
UninstallAppTitle=卸载
|
||||
UninstallAppFullTitle=%1 卸载
|
||||
|
||||
; *** Misc. common
|
||||
InformationTitle=信息
|
||||
ConfirmTitle=确认
|
||||
ErrorTitle=错误
|
||||
|
||||
; *** SetupLdr messages
|
||||
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
|
||||
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
|
||||
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
|
||||
HelpTextNote=
|
||||
|
||||
; *** 启动错误消息
|
||||
LastErrorMessage=%1。%n%n错误 %2: %3
|
||||
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
|
||||
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
|
||||
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
|
||||
InvalidParameter=无效的命令行参数:%n%n%1
|
||||
SetupAlreadyRunning=安装程序正在运行。
|
||||
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
|
||||
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
|
||||
NotOnThisPlatform=此程序不能在 %1 上运行。
|
||||
OnlyOnThisPlatform=此程序只能在 %1 上运行。
|
||||
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
|
||||
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
|
||||
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
|
||||
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
|
||||
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
|
||||
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
|
||||
; *** 启动问题
|
||||
PrivilegesRequiredOverrideTitle=选择安装程序模式
|
||||
PrivilegesRequiredOverrideInstruction=选择安装模式
|
||||
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
|
||||
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
|
||||
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
|
||||
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
|
||||
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
|
||||
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
|
||||
|
||||
; *** 其他错误
|
||||
ErrorCreatingDir=安装程序无法创建目录“%1”
|
||||
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
|
||||
|
||||
; *** 安装程序公共消息
|
||||
ExitSetupTitle=退出安装程序
|
||||
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
|
||||
AboutSetupMenuItem=关于安装程序(&A)...
|
||||
AboutSetupTitle=关于安装程序
|
||||
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
|
||||
; *** 按钮
|
||||
ButtonBack=< 上一步(&B)
|
||||
ButtonNext=下一步(&N) >
|
||||
ButtonInstall=安装(&I)
|
||||
ButtonOK=确定
|
||||
ButtonCancel=取消
|
||||
ButtonYes=是(&Y)
|
||||
ButtonYesToAll=全是(&A)
|
||||
ButtonNo=否(&N)
|
||||
ButtonNoToAll=全否(&O)
|
||||
ButtonFinish=完成(&F)
|
||||
ButtonBrowse=浏览(&B)...
|
||||
ButtonWizardBrowse=浏览(&R)...
|
||||
ButtonNewFolder=新建文件夹(&M)
|
||||
|
||||
; *** “选择语言”对话框消息
|
||||
SelectLanguageTitle=选择安装语言
|
||||
SelectLanguageLabel=选择安装时使用的语言。
|
||||
|
||||
; *** 公共向导文字
|
||||
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
|
||||
BeveledLabel=
|
||||
BrowseDialogTitle=浏览文件夹
|
||||
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
|
||||
NewFolderName=新建文件夹
|
||||
|
||||
; *** “欢迎”向导页
|
||||
WelcomeLabel1=欢迎使用 [name] 安装向导
|
||||
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
|
||||
|
||||
; *** “密码”向导页
|
||||
WizardPassword=密码
|
||||
PasswordLabel1=这个安装程序有密码保护。
|
||||
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
|
||||
PasswordEditLabel=密码(&P):
|
||||
IncorrectPassword=您输入的密码不正确,请重新输入。
|
||||
|
||||
; *** “许可协议”向导页
|
||||
WizardLicense=许可协议
|
||||
LicenseLabel=请在继续安装前阅读以下重要信息。
|
||||
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
|
||||
LicenseAccepted=我同意此协议(&A)
|
||||
LicenseNotAccepted=我不同意此协议(&D)
|
||||
|
||||
; *** “信息”向导页
|
||||
WizardInfoBefore=信息
|
||||
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
|
||||
WizardInfoAfter=信息
|
||||
InfoAfterLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
|
||||
|
||||
; *** “用户信息”向导页
|
||||
WizardUserInfo=用户信息
|
||||
UserInfoDesc=请输入您的信息。
|
||||
UserInfoName=用户名(&U):
|
||||
UserInfoOrg=组织(&O):
|
||||
UserInfoSerial=序列号(&S):
|
||||
UserInfoNameRequired=您必须输入用户名。
|
||||
|
||||
; *** “选择目标目录”向导页
|
||||
WizardSelectDir=选择目标位置
|
||||
SelectDirDesc=您想将 [name] 安装在哪里?
|
||||
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
|
||||
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
|
||||
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
|
||||
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
|
||||
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
|
||||
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
|
||||
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
|
||||
DiskSpaceWarningTitle=磁盘空间不足
|
||||
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
|
||||
DirNameTooLong=文件夹名称或路径太长。
|
||||
InvalidDirName=文件夹名称无效。
|
||||
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
DirExistsTitle=文件夹已存在
|
||||
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
|
||||
DirDoesntExistTitle=文件夹不存在
|
||||
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
|
||||
|
||||
; *** “选择组件”向导页
|
||||
WizardSelectComponents=选择组件
|
||||
SelectComponentsDesc=您想安装哪些程序组件?
|
||||
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
|
||||
FullInstallation=完全安装
|
||||
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||
CompactInstallation=简洁安装
|
||||
CustomInstallation=自定义安装
|
||||
NoUninstallWarningTitle=组件已存在
|
||||
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
|
||||
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
|
||||
|
||||
; *** “选择附加任务”向导页
|
||||
WizardSelectTasks=选择附加任务
|
||||
SelectTasksDesc=您想要安装程序执行哪些附加任务?
|
||||
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
|
||||
|
||||
; *** “选择开始菜单文件夹”向导页
|
||||
WizardSelectProgramGroup=选择开始菜单文件夹
|
||||
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
|
||||
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
|
||||
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
MustEnterGroupName=您必须输入一个文件夹名。
|
||||
GroupNameTooLong=文件夹名或路径太长。
|
||||
InvalidGroupName=无效的文件夹名字。
|
||||
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
|
||||
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
|
||||
|
||||
; *** “准备安装”向导页
|
||||
WizardReady=准备安装
|
||||
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
|
||||
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
|
||||
ReadyLabel2b=点击“安装”继续此安装程序。
|
||||
ReadyMemoUserInfo=用户信息:
|
||||
ReadyMemoDir=目标位置:
|
||||
ReadyMemoType=安装类型:
|
||||
ReadyMemoComponents=已选择组件:
|
||||
ReadyMemoGroup=开始菜单文件夹:
|
||||
ReadyMemoTasks=附加任务:
|
||||
|
||||
; *** TExtractionWizardPage 向导页面与 ExtractArchive
|
||||
ExtractingLabel=正在解压文件...
|
||||
ButtonStopExtraction=停止解压(&S)
|
||||
StopExtraction=您确定要停止解压吗?
|
||||
ErrorExtractionAborted=解压已中止
|
||||
ErrorExtractionFailed=解压失败:%1
|
||||
|
||||
; *** 压缩文件解压失败详情
|
||||
ArchiveIncorrectPassword=压缩文件密码不正确
|
||||
ArchiveIsCorrupted=压缩文件已损坏
|
||||
ArchiveUnsupportedFormat=不支持的压缩文件格式
|
||||
|
||||
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
|
||||
DownloadingLabel2=正在下载文件...
|
||||
ButtonStopDownload=停止下载(&S)
|
||||
StopDownload=您确定要停止下载吗?
|
||||
ErrorDownloadAborted=下载已中止
|
||||
ErrorDownloadFailed=下载失败:%1 %2
|
||||
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
|
||||
ErrorProgress=无效的进度:%1 / %2
|
||||
ErrorFileSize=文件大小错误:预期 %1,实际 %2
|
||||
|
||||
; *** “正在准备安装”向导页
|
||||
WizardPreparing=正在准备安装
|
||||
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
|
||||
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。
|
||||
CannotContinue=安装程序不能继续。请点击“取消”退出。
|
||||
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
|
||||
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
|
||||
CloseApplications=自动关闭应用程序(&A)
|
||||
DontCloseApplications=不要关闭应用程序(&D)
|
||||
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
|
||||
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动?
|
||||
|
||||
; *** “正在安装”向导页
|
||||
WizardInstalling=正在安装
|
||||
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
|
||||
|
||||
; *** “安装完成”向导页
|
||||
FinishedHeadingLabel=[name] 安装完成
|
||||
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
|
||||
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
|
||||
ClickFinish=点击“完成”退出安装程序。
|
||||
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
|
||||
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗?
|
||||
ShowReadmeCheck=是,我想查阅自述文件
|
||||
YesRadio=是,立即重启电脑(&Y)
|
||||
NoRadio=否,稍后重启电脑(&N)
|
||||
; used for example as 'Run MyProg.exe'
|
||||
RunEntryExec=运行 %1
|
||||
; used for example as 'View Readme.txt'
|
||||
RunEntryShellExec=查阅 %1
|
||||
|
||||
; *** “安装程序需要下一张磁盘”提示
|
||||
ChangeDiskTitle=安装程序需要下一张磁盘
|
||||
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
|
||||
PathLabel=路径(&P):
|
||||
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
|
||||
SelectDirectoryLabel=请指定下一张磁盘的位置。
|
||||
|
||||
; *** 安装阶段消息
|
||||
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
|
||||
AbortRetryIgnoreSelectAction=选择操作
|
||||
AbortRetryIgnoreRetry=重试(&T)
|
||||
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
|
||||
AbortRetryIgnoreCancel=关闭安装程序
|
||||
RetryCancelSelectAction=选择操作
|
||||
RetryCancelRetry=重试(&T)
|
||||
RetryCancelCancel=取消(&C)
|
||||
|
||||
; *** 安装状态消息
|
||||
StatusClosingApplications=正在关闭应用程序...
|
||||
StatusCreateDirs=正在创建目录...
|
||||
StatusExtractFiles=正在提取文件...
|
||||
StatusDownloadFiles=正在下载文件...
|
||||
StatusCreateIcons=正在创建快捷方式...
|
||||
StatusCreateIniEntries=正在创建 INI 条目...
|
||||
StatusCreateRegistryEntries=正在创建注册表条目...
|
||||
StatusRegisterFiles=正在注册文件...
|
||||
StatusSavingUninstall=正在保存卸载信息...
|
||||
StatusRunProgram=正在完成安装...
|
||||
StatusRestartingApplications=正在重启应用程序...
|
||||
StatusRollback=正在撤销更改...
|
||||
|
||||
; *** 其他错误
|
||||
ErrorInternal2=内部错误:%1
|
||||
ErrorFunctionFailedNoCode=%1 失败
|
||||
ErrorFunctionFailed=%1 失败;错误代码 %2
|
||||
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
|
||||
ErrorExecutingProgram=无法执行文件:%n%1
|
||||
|
||||
; *** 注册表错误
|
||||
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
|
||||
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
|
||||
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
|
||||
|
||||
; *** INI 错误
|
||||
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
|
||||
|
||||
; *** 文件复制错误
|
||||
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
|
||||
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
|
||||
SourceIsCorrupted=源文件已损坏
|
||||
SourceDoesntExist=源文件“%1”不存在
|
||||
SourceVerificationFailed=源文件验证失败: %1
|
||||
VerificationSignatureDoesntExist=签名文件“%1”不存在
|
||||
VerificationSignatureInvalid=签名文件“%1”无效
|
||||
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
|
||||
VerificationFileNameIncorrect=文件名不正确
|
||||
VerificationFileTagIncorrect=文件标签不正确
|
||||
VerificationFileSizeIncorrect=文件大小不正确
|
||||
VerificationFileHashIncorrect=文件哈希值不正确
|
||||
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
|
||||
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
|
||||
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
|
||||
ErrorReadingExistingDest=尝试读取现有文件时出错:
|
||||
FileExistsSelectAction=选择操作
|
||||
FileExists2=文件已经存在。
|
||||
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
|
||||
FileExistsKeepExisting=保留现有的文件(&K)
|
||||
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||
ExistingFileNewerSelectAction=选择操作
|
||||
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
|
||||
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
|
||||
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
|
||||
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
|
||||
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
|
||||
ErrorReadingSource=尝试读取下列源文件时出错:
|
||||
ErrorCopying=尝试复制下列文件时出错:
|
||||
ErrorDownloading=下载文件时出错:
|
||||
ErrorExtracting=解压压缩文件时出错:
|
||||
ErrorReplacingExistingFile=尝试替换现有文件时出错:
|
||||
ErrorRestartReplace=重启并替换失败:
|
||||
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
|
||||
ErrorRegisterServer=无法注册 DLL/OCX:%1
|
||||
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
|
||||
ErrorRegisterTypeLib=无法注册类库:%1
|
||||
|
||||
; *** 卸载显示名字标记
|
||||
; used for example as 'My Program (32-bit)'
|
||||
UninstallDisplayNameMark=%1 (%2)
|
||||
; used for example as 'My Program (32-bit, All users)'
|
||||
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||
UninstallDisplayNameMark32Bit=32 位
|
||||
UninstallDisplayNameMark64Bit=64 位
|
||||
UninstallDisplayNameMarkAllUsers=所有用户
|
||||
UninstallDisplayNameMarkCurrentUser=当前用户
|
||||
|
||||
; *** 安装后错误
|
||||
ErrorOpeningReadme=尝试打开自述文件时出错。
|
||||
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
|
||||
|
||||
; *** 卸载消息
|
||||
UninstallNotFound=文件“%1”不存在。无法卸载。
|
||||
UninstallOpenError=文件“%1”不能被打开。无法卸载。
|
||||
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
|
||||
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
|
||||
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
|
||||
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
|
||||
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
|
||||
UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。
|
||||
UninstalledAll=已顺利从您的电脑中移除 %1。
|
||||
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
|
||||
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗?
|
||||
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
|
||||
|
||||
; *** 卸载状态消息
|
||||
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
|
||||
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
|
||||
SharedFileNameLabel=文件名:
|
||||
SharedFileLocationLabel=位置:
|
||||
WizardUninstalling=卸载状态
|
||||
StatusUninstalling=正在卸载 %1...
|
||||
|
||||
; *** Shutdown block reasons
|
||||
ShutdownBlockReasonInstallingApp=正在安装 %1。
|
||||
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
|
||||
|
||||
; The custom messages below aren't used by Setup itself, but if you make
|
||||
; use of them in your scripts, you'll want to translate them.
|
||||
|
||||
[CustomMessages]
|
||||
|
||||
NameAndVersion=%1 版本 %2
|
||||
AdditionalIcons=附加快捷方式:
|
||||
CreateDesktopIcon=创建桌面快捷方式(&D)
|
||||
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
|
||||
ProgramOnTheWeb=%1 网站
|
||||
UninstallProgram=卸载 %1
|
||||
LaunchProgram=运行 %1
|
||||
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
|
||||
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
|
||||
AutoStartProgramGroupDescription=启动:
|
||||
AutoStartProgram=自动启动 %1
|
||||
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user