Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a62b5e174 | |||
| 47b3c89460 | |||
| bc85b2c8ad | |||
| 13fc9b7aaf | |||
| 97ebb18fd7 | |||
| ebf79d0355 | |||
| c402ea0070 | |||
| 40a80467ce | |||
| 18cd2497c1 | |||
| 8925646eef | |||
| afa1a0bcd2 | |||
| 0ec609255c | |||
| 5ad1289419 | |||
| daa83bd408 | |||
| 65d6b12a21 | |||
| b0a74f3607 | |||
| 543bd2712f | |||
| fab7bd21dd | |||
| 3b2ebc0202 | |||
| ff32c8a845 | |||
| af64e29459 | |||
| f788a7ab74 | |||
| 8a87d4a82c | |||
| 0d17533120 | |||
| 4e64e5eb47 | |||
| 1ec1d4209a | |||
| 22f07ed805 | |||
| 45b6ec832b | |||
| ba127826b2 | |||
| 0e2a4086e1 | |||
| 99f34a6223 | |||
| ce92d619ed | |||
| 22a31f6eb8 | |||
| 1c901432a6 | |||
| e124ed7c65 | |||
| 46304ab961 | |||
| d1260e8258 | |||
| 5b980ec58b | |||
| 2fc5776435 | |||
| bc252d1fcb | |||
| c0001f08c2 | |||
| 46ef7db480 | |||
| 0c96d5e325 | |||
| ced407ea87 | |||
| e550b73d6d | |||
| fa02fdbfd8 | |||
| 211fafc7e1 | |||
| 5278bea940 | |||
| 0bc3f4b702 | |||
| cfeb8f0bba | |||
| 480ce836bb |
@@ -0,0 +1,35 @@
|
||||
---
|
||||
description: 本地编译与静态检查(后端 go build/vet + 前端 flutter analyze)
|
||||
argument-hint: 可选 backend | client,留空则全部
|
||||
model: claude-sonnet-4-6
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
执行本地编译与静态检查,用于快速验证代码可编译、无警告。**不产出发布制品**(多平台打包由 CI 在发版时完成)。
|
||||
|
||||
参数 `$ARGUMENTS`:
|
||||
- 留空:检查后端 + 前端
|
||||
- `backend`:仅检查后端
|
||||
- `client`:仅检查前端
|
||||
|
||||
任意步骤失败立即停止并报告错误(贴出关键错误输出)。
|
||||
|
||||
## 后端检查(参数为空或 backend 时执行)
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd backend && go build ./... # 1. 编译通过
|
||||
cd backend && go vet ./... # 2. 无编译警告
|
||||
```
|
||||
|
||||
## 前端检查(参数为空或 client 时执行)
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings # error 才算失败,warning/info 允许
|
||||
```
|
||||
|
||||
## 完成报告
|
||||
|
||||
全部通过后,用一行 `result:` 汇总(如 `result: 后端 build+vet 通过、前端 analyze 无 error`)。
|
||||
任一失败则停止,指出失败的命令和原因。
|
||||
@@ -1,4 +1,17 @@
|
||||
执行本地发版流程,版本号为 $ARGUMENTS。
|
||||
---
|
||||
description: 执行本地发版流程(build → test → CHANGELOG → commit → tag → push)
|
||||
argument-hint: <version> 如 1.0.22
|
||||
model: claude-sonnet-4-6
|
||||
allowed-tools: Bash, Read, Edit, Write
|
||||
---
|
||||
|
||||
执行本地发版流程。
|
||||
|
||||
**确定版本号:**
|
||||
- 如果 `$ARGUMENTS` 非空,使用它作为版本号(VERSION)。
|
||||
- 如果 `$ARGUMENTS` 为空,运行 `git describe --tags --abbrev=0` 获取最新 tag(如 `v1.0.20`),去掉 `v` 前缀后将 patch 号加 1(如 `1.0.21`),作为版本号(VERSION)。将自动确定的版本号告知用户。
|
||||
|
||||
后续所有步骤中,将确定好的版本号记为 VERSION。
|
||||
|
||||
按以下步骤顺序执行,任意步骤失败立即停止并报告错误。
|
||||
|
||||
@@ -23,13 +36,13 @@ cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
|
||||
## 4. 更新 CHANGELOG.md
|
||||
|
||||
检查 CHANGELOG.md 中是否已有 `## [$ARGUMENTS]` 版本节。
|
||||
检查 CHANGELOG.md 中是否已有 `## [VERSION]` 版本节。
|
||||
|
||||
- **已有**:直接使用,不修改。
|
||||
- **没有**:读取 `git log` 从上一个 tag 到 HEAD 的提交记录,自动生成一个版本节插入到文件顶部(位于已有版本节之前),格式如下:
|
||||
|
||||
```
|
||||
## [$ARGUMENTS] - <今天日期>
|
||||
## [VERSION] - <今天日期>
|
||||
|
||||
### 新功能
|
||||
- ...
|
||||
@@ -58,19 +71,19 @@ cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
将所有改动(包括 CHANGELOG.md)用以下格式提交到 main:
|
||||
|
||||
```
|
||||
chore: release v$ARGUMENTS
|
||||
chore: release vVERSION
|
||||
```
|
||||
|
||||
## 6. 打 tag
|
||||
|
||||
```bash
|
||||
git tag v$ARGUMENTS
|
||||
git tag vVERSION
|
||||
```
|
||||
|
||||
## 7. 推送
|
||||
|
||||
```bash
|
||||
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main v$ARGUMENTS
|
||||
git push ssh://git@git.51yanmei.com:2222/wangjia/jiu.git main vVERSION
|
||||
```
|
||||
|
||||
推送成功后提示用户:CI/CD 已触发,等待 Telegram 通知。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: 运行本地测试(后端 go test + 前端 flutter test)
|
||||
argument-hint: 可选 backend | client,留空则全部
|
||||
model: claude-sonnet-4-6
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
运行本地自动化测试。任意用例失败立即停止并报告(贴出失败用例与关键输出)。
|
||||
|
||||
参数 `$ARGUMENTS`:
|
||||
- 留空:后端 + 前端
|
||||
- `backend`:仅后端
|
||||
- `client`:仅前端
|
||||
|
||||
## 后端测试(参数为空或 backend 时执行)
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd backend && go test ./...
|
||||
```
|
||||
|
||||
不得有 FAIL。`[no test files]` 属正常(该包无测试)。
|
||||
|
||||
## 前端测试(参数为空或 client 时执行)
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
cd client && flutter test
|
||||
```
|
||||
|
||||
须 `All tests passed!`。
|
||||
|
||||
## 完成报告
|
||||
|
||||
全部通过后用一行 `result:` 汇总(如 `result: 后端 go test 全过、前端 121 测试全过`)。
|
||||
任一失败则停止,列出失败用例名与原因,不要自行修改业务代码(如需修复,告知用户或交给对应 coder)。
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
description: 项目 TODO 管理(list / add / status / done / reject / reopen / rm / sub)
|
||||
argument-hint: "list | add <描述> | status <id> <open|doing|done> | done <id> [version] | reject <id> <原因> | reopen <id> | rm <id> | sub add <parent_id> ... | sub status <sid> ..."
|
||||
model: claude-sonnet-4-6
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
管理项目 `todo/todo.json` 待办清单,所有写操作会自动重渲染 `todo/todo.html`。
|
||||
|
||||
**重要规则**:所有待办项必须通过本命令管理,禁止使用其它 todo 工具(TaskCreate/TodoWrite 等)。
|
||||
|
||||
**状态流转**:`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收)
|
||||
- `done` 状态可被用户 `reject`(拒绝),退回 `open` 并升为最高优先级
|
||||
- Claude 可设:`open` / `doing` / `done`;用户专属:`accepted`(`/todo done`)、`reject`
|
||||
|
||||
**子任务(subtask)**:复杂任务(tier-1/2)可拆出子任务,SID 格式为 `{parentId}{Letter}`(如 `21A`、`21B`)。
|
||||
- 父任务在子任务全部 `done` 前**不得**手动设为 `done`;全部完成时**自动**转为 `done`
|
||||
- 子任务状态与父任务独立,自身有 `open` / `doing` / `done` 三态
|
||||
- 子任务可声明依赖(`deps`),HTML 中以绿/红徽章展示依赖完成情况
|
||||
|
||||
---
|
||||
|
||||
## 改动等级 (tier) 与开发流程
|
||||
|
||||
每个 todo 有两个独立维度:**重要度 `level`**(优先级高低)和 **改动等级 `tier`**(复杂度/工作量)。
|
||||
|
||||
| tier | 名称 | 判定标准 | 开发流程 |
|
||||
|------|------|---------|---------|
|
||||
| 1 | 一级 | 涉及接口设计 / 数据库 schema 变更,**或**改动跨越 **2 个以上模块** | **必须先进 plan 模式**,用户批准方案后才能编码;完成后同步更新 `docs/architecture/`、`docs/api/` 等设计文档 |
|
||||
| 2 | 二级 | 模块内较大改动,涉及 **5 个文件以上** | 可直接开发,完成后本地验证(build/test/analyze) |
|
||||
| 3 | 三级 | 小改动、局部优化、bugfix | 可直接开发,完成后本地验证 |
|
||||
|
||||
**判定关键词指引:**
|
||||
- 一级:「新增接口」「改 schema」「新表」「跨模块」「涉及后端+前端」「新增 API」
|
||||
- 二级:「重构」「整页改版」「涉及多个 screen」「较大」
|
||||
- 三级:「修复」「bugfix」「优化」「调整文案」「小改」「单文件」
|
||||
|
||||
**重要**:开发完成后只做本地验证(build / test / analyze),**不要**自动触发 `/release`。发版由用户决定。
|
||||
|
||||
---
|
||||
|
||||
根据 `$ARGUMENTS` 分派,缺省等同 `list`:
|
||||
|
||||
## list(或无参数)
|
||||
|
||||
运行以下命令并将终端 summary 转述给用户,同时提示 HTML 路径便于浏览:
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs list
|
||||
```
|
||||
|
||||
## add <描述>
|
||||
|
||||
将 `$ARGUMENTS` 去掉首词 `add` 后的内容解析为待办描述,推断:
|
||||
- `--title`:核心一句话标题(简洁,去掉平台/类别信息)
|
||||
- `--level`:`high`(阻断/紧急)/ `mid`(重要,默认)/ `low`(一般/优化)
|
||||
- 关键词「紧急」「阻断」「必须」「严重」→ high;「重要」「需要」→ mid;其余 → low
|
||||
- `--tier`:**必须推断**,见上方「改动等级」判定标准,取值 `1`/`2`/`3`
|
||||
- `--tags`:从描述推断平台/类别,可多个逗号分隔,常用值:`前端,后端,Web,mac,Windows,Android,iOS,数据库,CI/CD,文档`
|
||||
- `--desc`:补充说明(可选,如有具体文件/路径/上下文则填入)
|
||||
|
||||
然后运行:
|
||||
```bash
|
||||
node todo/todo.mjs add --title "..." --level mid --tier 2 --tags "前端,Web" --desc "..."
|
||||
```
|
||||
|
||||
转述:「已添加 #id [级别·等级] 标题 标签」,并显示 summary。
|
||||
|
||||
## status <id> <open|doing|done>
|
||||
|
||||
**Claude 在开发过程中主动调用**,更新条目的开发状态:
|
||||
- 开始处理某个 todo → `status <id> doing`(**先确认 tier**:一级须先 plan 模式,二/三级直接开发)
|
||||
- 开发完成提交验收 → `status <id> done`
|
||||
- 退回重做 → `status <id> open`
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs status <id> <open|doing|done>
|
||||
```
|
||||
|
||||
转述:「#id 状态已更新为 xxx」,并显示 summary。
|
||||
注意:`accepted` 不可通过此命令设置,仅用户可标记。
|
||||
|
||||
## done <id> [version]
|
||||
|
||||
**用户调用**,标记条目已验收交付,记录版本号。从 `$ARGUMENTS` 提取 id 和可选 version(格式 vX.Y.Z):
|
||||
|
||||
- 有 version:`node todo/todo.mjs done <id> --version <version>`
|
||||
- 无 version:`node todo/todo.mjs done <id>`(脚本自动用 `git describe --tags --abbrev=0`)
|
||||
|
||||
转述:「#id 已验收,记入版本 vX.Y.Z」,并显示 summary。
|
||||
|
||||
## reject <id> <原因>
|
||||
|
||||
**用户调用**,拒绝验收处于 `done` 状态的条目。拒绝后:
|
||||
- 状态退回 `open`
|
||||
- 优先级强制升为 `high`
|
||||
- 拒绝原因记录在条目上,HTML 卡片中可见
|
||||
|
||||
从 `$ARGUMENTS` 提取 id 和原因文字:
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs reject <id> --reason "<原因>"
|
||||
```
|
||||
|
||||
转述:「#id 已拒绝,优先级升为最高,原因:xxx」,并显示 summary。
|
||||
|
||||
## reopen <id>
|
||||
|
||||
重新开启条目(清空拒绝记录和验收信息),退回 `open` 状态:
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs reopen <id>
|
||||
```
|
||||
|
||||
转述「#id 已重新开启」。
|
||||
|
||||
## rm <id>
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs rm <id>
|
||||
```
|
||||
|
||||
转述「#id 已删除」。
|
||||
|
||||
---
|
||||
|
||||
## sub add <parent_id> --title "..." [--tier N] [--deps "21A,21B"]
|
||||
|
||||
**Claude 在开发过程中主动调用**,为复杂任务(tier-1/2)添加子任务:
|
||||
- `parent_id`:父任务数字 id(如 `21`)
|
||||
- `--title`:子任务标题
|
||||
- `--tier`:改动等级(`1`/`2`/`3`,默认 `2`)
|
||||
- `--deps`:依赖的其他子任务 SID,逗号分隔(如 `"21A,21B"`);被依赖项必须已存在
|
||||
|
||||
SID 自动分配:第一个子任务为 `{parent_id}A`,依此类推(21A, 21B, 21C…)。
|
||||
父任务若为 `open` 状态,添加第一个子任务时自动升为 `doing`。
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs sub add <parent_id> --title "..." [--tier N] [--deps "21A,21B"]
|
||||
```
|
||||
|
||||
转述:「已添加子任务 {SID} [等级]「{title}」→ #parent 依赖: ...」,并显示 summary。
|
||||
|
||||
## sub status <sid> <open|doing|done>
|
||||
|
||||
**Claude 在开发过程中主动调用**,更新子任务状态:
|
||||
- SID 格式:`21A`、`21B`(不区分大小写)
|
||||
- 当**最后一个子任务**标记为 `done` 时,父任务**自动**转为 `done`
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs sub status <sid> <open|doing|done>
|
||||
```
|
||||
|
||||
转述:「{SID}「{title}」→ {状态}」,若触发父任务自动转换则一并提示。
|
||||
|
||||
---
|
||||
|
||||
所有命令完成后,在终端显示来自脚本的完整 summary(按 open / doing / done / accepted 分组)。
|
||||
@@ -52,6 +52,64 @@ jobs:
|
||||
name: macos
|
||||
path: dist/
|
||||
|
||||
build-android:
|
||||
# Same physical mac runner (label: mac, capacity 1) — it already has the
|
||||
# Android SDK. Serialize after build-macos via needs so it doesn't sit
|
||||
# queued and time out.
|
||||
needs: build-macos
|
||||
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 Android APK)
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: sh scripts/ci/compile-android.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload android artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: android
|
||||
path: dist/
|
||||
|
||||
build-ios:
|
||||
# Same physical mac runner (label: mac, capacity 1) with Xcode. Serialize
|
||||
# after build-android. Builds a signed IPA and uploads to TestFlight; if the
|
||||
# iOS signing secrets are absent the script skips gracefully (exit 0), so this
|
||||
# job is safe to keep before the Apple Developer account is configured.
|
||||
needs: build-android
|
||||
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 & upload to TestFlight (Flutter iOS)
|
||||
env:
|
||||
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }}
|
||||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
|
||||
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||
run: sh scripts/ci/compile-ios.sh "${{ gitea.ref_name }}"
|
||||
|
||||
build-windows:
|
||||
runs-on: windows
|
||||
env:
|
||||
@@ -77,7 +135,7 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
release-deploy:
|
||||
needs: [build-linux-web, build-macos, build-windows]
|
||||
needs: [build-linux-web, build-macos, build-android, build-ios, build-windows]
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
@@ -99,6 +157,12 @@ jobs:
|
||||
name: macos
|
||||
path: dist/
|
||||
|
||||
- name: Download android artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: android
|
||||
path: dist/
|
||||
|
||||
- name: Download windows artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
|
||||
@@ -5,6 +5,119 @@ 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.30] - 2026-06-10
|
||||
|
||||
### 新功能
|
||||
- 官网新增更新日志页(/changelog/)、服务条款页(/terms/)、隐私政策页(/privacy/),全站页脚同步补充链接
|
||||
- 授权信息功能:新账号注册自动获得 30 天试用期;设置页「授权」Tab 支持粘贴授权码激活;到期后按宽限期 → 只读 → 锁定三阶段降级,前端对应横幅与弹窗提示
|
||||
- 商品详情页展示所有字段,包括分类、库存预警线、备注、自定义字段、描述文档
|
||||
|
||||
### 改进
|
||||
- 关于我们页「更新日志」按钮现在链接到 /changelog/ 版本历史页
|
||||
|
||||
### 修复
|
||||
- 授权状态接口返回值补充 phase 字段,修复前端横幅与到期弹窗始终不显示的问题
|
||||
|
||||
## [1.0.29] - 2026-06-09
|
||||
|
||||
### 新功能
|
||||
- Windows / macOS 桌面客户端现在限制只能运行一个实例;重复打开时弹窗提示「程序已在运行中」并自动退出
|
||||
|
||||
## [1.0.28] - 2026-06-09
|
||||
|
||||
### 修复
|
||||
- macOS 客户端打包脚本路径错误导致 CI 打包失败,修正后 macOS 安装包可正常构建并发布
|
||||
|
||||
## [1.0.27] - 2026-06-09
|
||||
|
||||
### 改进
|
||||
- 入库单新建表单:产地、保质期、储存方式、批次号、生产日期等选填字段默认折叠,点击「展开选填」按钮后在行内下方显示,减少初始界面信息量
|
||||
- 手机端现可从屏幕左边缘向右滑动打开导航菜单,无需点击汉堡图标
|
||||
- 入库单详情弹窗在手机上商品明细改为卡片式竖排布局,不再显示难以阅读的横向宽表格
|
||||
|
||||
### 修复
|
||||
- macOS 客户端下载 zip 解压失败:打包改用 ditto,正确保留 symlink 和执行权限
|
||||
|
||||
## [1.0.26] - 2026-06-08
|
||||
|
||||
### 新功能
|
||||
- 公开商品 API 新增商品编码、条码、当前库存数量字段,供扫码页及第三方集成使用
|
||||
- 公开页「商品报错」「意见反馈」提交时自动携带门店编号和商品信息,方便运营追踪
|
||||
|
||||
### 修复
|
||||
- 注册页 API 地址改为配置文件驱动,支持分离部署场景(不再依赖 window.location.origin)
|
||||
- 注册成功提示的客户端导航路径由「系统设置 → 关于」修正为「系统设置」
|
||||
- 公开页页脚「关于岩美」链接改为配置项,不再硬编码
|
||||
|
||||
## [1.0.25] - 2026-06-08
|
||||
|
||||
### 新功能
|
||||
- 基础数据页新增「产地」「保质期」「储存方式」「描述文档」4 个字典维护 Tab,可直接在基础数据中增删改查
|
||||
- 商品详情页展示产地、保质期、储存方式参数,并在介绍区上方显示建议零售价
|
||||
- 入库单详情中商品列展示产地、保质期、储存方式信息
|
||||
- 公开扫码页新增「商品报错」「意见反馈」功能,用户可提交反馈
|
||||
|
||||
## [1.0.24] - 2026-06-08
|
||||
|
||||
### 新功能
|
||||
- 扫码商品页新增「查看本店其他商品」功能,点击即可浏览该门店全部上架商品
|
||||
- 门店可在设置中填写微信号,顾客扫码后可直接查看并添加门店微信
|
||||
- 商品公开页展示建议零售价(有填写时显示)
|
||||
- 入库时可为商品关联产地、保质期、储存方式、描述文档,关联后扫码页自动呈现真实信息
|
||||
- 扫码商品页产地、保质期、储存方式、商品介绍改读数据库真实数据,不再显示占位内容
|
||||
|
||||
### 改进
|
||||
- 新增产地/保质期/储存方式/描述文档四类基础数据字典,支持增删改查,可在入库时按需关联商品
|
||||
|
||||
## [1.0.23] - 2026-06-07
|
||||
|
||||
### 修复
|
||||
- 官网功能详情页(库存管理/审核流)导航栏现与主站完全一致,不再缺少「库存」「审核流」入口
|
||||
|
||||
### 改进
|
||||
- 官网功能详情页接入统一模板系统,导航/页脚/版权信息改一处自动同步全站
|
||||
|
||||
## [1.0.22] - 2026-06-07
|
||||
|
||||
### 新功能
|
||||
- 官网移动端适配:手机浏览器下导航折叠、首页各区块单列排版、features 详情页和页脚自适应窄屏
|
||||
|
||||
### 改进
|
||||
- 客户端移动端布局优化:入库/出库详情页商品明细改为卡片式竖排,操作按钮收纳至溢出菜单,窄屏不再横向溢出
|
||||
- 信息架构整理:仓库管理移入基础数据,系统设置新增授权 Tab 和数据管理组,关于我们补充帮助文档/法律信息/系统信息入口
|
||||
|
||||
### 修复
|
||||
- 官网所有功能页死链修正(`/download.html` → `/download/`,`/docs.html` → `/docs/`)
|
||||
- 官网删除未实现功能的虚假宣传(红冲、调拨单、库存推送通知、过期预警等),文案与实际功能对齐
|
||||
- 官网客服邮箱更新为真实邮箱,移除假冒客服电话,页脚清除无目标死链
|
||||
- 导航新增「库存」「审核流」功能页入口,首页模块卡加「了解更多」链接
|
||||
- 下载页接口失败时按钮改为禁用态并提示,不再无响应
|
||||
- 首页 iOS 平台说明改为「TestFlight 内测中」,与下载页状态一致
|
||||
|
||||
## [1.0.21] - 2026-06-07
|
||||
|
||||
### 新功能
|
||||
- 商品详情页添加照片时,手机端(Android/iOS)支持直接拍照或从相册选择
|
||||
|
||||
## [1.0.20] - 2026-06-07
|
||||
|
||||
### 修复
|
||||
- 修复 Android 客户端登录页提示「服务不可达」、无法连接服务器的问题(正式版安装包此前缺少联网权限)
|
||||
|
||||
## [1.0.19] - 2026-06-07
|
||||
|
||||
### 改进
|
||||
- 优化 Android 构建与部署流程(CI/CD 内部调整),功能与界面无变化
|
||||
|
||||
## [1.0.18] - 2026-06-06
|
||||
|
||||
### 新功能
|
||||
- 移动端(Android)界面适配:窄屏(手机)自动切换为移动布局——侧边栏改为侧滑抽屉导航,各列表(库存/出入库/财务/往来单位/基础数据)由宽表格自动切换为卡片流,弹窗与表单自适应屏宽不再溢出;宽屏(桌面/Web)布局保持不变
|
||||
- Android 客户端正式上线:CI/CD 新增 Android APK 构建(正式 release 签名、versionCode 单调递增),随发版自动发布到下载页;下载页 Android 卡片已激活
|
||||
|
||||
### 改进
|
||||
- 表单字段在手机上占满整行,便于点选;底部状态栏在手机端隐藏
|
||||
|
||||
## [1.0.17] - 2026-06-05
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -94,7 +94,7 @@ doc-writer
|
||||
| linter | 格式自动修复(gofmt/dart format),docs/review/lint-report.md | 逻辑代码 |
|
||||
| code-reviewer | docs/review/*-review.md | 任何代码 |
|
||||
| security-auditor | docs/security/ | 任何代码 |
|
||||
| devops | deploy/, .github/workflows/ | 业务代码 |
|
||||
| devops | deploy/, .gitea/workflows/, scripts/ci/ | 业务代码 |
|
||||
| sre | docs/runbooks/ | 任何代码 |
|
||||
| doc-writer | docs/(除 context/ 外) | 任何代码 |
|
||||
|
||||
@@ -240,6 +240,13 @@ cd client && flutter test
|
||||
- 不要在 toolbar 放独立的筛选按钮,筛选入口应内嵌在列头
|
||||
- 列定义使用 `ColDef`,可隐藏列用 `ColumnToggleButton` 控制
|
||||
|
||||
### 响应式 / 移动端适配
|
||||
- 断点判定统一用 `context.isMobile`(`client/lib/core/responsive/responsive.dart`,宽度 < 600 为窄屏/手机),**禁止**在各处散落 `MediaQuery.width < 600` 这类魔法数
|
||||
- **列表屏**:用 `DataTableCard` 时必须同时传 `mobileCards`(窄屏渲染卡片流),卡片复用 `MobileListCard` / `MobileCardField`(`widgets/mobile_list_card.dart`);宽屏仍走原表格,行为不变
|
||||
- **弹窗固定宽度**:一律用 `context.dialogWidth(X)`(≤ 屏宽 92%),**禁止**裸写 `width: <固定值>` 导致窄屏溢出
|
||||
- **导航**:窄屏(手机)用 Drawer 抽屉(`app_shell.dart`),宽屏保持侧边栏;新增页面无需单独处理,挂在 shell 下即可
|
||||
- 平台判断仍遵守上面「前端平台判断」规则(`kIsWeb` 先于 `dart:io`)
|
||||
|
||||
### 权限中间件(已全局挂载)
|
||||
- `middleware.ReadOnly()`:已挂载到主 `api` 路由组,只读用户(role=readonly)所有写操作自动返回 403
|
||||
- `middleware.AdminOnly()`:挂载在 `/users` 路由组,仅管理员可管理用户
|
||||
@@ -261,7 +268,13 @@ cd client && flutter test
|
||||
```
|
||||
|
||||
执行顺序:本地 build → test → 更新 CHANGELOG.md → git commit → tag → push main+tag。
|
||||
CI/CD(Forgejo)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
|
||||
CI/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
|
||||
|
||||
**多平台构建矩阵**(脚本在 `scripts/ci/`):
|
||||
- mac runner(容量 1)串行链:`build-linux-web`(后端+Web+官网)→ `build-macos` → `build-android` → `build-ios` → `release-deploy`
|
||||
- windows runner 并行:`build-windows`(Inno Setup 打包 setup.exe)
|
||||
- 分发:桌面(Win/macOS) 发布到下载页 `/downloads/` + 应用内更新;**Android** APK 同样挂 `/downloads/`,需 `ANDROID_*` 签名 secrets(见 `docs/android-signing.md`);**iOS** 走 TestFlight,需 `IOS_*` / `APPSTORE_*` secrets(见 `docs/ios-signing.md`)
|
||||
- Android/iOS 的 job 在对应 secrets 未配置时**优雅跳过**(exit 0,不阻塞发版);本地无 `client/android/key.properties` 时 APK 自动回退 debug 签名
|
||||
|
||||
**CHANGELOG 格式**(Keep a Changelog):
|
||||
```markdown
|
||||
@@ -295,6 +308,33 @@ CI/CD(Forgejo)收到 tag 后自动:编译 → 测试 → 创建 Release
|
||||
}
|
||||
```
|
||||
|
||||
### 项目 TODO 管理
|
||||
|
||||
**唯一待办系统**:任务执行过程中如发现需要做的事项,**必须**用 `/todo add <描述>` 记入项目 todo 系统,**禁止**使用 TaskCreate/TodoWrite 等其它 todo 工具,保持单一真相源。
|
||||
|
||||
- **真相源**:`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本)
|
||||
- **HTML**:`todo/todo.html` 为生成物,每次写操作自动重渲染
|
||||
- **重要度**:`high`(阻断/紧急)/ `mid`(重要)/ `low`(一般/优化)三级
|
||||
- **改动等级**:`tier 1`(接口/schema/跨 2+ 模块)/ `tier 2`(5+ 文件)/ `tier 3`(小改/bugfix);**一级改动须先进 plan 模式经用户批准,完成后同步更新设计文档**,详见 `/todo`
|
||||
- **状态**:`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收)
|
||||
- **完成即标记**:开发完成后 `/todo status <id> done`,验收通过后用户运行 `/todo done <id>` 记入版本
|
||||
|
||||
常用命令:
|
||||
```
|
||||
/todo — 查看 summary(按状态分组)
|
||||
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
|
||||
/todo status <id> doing — 标记开发中(Claude 开始处理时调用)
|
||||
/todo status <id> done — 标记待验收(Claude 完成开发时调用)
|
||||
/todo done <id> — 用户验收通过,自动记入版本号
|
||||
/todo reject <id> <原因> — 用户拒绝验收,退回 open 并升为最高优先级
|
||||
/todo reopen <id> — 重新开启(清空验收/拒绝记录)
|
||||
/todo rm <id> — 删除
|
||||
```
|
||||
|
||||
底层脚本:`node todo/todo.mjs <子命令> [参数]`
|
||||
|
||||
---
|
||||
|
||||
### 搜索实现(拼音)
|
||||
|
||||
商品名搜索同时支持汉字、全拼、首字母:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// genkey generates an Ed25519 keypair for license signing.
|
||||
// Run once; store the private key in Bitwarden and set the public key in config.
|
||||
//
|
||||
// Usage: go run ./cmd/genkey
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
priv, pub, err := util.GenerateEd25519KeyPair()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to generate keypair: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Ed25519 License Keypair ===")
|
||||
fmt.Println()
|
||||
fmt.Println("[Bitwarden] Private key (keep secret, never commit):")
|
||||
fmt.Println(priv)
|
||||
fmt.Println()
|
||||
fmt.Println("[Config / LICENSE_ED25519_PUBLIC_KEY] Public key:")
|
||||
fmt.Println(pub)
|
||||
fmt.Println()
|
||||
|
||||
// Demo: issue and verify a sample token to confirm the keypair works
|
||||
now := time.Now()
|
||||
exp := now.Add(30 * 24 * time.Hour).Unix()
|
||||
sample := util.LicensePayload{
|
||||
ShopID: 1,
|
||||
LicenseID: 1,
|
||||
Type: "trial",
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MaxDevices: 3,
|
||||
}
|
||||
token, err := util.IssueLicenseToken(sample, priv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "demo sign failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
verified, err := util.VerifyLicenseToken(token, pub)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "demo verify failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out, _ := json.MarshalIndent(verified, "", " ")
|
||||
fmt.Println("[Demo] Sample token (30-day trial, shop_id=1):")
|
||||
fmt.Println(token)
|
||||
fmt.Println()
|
||||
fmt.Println("[Demo] Verified payload:")
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// issue signs a license token for a specific shop.
|
||||
// Usage: go run ./cmd/issue -shop 1 -days 365 -type annual -key <base64-private-key>
|
||||
// Or use env var: LICENSE_ED25519_PRIVATE_KEY=<key> go run ./cmd/issue -shop 1 -days 365
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
shopID := flag.Uint64("shop", 0, "shop ID (required)")
|
||||
licenseID := flag.Uint64("license", 0, "license record ID (optional, 0 = omit)")
|
||||
days := flag.Int("days", 365, "validity days; 0 = perpetual (no expiry)")
|
||||
licType := flag.String("type", "annual", "license type: trial | annual | lifetime")
|
||||
maxDevices := flag.Int("devices", 3, "max devices")
|
||||
privKey := flag.String("key", "", "Ed25519 private key (base64); falls back to LICENSE_ED25519_PRIVATE_KEY env")
|
||||
flag.Parse()
|
||||
|
||||
if *shopID == 0 {
|
||||
fmt.Fprintln(os.Stderr, "error: -shop is required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
key := *privKey
|
||||
if key == "" {
|
||||
key = os.Getenv("LICENSE_ED25519_PRIVATE_KEY")
|
||||
}
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: provide -key or set LICENSE_ED25519_PRIVATE_KEY")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
payload := util.LicensePayload{
|
||||
ShopID: *shopID,
|
||||
Type: *licType,
|
||||
IssuedAt: now.Unix(),
|
||||
MaxDevices: *maxDevices,
|
||||
}
|
||||
if *licenseID > 0 {
|
||||
payload.LicenseID = *licenseID
|
||||
}
|
||||
if *days > 0 {
|
||||
exp := now.Add(time.Duration(*days) * 24 * time.Hour).Unix()
|
||||
payload.ExpiresAt = &exp
|
||||
}
|
||||
|
||||
token, err := util.IssueLicenseToken(payload, key)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "sign failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Println("=== License Token ===")
|
||||
fmt.Println(token)
|
||||
fmt.Println()
|
||||
fmt.Println("=== Payload ===")
|
||||
fmt.Println(string(out))
|
||||
if payload.ExpiresAt != nil {
|
||||
fmt.Printf("\nExpires: %s\n", time.Unix(*payload.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
} else {
|
||||
fmt.Println("\nExpires: never (perpetual)")
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,9 @@ type JWTConfig struct {
|
||||
}
|
||||
|
||||
type LicenseConfig struct {
|
||||
HMACSecret string `mapstructure:"hmac_secret"` // 许可证签名密钥
|
||||
HMACSecret string `mapstructure:"hmac_secret"` // legacy, kept for backward compat
|
||||
Ed25519PublicKey string `mapstructure:"ed25519_public_key"` // base64 Ed25519 public key for token verification
|
||||
Ed25519PrivateKey string `mapstructure:"ed25519_private_key"` // base64 Ed25519 private key for token signing (keep in Bitwarden)
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
@@ -57,6 +59,8 @@ func Load() {
|
||||
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
|
||||
_ = viper.BindEnv("jwt.secret", "JWT_SECRET")
|
||||
_ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET")
|
||||
_ = viper.BindEnv("license.ed25519_public_key", "LICENSE_ED25519_PUBLIC_KEY")
|
||||
_ = viper.BindEnv("license.ed25519_private_key", "LICENSE_ED25519_PRIVATE_KEY")
|
||||
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
|
||||
|
||||
@@ -6,5 +6,5 @@ download_urls:
|
||||
macos: ""
|
||||
windows: ""
|
||||
ios: ""
|
||||
android: ""
|
||||
android: "https://jiu.51yanmei.com/downloads/jiu-android.apk"
|
||||
web: "https://jiu.51yanmei.com/app"
|
||||
|
||||
@@ -17,16 +17,17 @@ func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
|
||||
}
|
||||
|
||||
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 毫秒
|
||||
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"`
|
||||
ProductInfo string `json:"product_info"`
|
||||
ErrorMsg string `json:"error_msg" binding:"required"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
|
||||
}
|
||||
|
||||
// Submit POST /api/v1/public/errors
|
||||
@@ -49,17 +50,18 @@ func (h *ErrorReportHandler) Submit(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
ErrorType: req.ErrorType,
|
||||
AppVersion: req.AppVersion,
|
||||
Platform: req.Platform,
|
||||
Username: req.Username,
|
||||
ShopID: req.ShopID,
|
||||
ShopNo: req.ShopNo,
|
||||
Role: req.Role,
|
||||
ProductInfo: req.ProductInfo,
|
||||
ClientIP: c.ClientIP(),
|
||||
ErrorMsg: req.ErrorMsg,
|
||||
StackTrace: stack,
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&report).Error; err != nil {
|
||||
|
||||
@@ -60,7 +60,15 @@ func (h *LicenseHandler) Info(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": lic})
|
||||
phase := middleware.CalcLicensePhase(lic.ExpiresAt)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||||
"id": lic.ID,
|
||||
"type": lic.Type,
|
||||
"is_active": lic.IsActive,
|
||||
"max_devices": lic.MaxDevices,
|
||||
"expires_at": lic.ExpiresAt,
|
||||
"phase": phase,
|
||||
}})
|
||||
}
|
||||
|
||||
// Deactivate POST /api/v1/license/deactivate
|
||||
|
||||
@@ -127,22 +127,26 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
namePinyin, nameInitials := util.ToPinyin(req.Name)
|
||||
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
|
||||
if err := h.db.Model(&product).Updates(map[string]interface{}{
|
||||
"code": req.Code,
|
||||
"barcode": req.Barcode,
|
||||
"name": req.Name,
|
||||
"series": req.Series,
|
||||
"spec": req.Spec,
|
||||
"unit": req.Unit,
|
||||
"category_id": req.CategoryID,
|
||||
"brand": req.Brand,
|
||||
"purchase_price": req.PurchasePrice,
|
||||
"sale_price": req.SalePrice,
|
||||
"min_stock": req.MinStock,
|
||||
"description": req.Description,
|
||||
"remark": req.Remark,
|
||||
"custom_fields": req.CustomFields,
|
||||
"name_pinyin": namePinyin,
|
||||
"name_initials": nameInitials,
|
||||
"code": req.Code,
|
||||
"barcode": req.Barcode,
|
||||
"name": req.Name,
|
||||
"series": req.Series,
|
||||
"spec": req.Spec,
|
||||
"unit": req.Unit,
|
||||
"category_id": req.CategoryID,
|
||||
"brand": req.Brand,
|
||||
"purchase_price": req.PurchasePrice,
|
||||
"sale_price": req.SalePrice,
|
||||
"min_stock": req.MinStock,
|
||||
"description": req.Description,
|
||||
"remark": req.Remark,
|
||||
"custom_fields": req.CustomFields,
|
||||
"name_pinyin": namePinyin,
|
||||
"name_initials": nameInitials,
|
||||
"origin_id": req.OriginID,
|
||||
"shelf_life_id": req.ShelfLifeID,
|
||||
"storage_id": req.StorageID,
|
||||
"description_doc_id": req.DescriptionDocID,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -161,6 +165,7 @@ func (h *ProductHandler) Detail(c *gin.Context) {
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
Preload("Category").Preload("Images").
|
||||
Preload("Origin").Preload("ShelfLife").Preload("Storage").
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -208,9 +213,13 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
||||
func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Series string `json:"series"`
|
||||
Spec string `json:"spec"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Series string `json:"series"`
|
||||
Spec string `json:"spec"`
|
||||
OriginID *uint64 `json:"origin_id"`
|
||||
ShelfLifeID *uint64 `json:"shelf_life_id"`
|
||||
StorageID *uint64 `json:"storage_id"`
|
||||
DescriptionDocID *uint64 `json:"description_doc_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -221,6 +230,23 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
||||
err := h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
|
||||
shopID, req.Name, req.Series, req.Spec).First(&product).Error
|
||||
if err == nil {
|
||||
// 商品已存在:如果传入了属性 ID,则更新属性关联
|
||||
attrUpdates := map[string]interface{}{}
|
||||
if req.OriginID != nil {
|
||||
attrUpdates["origin_id"] = req.OriginID
|
||||
}
|
||||
if req.ShelfLifeID != nil {
|
||||
attrUpdates["shelf_life_id"] = req.ShelfLifeID
|
||||
}
|
||||
if req.StorageID != nil {
|
||||
attrUpdates["storage_id"] = req.StorageID
|
||||
}
|
||||
if req.DescriptionDocID != nil {
|
||||
attrUpdates["description_doc_id"] = req.DescriptionDocID
|
||||
}
|
||||
if len(attrUpdates) > 0 {
|
||||
h.db.Model(&product).Updates(attrUpdates)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": product})
|
||||
return
|
||||
}
|
||||
@@ -233,14 +259,18 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
||||
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),
|
||||
NamePinyin: namePinyin,
|
||||
NameInitials: nameInitials,
|
||||
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,
|
||||
OriginID: req.OriginID,
|
||||
ShelfLifeID: req.ShelfLifeID,
|
||||
StorageID: req.StorageID,
|
||||
DescriptionDocID: req.DescriptionDocID,
|
||||
}
|
||||
if createErr := h.db.Create(&product).Error; createErr != nil {
|
||||
// Race condition: try to find the record created by another request
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
// ProductAttrHandler 处理商品属性字典(产地/保质期/储存方式/描述文档)
|
||||
type ProductAttrHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProductAttrHandler(db *gorm.DB) *ProductAttrHandler {
|
||||
return &ProductAttrHandler{db: db}
|
||||
}
|
||||
|
||||
// ── 产地(origins) ──────────────────────────────────────────────
|
||||
|
||||
func (h *ProductAttrHandler) ListOrigins(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
items := make([]model.ProductOriginOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductOriginOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductOriginOption
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Code = req.Code
|
||||
item.Name = req.Name
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) DeleteOrigin(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductOriginOption{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ── 保质期(shelf-lives) ─────────────────────────────────────────
|
||||
|
||||
func (h *ProductAttrHandler) ListShelfLives(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
items := make([]model.ProductShelfLifeOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductShelfLifeOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductShelfLifeOption
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Code = req.Code
|
||||
item.Name = req.Name
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) DeleteShelfLife(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductShelfLifeOption{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ── 储存方式(storages) ──────────────────────────────────────────
|
||||
|
||||
func (h *ProductAttrHandler) ListStorages(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
items := make([]model.ProductStorageOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) CreateStorage(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductStorageOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductStorageOption
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Code = req.Code
|
||||
item.Name = req.Name
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) DeleteStorage(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductStorageOption{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ── 描述文档(description-docs) ─────────────────────────────────
|
||||
|
||||
func (h *ProductAttrHandler) ListDescriptionDocs(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
items := make([]model.ProductDescriptionDoc, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductDescriptionDoc{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductDescriptionDoc
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Title = req.Title
|
||||
item.Content = req.Content
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductAttrHandler) DeleteDescriptionDoc(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductDescriptionDoc{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -9,6 +13,12 @@ import (
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
// 默认话术(保质期/储存方式空时使用)
|
||||
const (
|
||||
defaultShelfLife = "无限期(适饮)"
|
||||
defaultStorage = "阴凉干燥、避光保存"
|
||||
)
|
||||
|
||||
type PublicHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -24,6 +34,10 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
|
||||
var product model.Product
|
||||
if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID).
|
||||
Preload("Images").
|
||||
Preload("Origin").
|
||||
Preload("ShelfLife").
|
||||
Preload("Storage").
|
||||
Preload("DescriptionDoc").
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -39,6 +53,7 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
|
||||
"address": shop.Address,
|
||||
"phone": shop.Phone,
|
||||
"business_hours": shop.BusinessHours,
|
||||
"wechat_id": shop.WechatID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,25 +72,105 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
|
||||
"production_date": pdStr,
|
||||
"batch_no": inv.BatchNo,
|
||||
"in_stock_date": inv.CreatedAt.Format("2006-01-02"),
|
||||
"quantity": inv.Quantity,
|
||||
}
|
||||
}
|
||||
|
||||
// 产地:空串表示无,前端不展示该行
|
||||
origin := ""
|
||||
if product.Origin != nil {
|
||||
origin = product.Origin.Name
|
||||
}
|
||||
|
||||
// 保质期:无关联时用默认话术
|
||||
shelfLife := defaultShelfLife
|
||||
if product.ShelfLife != nil {
|
||||
shelfLife = product.ShelfLife.Name
|
||||
}
|
||||
|
||||
// 储存方式:无关联时用默认话术
|
||||
storage := defaultStorage
|
||||
if product.Storage != nil {
|
||||
storage = product.Storage.Name
|
||||
}
|
||||
|
||||
// 介绍三级回退:描述文档 → 旧 Description → 通用中性兜底
|
||||
descTitle, descBody, descKeywords := buildDescription(product)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"id": product.ID,
|
||||
"name": product.Name,
|
||||
"series": product.Series,
|
||||
"spec": product.Spec,
|
||||
"brand": product.Brand,
|
||||
"unit": product.Unit,
|
||||
"description": product.Description,
|
||||
"images": product.Images,
|
||||
"shop": shopData,
|
||||
"batch": batchData,
|
||||
"id": product.ID,
|
||||
"name": product.Name,
|
||||
"code": product.Code,
|
||||
"barcode": product.Barcode,
|
||||
"series": product.Series,
|
||||
"spec": product.Spec,
|
||||
"brand": product.Brand,
|
||||
"unit": product.Unit,
|
||||
"sale_price": product.SalePrice,
|
||||
"description": descBody,
|
||||
"description_title": descTitle,
|
||||
"description_keywords": descKeywords,
|
||||
"origin": origin,
|
||||
"shelf_life": shelfLife,
|
||||
"storage": storage,
|
||||
"images": product.Images,
|
||||
"shop": shopData,
|
||||
"batch": batchData,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildDescription 商品介绍三级回退逻辑
|
||||
// 1. DescriptionDoc.Content(结构化介绍文档)
|
||||
// 2. Product.Description(旧自由文本)
|
||||
// 3. 通用中性兜底(用真实字段拼,不含品牌专属词)
|
||||
func buildDescription(p model.Product) (title, body string, keywords []string) {
|
||||
// 1. 描述文档
|
||||
if p.DescriptionDoc != nil && p.DescriptionDoc.Content != "" {
|
||||
return p.DescriptionDoc.Title, p.DescriptionDoc.Content, buildKeywords(p)
|
||||
}
|
||||
|
||||
// 2. 旧 Description 自由文本
|
||||
if p.Description != "" {
|
||||
return "商品介绍", p.Description, buildKeywords(p)
|
||||
}
|
||||
|
||||
// 3. 通用中性兜底
|
||||
parts := make([]string, 0, 4)
|
||||
if p.Brand != "" && p.Name != "" {
|
||||
parts = append(parts, fmt.Sprintf("%s%s", p.Brand, p.Name))
|
||||
} else if p.Name != "" {
|
||||
parts = append(parts, p.Name)
|
||||
}
|
||||
if p.Series != "" {
|
||||
parts = append(parts, fmt.Sprintf("%s系列", p.Series))
|
||||
}
|
||||
if p.Spec != "" {
|
||||
parts = append(parts, fmt.Sprintf("规格%s", p.Spec))
|
||||
}
|
||||
intro := strings.Join(parts, ",")
|
||||
if intro != "" {
|
||||
intro += "。"
|
||||
}
|
||||
intro += "本商品由门店正品供应,支持扫码验真,请认准官方渠道。"
|
||||
|
||||
return "商品介绍", intro, buildKeywords(p)
|
||||
}
|
||||
|
||||
// buildKeywords 从真实商品属性派生关键词 chips
|
||||
func buildKeywords(p model.Product) []string {
|
||||
kws := make([]string, 0, 4)
|
||||
if p.Brand != "" {
|
||||
kws = append(kws, p.Brand)
|
||||
}
|
||||
if p.Series != "" {
|
||||
kws = append(kws, p.Series)
|
||||
}
|
||||
kws = append(kws, "正品保障", "扫码验真")
|
||||
return kws
|
||||
}
|
||||
|
||||
// GetRelease GET /api/v1/public/release (no auth)
|
||||
func (h *PublicHandler) GetRelease(c *gin.Context) {
|
||||
cfg, err := loadVersionConfig()
|
||||
@@ -93,3 +188,89 @@ func (h *PublicHandler) GetRelease(c *gin.Context) {
|
||||
"download_urls": cfg.DownloadURLs,
|
||||
})
|
||||
}
|
||||
|
||||
type publicProductImage struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type publicProductResp struct {
|
||||
ID uint64 `json:"id"`
|
||||
PublicID string `json:"public_id"`
|
||||
Name string `json:"name"`
|
||||
Series string `json:"series"`
|
||||
Spec string `json:"spec"`
|
||||
Brand string `json:"brand"`
|
||||
Unit string `json:"unit"`
|
||||
SalePrice float64 `json:"sale_price"`
|
||||
Images []publicProductImage `json:"images"`
|
||||
}
|
||||
|
||||
// ListShopProducts GET /api/v1/public/shops/:shop_code/products (no auth)
|
||||
func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
||||
shopCode := c.Param("shop_code")
|
||||
|
||||
var shop model.Shop
|
||||
if err := h.db.Where("code = ? AND deleted_at IS NULL", shopCode).First(&shop).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "shop not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 50 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := h.db.Model(&model.Product{}).
|
||||
Where("shop_id = ? AND public_id IS NOT NULL AND public_id != '' AND deleted_at IS NULL", shop.ID)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var products []model.Product
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Preload("Images").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Order("id DESC").
|
||||
Find(&products).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
listData := make([]publicProductResp, len(products))
|
||||
for i, p := range products {
|
||||
imgs := make([]publicProductImage, len(p.Images))
|
||||
for j, img := range p.Images {
|
||||
imgs[j] = publicProductImage{URL: img.URL}
|
||||
}
|
||||
listData[i] = publicProductResp{
|
||||
ID: p.ID,
|
||||
PublicID: p.PublicID,
|
||||
Name: p.Name,
|
||||
Series: p.Series,
|
||||
Spec: p.Spec,
|
||||
Brand: p.Brand,
|
||||
Unit: p.Unit,
|
||||
SalePrice: p.SalePrice,
|
||||
Images: imgs,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": listData,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
||||
Phone string `json:"phone"`
|
||||
ManagerName string `json:"manager_name"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
WechatID string `json:"wechat_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -58,6 +59,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
||||
"address": req.Address,
|
||||
"phone": req.Phone,
|
||||
"manager_name": req.ManagerName,
|
||||
"wechat_id": req.WechatID,
|
||||
}
|
||||
if req.LogoURL != "" {
|
||||
updates["logo_url"] = req.LogoURL
|
||||
|
||||
@@ -62,7 +62,9 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
func (h *StockInHandler) Get(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var order model.StockInOrder
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
if err := h.db.Preload("Items.Product").Preload("Items.Product.Origin").
|
||||
Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage").
|
||||
Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -10,16 +10,18 @@ import (
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
Role string `json:"role"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
Role string `json:"role"`
|
||||
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxShopID = "shop_id"
|
||||
CtxRole = "role"
|
||||
CtxUserID = "user_id"
|
||||
CtxShopID = "shop_id"
|
||||
CtxRole = "role"
|
||||
CtxLicenseExpiresAt = "lic_exp"
|
||||
)
|
||||
|
||||
func JWT() gin.HandlerFunc {
|
||||
@@ -43,6 +45,7 @@ func JWT() gin.HandlerFunc {
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxShopID, claims.ShopID)
|
||||
c.Set(CtxRole, claims.Role)
|
||||
c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
PhaseNormal = "normal"
|
||||
PhaseGrace = "grace" // expired 0–7 days: writable, show banner
|
||||
PhaseReadOnly = "readonly" // expired 7–15 days: read-only
|
||||
PhaseLocked = "locked" // expired 15+ days: no login
|
||||
)
|
||||
|
||||
var (
|
||||
graceWindow = 7 * 24 * time.Hour
|
||||
readOnlyWindow = 15 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// CalcLicensePhase computes the degradation phase based on expires_at.
|
||||
// nil expiresAt = perpetual license = normal.
|
||||
func CalcLicensePhase(expiresAt *time.Time) string {
|
||||
if expiresAt == nil {
|
||||
return PhaseNormal
|
||||
}
|
||||
elapsed := time.Since(*expiresAt)
|
||||
if elapsed <= 0 {
|
||||
return PhaseNormal
|
||||
}
|
||||
if elapsed <= graceWindow {
|
||||
return PhaseGrace
|
||||
}
|
||||
if elapsed <= readOnlyWindow {
|
||||
return PhaseReadOnly
|
||||
}
|
||||
return PhaseLocked
|
||||
}
|
||||
|
||||
// GetLicensePhase returns the current phase for the authenticated request.
|
||||
func GetLicensePhase(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxLicenseExpiresAt)
|
||||
ptr, _ := v.(*int64)
|
||||
if ptr == nil {
|
||||
return PhaseNormal
|
||||
}
|
||||
t := time.Unix(*ptr, 0)
|
||||
return CalcLicensePhase(&t)
|
||||
}
|
||||
|
||||
// LicenseGuard blocks write operations when the shop's license is expired (readonly/locked).
|
||||
// License routes (/license/*) must be mounted outside this middleware so users can
|
||||
// view status and activate a new key even when locked.
|
||||
func LicenseGuard() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
phase := GetLicensePhase(c)
|
||||
switch phase {
|
||||
case PhaseLocked:
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "授权已锁定,请续费或激活新授权码",
|
||||
"phase": PhaseLocked,
|
||||
})
|
||||
case PhaseReadOnly:
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "授权已过期,当前为只读模式",
|
||||
"phase": PhaseReadOnly,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
default:
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCalcLicensePhase(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
// nil = perpetual → normal
|
||||
assert.Equal(t, PhaseNormal, CalcLicensePhase(nil))
|
||||
|
||||
// future expiry → normal
|
||||
future := now.Add(10 * 24 * time.Hour)
|
||||
assert.Equal(t, PhaseNormal, CalcLicensePhase(&future))
|
||||
|
||||
// just expired (1h ago) → grace
|
||||
grace := now.Add(-1 * time.Hour)
|
||||
assert.Equal(t, PhaseGrace, CalcLicensePhase(&grace))
|
||||
|
||||
// expired 6 days ago → grace (boundary)
|
||||
grace6d := now.Add(-6 * 24 * time.Hour)
|
||||
assert.Equal(t, PhaseGrace, CalcLicensePhase(&grace6d))
|
||||
|
||||
// expired 8 days ago → readonly
|
||||
readonly := now.Add(-8 * 24 * time.Hour)
|
||||
assert.Equal(t, PhaseReadOnly, CalcLicensePhase(&readonly))
|
||||
|
||||
// expired 14 days ago → readonly (boundary)
|
||||
readonly14d := now.Add(-14 * 24 * time.Hour)
|
||||
assert.Equal(t, PhaseReadOnly, CalcLicensePhase(&readonly14d))
|
||||
|
||||
// expired 16 days ago → locked
|
||||
locked := now.Add(-16 * 24 * time.Hour)
|
||||
assert.Equal(t, PhaseLocked, CalcLicensePhase(&locked))
|
||||
}
|
||||
@@ -12,8 +12,9 @@ type ErrorReport struct {
|
||||
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"`
|
||||
Role string `gorm:"size:20" json:"role"`
|
||||
ProductInfo string `gorm:"size:200" json:"product_info"`
|
||||
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"`
|
||||
|
||||
@@ -4,12 +4,14 @@ import "time"
|
||||
|
||||
type License struct {
|
||||
Base
|
||||
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
||||
LicenseKey string `gorm:"size:255;uniqueIndex" json:"license_key"`
|
||||
DeviceID string `gorm:"size:255" json:"device_id"`
|
||||
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Features JSON `gorm:"type:json" json:"features,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activated_at"`
|
||||
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
||||
LicenseKey string `gorm:"size:2048;uniqueIndex" json:"license_key"`
|
||||
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
MaxDevices int `gorm:"default:3" json:"max_devices"`
|
||||
Features JSON `gorm:"type:json" json:"features,omitempty"`
|
||||
// Deprecated: device binding is now tracked in license_devices table.
|
||||
DeviceID string `gorm:"size:255" json:"device_id,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type LicenseDevice struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
LicenseID uint64 `gorm:"not null;index" json:"license_id"`
|
||||
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
||||
DeviceID string `gorm:"size:255;not null" json:"device_id"`
|
||||
DeviceName string `gorm:"size:255" json:"device_name"`
|
||||
Platform string `gorm:"size:50" json:"platform"` // windows|macos|android|ios|web
|
||||
ActivatedAt time.Time `gorm:"not null;autoCreateTime" json:"activated_at"`
|
||||
LastSeenAt time.Time `gorm:"not null;autoUpdateTime" json:"last_seen_at"`
|
||||
}
|
||||
|
||||
func (LicenseDevice) TableName() string { return "license_devices" }
|
||||
@@ -22,11 +22,21 @@ type Product struct {
|
||||
SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"`
|
||||
MinStock int `gorm:"default:0" json:"min_stock"`
|
||||
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:"-"`
|
||||
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"`
|
||||
// 商品属性字典外键(可空,公开页展示用)
|
||||
OriginID *uint64 `json:"origin_id"`
|
||||
ShelfLifeID *uint64 `json:"shelf_life_id"`
|
||||
StorageID *uint64 `json:"storage_id"`
|
||||
DescriptionDocID *uint64 `json:"description_doc_id"`
|
||||
|
||||
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
||||
Origin *ProductOriginOption `gorm:"foreignKey:OriginID" json:"origin,omitempty"`
|
||||
ShelfLife *ProductShelfLifeOption `gorm:"foreignKey:ShelfLifeID" json:"shelf_life,omitempty"`
|
||||
Storage *ProductStorageOption `gorm:"foreignKey:StorageID" json:"storage,omitempty"`
|
||||
DescriptionDoc *ProductDescriptionDoc `gorm:"foreignKey:DescriptionDocID" json:"description_doc,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
// ProductOriginOption 产地字典(门店级)
|
||||
type ProductOriginOption struct {
|
||||
TenantBase
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
|
||||
// ProductShelfLifeOption 保质期字典(门店级)
|
||||
type ProductShelfLifeOption struct {
|
||||
TenantBase
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Name string `gorm:"size:200;not null" json:"name"` // 如「无限期(适饮)」「24个月」
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
|
||||
// ProductStorageOption 储存方式字典(门店级)
|
||||
type ProductStorageOption struct {
|
||||
TenantBase
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Name string `gorm:"size:200;not null" json:"name"` // 如「阴凉干燥、避光保存」
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
|
||||
// ProductDescriptionDoc 商品描述文档字典(门店级)
|
||||
// Title = 酒类型(如「酱香型白酒」),Content = 商品介绍正文
|
||||
type ProductDescriptionDoc struct {
|
||||
TenantBase
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type Shop struct {
|
||||
BusinessHours string `gorm:"size:100" json:"business_hours"`
|
||||
ManagerName string `gorm:"size:50" json:"manager_name"`
|
||||
LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"`
|
||||
WechatID string `gorm:"size:100" json:"wechat_id"`
|
||||
BusinessLicense string `gorm:"size:500" json:"business_license"`
|
||||
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
|
||||
@@ -27,6 +27,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
importH := handler.NewImportHandler(db)
|
||||
userH := handler.NewUserHandler(db)
|
||||
productOptH := handler.NewProductOptionHandler(db)
|
||||
productAttrH := handler.NewProductAttrHandler(db)
|
||||
productImageH := handler.NewProductImageHandler(db)
|
||||
financeH := handler.NewFinanceHandler(db)
|
||||
numberRuleH := handler.NewNumberRuleHandler(db)
|
||||
@@ -57,23 +58,29 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
public := v1.Group("/public")
|
||||
{
|
||||
public.GET("/products/:public_id", publicH.GetProduct)
|
||||
public.GET("/shops/:shop_code/products", publicH.ListShopProducts)
|
||||
public.GET("/release", publicH.GetRelease)
|
||||
public.POST("/errors", errorReportH.Submit)
|
||||
public.POST("/register", authH.Register)
|
||||
}
|
||||
|
||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||
// 需要 JWT 的基础路由组
|
||||
api := v1.Group("")
|
||||
api.Use(middleware.JWT(), middleware.ReadOnly())
|
||||
api.Use(middleware.JWT())
|
||||
|
||||
// 许可证路由:豁免 LicenseGuard(锁定时仍需查看状态和激活)
|
||||
license := api.Group("/license")
|
||||
license.Use(middleware.ReadOnly())
|
||||
{
|
||||
// 许可证
|
||||
license := api.Group("/license")
|
||||
{
|
||||
license.GET("/info", licenseH.Info)
|
||||
license.POST("/activate", licenseH.Activate)
|
||||
license.GET("/verify", licenseH.Verify)
|
||||
license.POST("/deactivate", licenseH.Deactivate)
|
||||
}
|
||||
license.GET("/info", licenseH.Info)
|
||||
license.POST("/activate", licenseH.Activate)
|
||||
license.GET("/verify", licenseH.Verify)
|
||||
license.POST("/deactivate", licenseH.Deactivate)
|
||||
}
|
||||
|
||||
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
|
||||
{
|
||||
api.Use(middleware.ReadOnly(), middleware.LicenseGuard())
|
||||
|
||||
// 商品
|
||||
products := api.Group("/products")
|
||||
@@ -217,6 +224,27 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
opts.POST("/specs", productOptH.CreateSpec)
|
||||
opts.PUT("/specs/:id", productOptH.UpdateSpec)
|
||||
opts.DELETE("/specs/:id", productOptH.DeleteSpec)
|
||||
|
||||
// 商品属性字典(产地/保质期/储存方式/描述文档)
|
||||
opts.GET("/origins", productAttrH.ListOrigins)
|
||||
opts.POST("/origins", productAttrH.CreateOrigin)
|
||||
opts.PUT("/origins/:id", productAttrH.UpdateOrigin)
|
||||
opts.DELETE("/origins/:id", productAttrH.DeleteOrigin)
|
||||
|
||||
opts.GET("/shelf-lives", productAttrH.ListShelfLives)
|
||||
opts.POST("/shelf-lives", productAttrH.CreateShelfLife)
|
||||
opts.PUT("/shelf-lives/:id", productAttrH.UpdateShelfLife)
|
||||
opts.DELETE("/shelf-lives/:id", productAttrH.DeleteShelfLife)
|
||||
|
||||
opts.GET("/storages", productAttrH.ListStorages)
|
||||
opts.POST("/storages", productAttrH.CreateStorage)
|
||||
opts.PUT("/storages/:id", productAttrH.UpdateStorage)
|
||||
opts.DELETE("/storages/:id", productAttrH.DeleteStorage)
|
||||
|
||||
opts.GET("/description-docs", productAttrH.ListDescriptionDocs)
|
||||
opts.POST("/description-docs", productAttrH.CreateDescriptionDoc)
|
||||
opts.PUT("/description-docs/:id", productAttrH.UpdateDescriptionDoc)
|
||||
opts.DELETE("/description-docs/:id", productAttrH.DeleteDescriptionDoc)
|
||||
}
|
||||
|
||||
// 超级管理员专属
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUserInactive = errors.New("user is disabled")
|
||||
ErrLicenseLocked = errors.New("license locked, please renew or contact support")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
@@ -56,6 +57,10 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := s.checkLicenseNotLocked(shop.ID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -123,6 +128,8 @@ func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
createTrialLicense(tx, shop.ID)
|
||||
|
||||
result = RegisterResult{
|
||||
ShopCode: shop.Code,
|
||||
ShopName: shop.Name,
|
||||
@@ -151,15 +158,37 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
return s.issueTokens(claims.UserID, claims.ShopID, claims.Role)
|
||||
}
|
||||
|
||||
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
||||
var lic model.License
|
||||
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
||||
Order("id DESC").First(&lic).Error; err != nil {
|
||||
return nil // no license record → allow login
|
||||
}
|
||||
if middleware.CalcLicensePhase(lic.ExpiresAt) == middleware.PhaseLocked {
|
||||
return ErrLicenseLocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPair, error) {
|
||||
cfg := config.C.JWT
|
||||
now := time.Now()
|
||||
|
||||
// Embed license expires_at in JWT so LicenseGuard can check phase without DB.
|
||||
var licExpAt *int64
|
||||
var lic model.License
|
||||
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
||||
Order("id DESC").First(&lic).Error; err == nil && lic.ExpiresAt != nil {
|
||||
ts := lic.ExpiresAt.Unix()
|
||||
licExpAt = &ts
|
||||
}
|
||||
|
||||
accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute)
|
||||
accessClaims := middleware.Claims{
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
LicenseExpiresAt: licExpAt,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(accessExp),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
@@ -172,9 +201,10 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai
|
||||
|
||||
refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour)
|
||||
refreshClaims := middleware.Claims{
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
LicenseExpiresAt: licExpAt,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExp),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -98,3 +100,40 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
||||
Where("shop_id = ? AND device_id = ?", shopID, deviceID).
|
||||
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
// 若私钥未配置则静默跳过(开发/测试环境可不配置私钥)。
|
||||
func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
privKey := config.C.License.Ed25519PrivateKey
|
||||
if privKey == "" {
|
||||
log.Printf("[license] Ed25519 private key not configured, skipping trial for shop %d", shopID)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * 24 * time.Hour)
|
||||
expiresUnix := expiresAt.Unix()
|
||||
payload := util.LicensePayload{
|
||||
ShopID: shopID,
|
||||
Type: "trial",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &expiresUnix,
|
||||
MaxDevices: 1,
|
||||
}
|
||||
token, err := util.IssueLicenseToken(payload, privKey)
|
||||
if err != nil {
|
||||
log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err)
|
||||
return
|
||||
}
|
||||
|
||||
lic := model.License{
|
||||
ShopID: shopID,
|
||||
LicenseKey: token,
|
||||
Type: "trial",
|
||||
ExpiresAt: &expiresAt,
|
||||
IsActive: true,
|
||||
MaxDevices: 1,
|
||||
}
|
||||
if err := tx.Create(&lic).Error; err != nil {
|
||||
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidLicenseToken = errors.New("invalid license token")
|
||||
ErrInvalidLicenseSignature = errors.New("invalid license token signature")
|
||||
)
|
||||
|
||||
// LicensePayload is the verified content extracted from a signed license token.
|
||||
type LicensePayload struct {
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
LicenseID uint64 `json:"license_id,omitempty"`
|
||||
Type string `json:"type"` // trial | monthly | annual | lifetime
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
ExpiresAt *int64 `json:"expires_at,omitempty"` // unix seconds; nil = perpetual
|
||||
MaxDevices int `json:"max_devices"`
|
||||
Features map[string]any `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateEd25519KeyPair generates a new Ed25519 keypair.
|
||||
// Returns standard base64-encoded private key (64 bytes) and public key (32 bytes).
|
||||
// The private key must be stored securely (Bitwarden); the public key goes in config.
|
||||
func GenerateEd25519KeyPair() (privKeyB64, pubKeyB64 string, err error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(priv),
|
||||
base64.StdEncoding.EncodeToString(pub),
|
||||
nil
|
||||
}
|
||||
|
||||
// IssueLicenseToken signs a LicensePayload with the Ed25519 private key and returns
|
||||
// a compact token: base64url(header).base64url(payload).base64url(signature).
|
||||
// privKeyB64 is the standard base64-encoded 64-byte Ed25519 private key.
|
||||
func IssueLicenseToken(payload LicensePayload, privKeyB64 string) (string, error) {
|
||||
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
if len(privKeyBytes) != ed25519.PrivateKeySize {
|
||||
return "", fmt.Errorf("private key must be %d bytes, got %d", ed25519.PrivateKeySize, len(privKeyBytes))
|
||||
}
|
||||
privKey := ed25519.PrivateKey(privKeyBytes)
|
||||
|
||||
header := rawB64([]byte(`{"alg":"EdDSA","typ":"LIC"}`))
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := rawB64(payloadJSON)
|
||||
signingInput := header + "." + body
|
||||
sig := ed25519.Sign(privKey, []byte(signingInput))
|
||||
return signingInput + "." + rawB64(sig), nil
|
||||
}
|
||||
|
||||
// VerifyLicenseToken verifies the Ed25519 signature of a license token and returns
|
||||
// the decoded payload. Does NOT check expiry — callers must check ExpiresAt themselves.
|
||||
// pubKeyB64 is the standard base64-encoded 32-byte Ed25519 public key.
|
||||
func VerifyLicenseToken(token, pubKeyB64 string) (*LicensePayload, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidLicenseToken
|
||||
}
|
||||
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(pubKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode public key: %w", err)
|
||||
}
|
||||
if len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("public key must be %d bytes, got %d", ed25519.PublicKeySize, len(pubKeyBytes))
|
||||
}
|
||||
pubKey := ed25519.PublicKey(pubKeyBytes)
|
||||
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidLicenseToken
|
||||
}
|
||||
if !ed25519.Verify(pubKey, []byte(signingInput), sigBytes) {
|
||||
return nil, ErrInvalidLicenseSignature
|
||||
}
|
||||
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidLicenseToken
|
||||
}
|
||||
var p LicensePayload
|
||||
if err := json.Unmarshal(payloadJSON, &p); err != nil {
|
||||
return nil, ErrInvalidLicenseToken
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func rawB64(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLicenseKeyRoundTrip(t *testing.T) {
|
||||
priv, pub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
exp := time.Now().Add(30 * 24 * time.Hour).Unix()
|
||||
payload := LicensePayload{
|
||||
ShopID: 42,
|
||||
LicenseID: 7,
|
||||
Type: "annual",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MaxDevices: 3,
|
||||
}
|
||||
|
||||
token, err := IssueLicenseToken(payload, priv)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
got, err := VerifyLicenseToken(token, pub)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload.ShopID, got.ShopID)
|
||||
assert.Equal(t, payload.Type, got.Type)
|
||||
assert.Equal(t, payload.MaxDevices, got.MaxDevices)
|
||||
assert.Equal(t, *payload.ExpiresAt, *got.ExpiresAt)
|
||||
}
|
||||
|
||||
func TestVerifyLicenseToken_TamperedPayload(t *testing.T) {
|
||||
priv, pub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
exp := time.Now().Add(30 * 24 * time.Hour).Unix()
|
||||
token, err := IssueLicenseToken(LicensePayload{
|
||||
ShopID: 1, Type: "trial", IssuedAt: time.Now().Unix(), ExpiresAt: &exp, MaxDevices: 1,
|
||||
}, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Flip the last byte of the signature to simulate tampering
|
||||
tampered := token[:len(token)-2] + "XX"
|
||||
_, err = VerifyLicenseToken(tampered, pub)
|
||||
assert.Error(t, err, "tampered token must be rejected")
|
||||
}
|
||||
|
||||
func TestVerifyLicenseToken_WrongKey(t *testing.T) {
|
||||
priv, _, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
_, otherPub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
exp := time.Now().Add(30 * 24 * time.Hour).Unix()
|
||||
token, err := IssueLicenseToken(LicensePayload{
|
||||
ShopID: 1, Type: "trial", IssuedAt: time.Now().Unix(), ExpiresAt: &exp, MaxDevices: 1,
|
||||
}, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = VerifyLicenseToken(token, otherPub)
|
||||
assert.ErrorIs(t, err, ErrInvalidLicenseSignature)
|
||||
}
|
||||
|
||||
func TestVerifyLicenseToken_InvalidFormat(t *testing.T) {
|
||||
_, pub, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = VerifyLicenseToken("not-a-valid-token", pub)
|
||||
assert.ErrorIs(t, err, ErrInvalidLicenseToken)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.Shop{},
|
||||
&model.User{},
|
||||
&model.License{},
|
||||
&model.LicenseDevice{},
|
||||
&model.ProductCategory{},
|
||||
&model.Product{},
|
||||
&model.Warehouse{},
|
||||
@@ -107,6 +108,10 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.ProductNameOption{},
|
||||
&model.ProductSeriesOption{},
|
||||
&model.ProductSpecOption{},
|
||||
&model.ProductOriginOption{},
|
||||
&model.ProductShelfLifeOption{},
|
||||
&model.ProductStorageOption{},
|
||||
&model.ProductDescriptionDoc{},
|
||||
&model.ProductImage{},
|
||||
&model.ErrorReport{},
|
||||
&model.Feedback{},
|
||||
|
||||
@@ -56,22 +56,38 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
-- 许可证
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `licenses` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`license_key` VARCHAR(255) NOT NULL COMMENT '激活码',
|
||||
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '绑定设备ID',
|
||||
`type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`features` JSON DEFAULT NULL COMMENT '功能开关 {"finance":true}',
|
||||
`activated_at` DATETIME DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`license_key` VARCHAR(2048) NOT NULL COMMENT 'Ed25519 signed token',
|
||||
`device_id` VARCHAR(255) DEFAULT NULL COMMENT 'deprecated: use license_devices',
|
||||
`type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`max_devices` INT NOT NULL DEFAULT 3 COMMENT '最大绑定设备数',
|
||||
`features` JSON DEFAULT NULL COMMENT '功能开关 {"finance":true}',
|
||||
`activated_at` DATETIME DEFAULT NULL COMMENT 'deprecated',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_license_key` (`license_key`),
|
||||
UNIQUE KEY `uk_license_key` (`license_key`(255)),
|
||||
KEY `idx_shop_id` (`shop_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `license_devices` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`license_id` BIGINT UNSIGNED NOT NULL,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`device_id` VARCHAR(255) NOT NULL,
|
||||
`device_name` VARCHAR(255) DEFAULT NULL,
|
||||
`platform` VARCHAR(50) DEFAULT NULL COMMENT 'windows|macos|android|ios|web',
|
||||
`activated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_license_device` (`license_id`, `device_id`),
|
||||
KEY `idx_shop_id` (`shop_id`),
|
||||
KEY `idx_license_id` (`license_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证设备绑定';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 商品分类
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -58,6 +58,7 @@ func SetupTestDB() *gorm.DB {
|
||||
phone TEXT,
|
||||
manager_name TEXT,
|
||||
logo_url TEXT DEFAULT '',
|
||||
wechat_id TEXT DEFAULT '',
|
||||
business_license TEXT,
|
||||
business_hours TEXT,
|
||||
shop_photos TEXT,
|
||||
@@ -89,9 +90,21 @@ func SetupTestDB() *gorm.DB {
|
||||
type TEXT DEFAULT 'trial',
|
||||
expires_at DATETIME,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
max_devices INTEGER DEFAULT 3,
|
||||
features TEXT,
|
||||
activated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS license_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id INTEGER NOT NULL,
|
||||
shop_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT,
|
||||
platform TEXT,
|
||||
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(license_id, device_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS product_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
@@ -124,7 +137,51 @@ func SetupTestDB() *gorm.DB {
|
||||
custom_fields TEXT,
|
||||
remark TEXT,
|
||||
name_pinyin TEXT DEFAULT '',
|
||||
name_initials TEXT DEFAULT ''
|
||||
name_initials TEXT DEFAULT '',
|
||||
origin_id INTEGER,
|
||||
shelf_life_id INTEGER,
|
||||
storage_id INTEGER,
|
||||
description_doc_id INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS product_origin_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME,
|
||||
shop_id INTEGER NOT NULL,
|
||||
code TEXT,
|
||||
name TEXT NOT NULL,
|
||||
remark TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS product_shelf_life_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME,
|
||||
shop_id INTEGER NOT NULL,
|
||||
code TEXT,
|
||||
name TEXT NOT NULL,
|
||||
remark TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS product_storage_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME,
|
||||
shop_id INTEGER NOT NULL,
|
||||
code TEXT,
|
||||
name TEXT NOT NULL,
|
||||
remark TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS product_description_docs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME,
|
||||
shop_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
remark TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS warehouses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,11 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
- platform: ios
|
||||
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,15 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 读取 release 签名配置(key.properties 不入库,由本地或 CI 生成)。
|
||||
// 缺失时回退 debug 签名,保证本地 `flutter run --release` 与无密钥环境可用。
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val hasReleaseSigning = keystorePropertiesFile.exists()
|
||||
if (hasReleaseSigning) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.yanmei.jiu"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -20,21 +32,32 @@ android {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
signingConfig = if (hasReleaseSigning) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
// 无正式密钥时回退 debug,便于本地构建/调试
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- release 包走此主清单,必须显式声明联网权限;debug/profile 清单由 Flutter 模板自动添加,
|
||||
release 不带会导致所有网络请求失败(登录页显示「服务不可达」)。 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="jiu_client"
|
||||
android:label="岩美酒库"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"provider": "岩美技术有限公司",
|
||||
"website": "https://jiu.yanmei.com",
|
||||
"email": "yammy2023@163.com"
|
||||
"email": "yammy2023@163.com",
|
||||
"phone": "",
|
||||
"wechat": "",
|
||||
"terms_url": "https://jiu.yanmei.com/terms/",
|
||||
"privacy_url": "https://jiu.yanmei.com/privacy/",
|
||||
"docs_url": "https://jiu.yanmei.com/docs/"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- printing (1.0.0):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- printing (from `.symlinks/plugins/printing/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
printing:
|
||||
:path: ".symlinks/plugins/printing/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -0,0 +1,756 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3460B9FE6E275516D150453E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 086A871143ACEBCC07E71A24 /* Pods_Runner.framework */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
CBD53FA90DB3F4E143A654CC /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 852943EE4176C906DE56A6AB /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
086A871143ACEBCC07E71A24 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
08787BE3C2097BFA0BE7AA92 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
1EE111A264F501C30DD0A904 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
2F6692A92756378556D65AC0 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
5A9159D36C6D9B7DF22F0FBF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
852943EE4176C906DE56A6AB /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
9C64D02D6BED1C5F196D6192 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
F4A0B9102B939B3B38F72001 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0CE49F0D076B3E115DB6BF6D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CBD53FA90DB3F4E143A654CC /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
3460B9FE6E275516D150453E /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
D5753B5E6E2CB7979B358C42 /* Pods */,
|
||||
AA598329361F3C077ABDB4F2 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA598329361F3C077ABDB4F2 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
086A871143ACEBCC07E71A24 /* Pods_Runner.framework */,
|
||||
852943EE4176C906DE56A6AB /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D5753B5E6E2CB7979B358C42 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9C64D02D6BED1C5F196D6192 /* Pods-Runner.debug.xcconfig */,
|
||||
5A9159D36C6D9B7DF22F0FBF /* Pods-Runner.release.xcconfig */,
|
||||
1EE111A264F501C30DD0A904 /* Pods-Runner.profile.xcconfig */,
|
||||
08787BE3C2097BFA0BE7AA92 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
2F6692A92756378556D65AC0 /* Pods-RunnerTests.release.xcconfig */,
|
||||
F4A0B9102B939B3B38F72001 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
B21EFFE7BABA33CD7CB4E616 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
0CE49F0D076B3E115DB6BF6D /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
3052FA6A8375458CBCA9F94D /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
DB40772CF39C21BA0E695053 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3052FA6A8375458CBCA9F94D /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
B21EFFE7BABA33CD7CB4E616 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
DB40772CF39C21BA0E695053 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 08787BE3C2097BFA0BE7AA92 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 2F6692A92756378556D65AC0 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = F4A0B9102B939B3B38F72001 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,5 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>用于拍摄商品照片</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>用于从相册选择商品照片</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>岩美酒库</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>jiu_client</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -34,6 +34,11 @@ class AppConfig {
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static const aboutUrl = String.fromEnvironment(
|
||||
'ABOUT_URL',
|
||||
defaultValue: 'https://jiu.51yanmei.com',
|
||||
);
|
||||
|
||||
static String get baseUrl => _baseUrl;
|
||||
static String get apiBaseUrl => '$_baseUrl/api/v1';
|
||||
static String get healthUrl => '$_baseUrl/health';
|
||||
|
||||
@@ -12,6 +12,11 @@ class AppInfo {
|
||||
static String provider = '';
|
||||
static String website = '';
|
||||
static String email = '';
|
||||
static String phone = '';
|
||||
static String wechat = '';
|
||||
static String termsUrl = '';
|
||||
static String privacyUrl = '';
|
||||
static String docsUrl = '';
|
||||
|
||||
/// 加载配置文件。失败时保留为空(不影响应用启动)。
|
||||
static Future<void> load() async {
|
||||
@@ -21,6 +26,11 @@ class AppInfo {
|
||||
provider = (map['provider'] as String?) ?? '';
|
||||
website = (map['website'] as String?) ?? '';
|
||||
email = (map['email'] as String?) ?? '';
|
||||
phone = (map['phone'] as String?) ?? '';
|
||||
wechat = (map['wechat'] as String?) ?? '';
|
||||
termsUrl = (map['terms_url'] as String?) ?? '';
|
||||
privacyUrl = (map['privacy_url'] as String?) ?? '';
|
||||
docsUrl = (map['docs_url'] as String?) ?? '';
|
||||
} catch (e) {
|
||||
debugPrint('AppInfo.load failed: $e');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _kDeviceIdKey = 'device_id';
|
||||
|
||||
/// Returns a stable device identifier persisted across app launches.
|
||||
/// On first call a random UUID-v4 is generated and stored in SharedPreferences.
|
||||
/// Follows the rule: check kIsWeb before dart:io.
|
||||
class DeviceId {
|
||||
static String? _cached;
|
||||
|
||||
static Future<String> get() async {
|
||||
if (_cached != null) return _cached!;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var id = prefs.getString(_kDeviceIdKey);
|
||||
if (id == null || id.isEmpty) {
|
||||
id = _generateUuid();
|
||||
await prefs.setString(_kDeviceIdKey, id);
|
||||
}
|
||||
return _cached = id;
|
||||
}
|
||||
|
||||
/// Best-effort platform name for display purposes.
|
||||
static String get platformName {
|
||||
if (kIsWeb) return 'web';
|
||||
// defaultTargetPlatform is safe on all platforms (no dart:io needed)
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.windows:
|
||||
return 'windows';
|
||||
case TargetPlatform.macOS:
|
||||
return 'macos';
|
||||
case TargetPlatform.android:
|
||||
return 'android';
|
||||
case TargetPlatform.iOS:
|
||||
return 'ios';
|
||||
case TargetPlatform.linux:
|
||||
return 'linux';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _generateUuid() {
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// Keep the RAF open so the OS lock stays alive for the lifetime of the process.
|
||||
// ignore: unused_element
|
||||
RandomAccessFile? _raf;
|
||||
|
||||
/// Returns true if this process successfully acquired the single-instance lock.
|
||||
/// Returns false if another instance is already running.
|
||||
/// On Web / mobile / Linux the function always returns true (no restriction).
|
||||
Future<bool> acquire() async {
|
||||
if (kIsWeb) return true;
|
||||
if (!Platform.isWindows && !Platform.isMacOS) return true;
|
||||
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final lockFile = File('${dir.path}/jiu.lock');
|
||||
final raf = await lockFile.open(mode: FileMode.write);
|
||||
await raf.lock(FileLock.exclusive);
|
||||
_raf = raf;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 移动端断点:宽度 < 600 视为窄屏(手机)。
|
||||
const double kMobileBreakpoint = 600;
|
||||
|
||||
extension ResponsiveX on BuildContext {
|
||||
/// 是否为窄屏(手机)布局。宽屏(桌面 / Web / 平板)返回 false。
|
||||
bool get isMobile => MediaQuery.sizeOf(this).width < kMobileBreakpoint;
|
||||
|
||||
/// 固定宽度弹窗的安全宽度:不超过屏宽的 92%,避免窄屏溢出。
|
||||
double dialogWidth(double preferred) {
|
||||
final max = MediaQuery.sizeOf(this).width * 0.92;
|
||||
return preferred < max ? preferred : max;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import '../../screens/finance/finance_screen.dart';
|
||||
import '../../screens/products/products_screen.dart';
|
||||
import '../../screens/products/product_detail_screen.dart';
|
||||
import '../../screens/public/public_product_screen.dart';
|
||||
import '../../screens/public/public_shop_products_screen.dart';
|
||||
import '../../screens/settings/settings_screen.dart';
|
||||
import '../../screens/about/about_screen.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
@@ -40,7 +41,8 @@ class _RouterNotifier extends ChangeNotifier {
|
||||
final isLoggedIn = authState.isLoggedIn;
|
||||
final loc = state.matchedLocation;
|
||||
final isPublicRoute = loc == '/login' ||
|
||||
loc.startsWith('/product/');
|
||||
loc.startsWith('/product/') ||
|
||||
loc.startsWith('/shop/');
|
||||
final result = !authState.initialized
|
||||
? null
|
||||
: (!isLoggedIn && !isPublicRoute)
|
||||
@@ -79,6 +81,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state) =>
|
||||
PublicProductScreen(publicId: state.pathParameters['public_id']!),
|
||||
),
|
||||
// Public shop product list — no auth, no shell nav bar
|
||||
GoRoute(
|
||||
path: '/shop/:shop_code',
|
||||
builder: (context, state) => PublicShopProductsScreen(
|
||||
shopCode: state.pathParameters['shop_code']!,
|
||||
shopName: state.uri.queryParameters['shopName'] ?? '',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -6,6 +7,7 @@ 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/platform/single_instance.dart' as single_instance;
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'providers/connectivity_provider.dart';
|
||||
@@ -27,6 +29,12 @@ void main() {
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (!await single_instance.acquire()) {
|
||||
runApp(const _AlreadyRunningApp());
|
||||
return;
|
||||
}
|
||||
|
||||
await AppInfo.load(); // 从配置文件加载「关于我们」品牌信息
|
||||
runApp(const ProviderScope(child: JiuApp()));
|
||||
},
|
||||
@@ -43,6 +51,38 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
class _AlreadyRunningApp extends StatelessWidget {
|
||||
const _AlreadyRunningApp();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('岩美酒库已在运行'),
|
||||
content: const Text('请勿重复打开,程序已在运行中。\n请在任务栏或 Dock 中找到已打开的窗口。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => exit(0),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
return const Scaffold();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JiuApp extends ConsumerStatefulWidget {
|
||||
const JiuApp({super.key});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
class LicenseInfo {
|
||||
final int id;
|
||||
final String type; // trial | monthly | annual | lifetime
|
||||
final bool isActive;
|
||||
final int maxDevices;
|
||||
final DateTime? expiresAt;
|
||||
final String phase; // normal | grace | readonly | locked
|
||||
|
||||
const LicenseInfo({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.isActive,
|
||||
required this.maxDevices,
|
||||
required this.phase,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
factory LicenseInfo.fromJson(Map<String, dynamic> json) {
|
||||
return LicenseInfo(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
type: json['type'] as String? ?? 'trial',
|
||||
isActive: json['is_active'] as bool? ?? false,
|
||||
maxDevices: (json['max_devices'] as num?)?.toInt() ?? 3,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.tryParse(json['expires_at'] as String)
|
||||
: null,
|
||||
phase: json['phase'] as String? ?? 'normal',
|
||||
);
|
||||
}
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'monthly':
|
||||
return '月度授权';
|
||||
case 'annual':
|
||||
return '年度授权';
|
||||
case 'lifetime':
|
||||
return '永久授权';
|
||||
default:
|
||||
return '试用版';
|
||||
}
|
||||
}
|
||||
|
||||
bool get isExpired =>
|
||||
expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
|
||||
int? get daysRemaining {
|
||||
if (expiresAt == null) return null;
|
||||
final diff = expiresAt!.difference(DateTime.now()).inDays;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
|
||||
bool get isReadOnlyPhase => phase == 'readonly' || phase == 'locked';
|
||||
bool get isLockedPhase => phase == 'locked';
|
||||
bool get needsAttention => phase == 'grace' || phase == 'readonly' || phase == 'locked';
|
||||
}
|
||||
@@ -18,6 +18,14 @@ class Product {
|
||||
final String? remark;
|
||||
final Map<String, dynamic>? customFields;
|
||||
final List<ProductImage> images;
|
||||
// 产地/保质期/储存方式/分类名称(Detail 接口 Preload 后填充)
|
||||
final String? originName;
|
||||
final String? shelfLifeName;
|
||||
final String? storageName;
|
||||
final String? categoryName;
|
||||
// 描述文档(关联的字典记录)
|
||||
final String? descriptionDocTitle;
|
||||
final String? descriptionDocContent;
|
||||
|
||||
const Product({
|
||||
required this.id,
|
||||
@@ -37,6 +45,12 @@ class Product {
|
||||
this.remark,
|
||||
this.customFields,
|
||||
this.images = const [],
|
||||
this.originName,
|
||||
this.shelfLifeName,
|
||||
this.storageName,
|
||||
this.categoryName,
|
||||
this.descriptionDocTitle,
|
||||
this.descriptionDocContent,
|
||||
});
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) => Product(
|
||||
@@ -68,6 +82,12 @@ class Product {
|
||||
?.map((e) => ProductImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
originName: (json['origin'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
shelfLifeName: (json['shelf_life'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
storageName: (json['storage'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
categoryName: (json['category'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
descriptionDocTitle: (json['description_doc'] as Map<String, dynamic>?)?['title'] as String?,
|
||||
descriptionDocContent: (json['description_doc'] as Map<String, dynamic>?)?['content'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -66,3 +66,95 @@ class ProductSpecOption {
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 商品属性字典(产地/保质期/储存方式) ─────────────────────────
|
||||
|
||||
class ProductOriginOption {
|
||||
final int id;
|
||||
final String? code;
|
||||
final String name;
|
||||
final String? remark;
|
||||
|
||||
const ProductOriginOption({
|
||||
required this.id,
|
||||
this.code,
|
||||
required this.name,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
factory ProductOriginOption.fromJson(Map<String, dynamic> json) =>
|
||||
ProductOriginOption(
|
||||
id: (json['id'] as num).toInt(),
|
||||
code: json['code'] as String?,
|
||||
name: json['name'] as String,
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class ProductShelfLifeOption {
|
||||
final int id;
|
||||
final String? code;
|
||||
final String name;
|
||||
final String? remark;
|
||||
|
||||
const ProductShelfLifeOption({
|
||||
required this.id,
|
||||
this.code,
|
||||
required this.name,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
factory ProductShelfLifeOption.fromJson(Map<String, dynamic> json) =>
|
||||
ProductShelfLifeOption(
|
||||
id: (json['id'] as num).toInt(),
|
||||
code: json['code'] as String?,
|
||||
name: json['name'] as String,
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class ProductStorageOption {
|
||||
final int id;
|
||||
final String? code;
|
||||
final String name;
|
||||
final String? remark;
|
||||
|
||||
const ProductStorageOption({
|
||||
required this.id,
|
||||
this.code,
|
||||
required this.name,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
factory ProductStorageOption.fromJson(Map<String, dynamic> json) =>
|
||||
ProductStorageOption(
|
||||
id: (json['id'] as num).toInt(),
|
||||
code: json['code'] as String?,
|
||||
name: json['name'] as String,
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 描述文档 ─────────────────────────────────────────────────────
|
||||
|
||||
class ProductDescriptionDoc {
|
||||
final int id;
|
||||
final String title;
|
||||
final String? content;
|
||||
final String? remark;
|
||||
|
||||
const ProductDescriptionDoc({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.content,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
factory ProductDescriptionDoc.fromJson(Map<String, dynamic> json) =>
|
||||
ProductDescriptionDoc(
|
||||
id: (json['id'] as num).toInt(),
|
||||
title: json['title'] as String,
|
||||
content: json['content'] as String?,
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class ShopInfo {
|
||||
final String phone;
|
||||
final String managerName;
|
||||
final String logoUrl;
|
||||
final String wechatId;
|
||||
|
||||
const ShopInfo({
|
||||
required this.id,
|
||||
@@ -15,6 +16,7 @@ class ShopInfo {
|
||||
required this.phone,
|
||||
required this.managerName,
|
||||
this.logoUrl = '',
|
||||
this.wechatId = '',
|
||||
});
|
||||
|
||||
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
|
||||
@@ -25,5 +27,6 @@ class ShopInfo {
|
||||
phone: json['phone'] as String? ?? '',
|
||||
managerName: json['manager_name'] as String? ?? '',
|
||||
logoUrl: json['logo_url'] as String? ?? '',
|
||||
wechatId: json['wechat_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ class StockInItem {
|
||||
final String? productSeries;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
final String? productOrigin;
|
||||
final String? productShelfLife;
|
||||
final String? productStorage;
|
||||
|
||||
const StockInItem({
|
||||
this.orderId,
|
||||
@@ -26,6 +29,9 @@ class StockInItem {
|
||||
this.productSeries,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
this.productOrigin,
|
||||
this.productShelfLife,
|
||||
this.productStorage,
|
||||
});
|
||||
|
||||
factory StockInItem.fromJson(Map<String, dynamic> json) => StockInItem(
|
||||
@@ -43,6 +49,12 @@ class StockInItem {
|
||||
productSeries: (json['product'] as Map<String, dynamic>?)?['series'] as String?,
|
||||
productSpec: (json['product'] as Map<String, dynamic>?)?['spec'] as String?,
|
||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||
productOrigin: ((json['product'] as Map<String, dynamic>?)?['origin']
|
||||
as Map<String, dynamic>?)?['name'] as String?,
|
||||
productShelfLife: ((json['product'] as Map<String, dynamic>?)?['shelf_life']
|
||||
as Map<String, dynamic>?)?['name'] as String?,
|
||||
productStorage: ((json['product'] as Map<String, dynamic>?)?['storage']
|
||||
as Map<String, dynamic>?)?['name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -1,53 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../models/license.dart';
|
||||
import '../repositories/license_repository.dart';
|
||||
|
||||
class LicenseInfo {
|
||||
final String type; // trial / monthly / annual / lifetime
|
||||
final bool isActive;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? activatedAt;
|
||||
export '../models/license.dart';
|
||||
|
||||
const LicenseInfo({
|
||||
required this.type,
|
||||
required this.isActive,
|
||||
this.expiresAt,
|
||||
this.activatedAt,
|
||||
});
|
||||
|
||||
factory LicenseInfo.fromJson(Map<String, dynamic> json) {
|
||||
return LicenseInfo(
|
||||
type: json['type'] as String? ?? 'trial',
|
||||
isActive: json['is_active'] as bool? ?? false,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.tryParse(json['expires_at'] as String)
|
||||
: null,
|
||||
activatedAt: json['activated_at'] != null
|
||||
? DateTime.tryParse(json['activated_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'monthly': return '月度授权';
|
||||
case 'annual': return '年度授权';
|
||||
case 'lifetime': return '永久授权';
|
||||
default: return '试用版';
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已过期
|
||||
bool get isExpired =>
|
||||
expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
|
||||
/// 距到期剩余天数(null = 永久)
|
||||
int? get daysRemaining {
|
||||
if (expiresAt == null) return null;
|
||||
final diff = expiresAt!.difference(DateTime.now()).inDays;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
}
|
||||
final licenseRepositoryProvider = Provider<LicenseRepository>(
|
||||
(ref) => LicenseRepository(ref.read(apiClientProvider)),
|
||||
);
|
||||
|
||||
final licenseProvider =
|
||||
AsyncNotifierProvider<LicenseNotifier, LicenseInfo?>(LicenseNotifier.new);
|
||||
@@ -61,11 +22,7 @@ class LicenseNotifier extends AsyncNotifier<LicenseInfo?> {
|
||||
|
||||
Future<LicenseInfo?> _fetch() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final resp = await client.get('/license/info');
|
||||
final data = resp.data['data'];
|
||||
if (data == null) return null;
|
||||
return LicenseInfo.fromJson(data as Map<String, dynamic>);
|
||||
return await ref.read(licenseRepositoryProvider).getInfo();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -125,3 +125,159 @@ class ProductSpecListNotifier extends AsyncNotifier<List<ProductSpecOption>> {
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 产地 ──────────────────────────────────────────────────────
|
||||
|
||||
final productOriginListProvider =
|
||||
AsyncNotifierProvider<ProductOriginListNotifier, List<ProductOriginOption>>(
|
||||
ProductOriginListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductOriginListNotifier extends AsyncNotifier<List<ProductOriginOption>> {
|
||||
@override
|
||||
Future<List<ProductOriginOption>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listOrigins();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listOrigins().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createOrigin(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateOrigin(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteOrigin(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保质期 ────────────────────────────────────────────────────
|
||||
|
||||
final productShelfLifeListProvider =
|
||||
AsyncNotifierProvider<ProductShelfLifeListNotifier, List<ProductShelfLifeOption>>(
|
||||
ProductShelfLifeListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductShelfLifeListNotifier extends AsyncNotifier<List<ProductShelfLifeOption>> {
|
||||
@override
|
||||
Future<List<ProductShelfLifeOption>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listShelfLives();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listShelfLives().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createShelfLife(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateShelfLife(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteShelfLife(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 储存方式 ──────────────────────────────────────────────────
|
||||
|
||||
final productStorageListProvider =
|
||||
AsyncNotifierProvider<ProductStorageListNotifier, List<ProductStorageOption>>(
|
||||
ProductStorageListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductStorageListNotifier extends AsyncNotifier<List<ProductStorageOption>> {
|
||||
@override
|
||||
Future<List<ProductStorageOption>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listStorages();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listStorages().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createStorage(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateStorage(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteStorage(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 描述文档 ──────────────────────────────────────────────────
|
||||
|
||||
final productDescriptionDocListProvider =
|
||||
AsyncNotifierProvider<ProductDescriptionDocListNotifier, List<ProductDescriptionDoc>>(
|
||||
ProductDescriptionDocListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductDescriptionDocListNotifier extends AsyncNotifier<List<ProductDescriptionDoc>> {
|
||||
@override
|
||||
Future<List<ProductDescriptionDoc>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listDescriptionDocs();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listDescriptionDocs().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createDescriptionDoc(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateDescriptionDoc(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteDescriptionDoc(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/device/device_id.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../models/license.dart';
|
||||
|
||||
class LicenseRepository {
|
||||
final ApiClient _client;
|
||||
const LicenseRepository(this._client);
|
||||
|
||||
Future<LicenseInfo?> getInfo() async {
|
||||
try {
|
||||
final resp = await _client.get('/license/info');
|
||||
final data = resp.data['data'];
|
||||
if (data == null) return null;
|
||||
return LicenseInfo.fromJson(data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取授权信息失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate a license key for this device.
|
||||
Future<void> activate(String licenseKey, {String? deviceName}) async {
|
||||
final deviceId = await DeviceId.get();
|
||||
try {
|
||||
await _client.post('/license/activate', data: {
|
||||
'license_key': licenseKey,
|
||||
'device_id': deviceId,
|
||||
'device_name': deviceName ?? DeviceId.platformName,
|
||||
'platform': DeviceId.platformName,
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '激活失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate (unbind) this device from its license.
|
||||
Future<void> deactivate() async {
|
||||
final deviceId = await DeviceId.get();
|
||||
try {
|
||||
await _client.post('/license/deactivate', data: {'device_id': deviceId});
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '解绑失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,4 +127,164 @@ class ProductOptionRepository {
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 产地 ────────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductOriginOption>> listOrigins() async {
|
||||
try {
|
||||
final resp = await _client.get('/product-options/origins');
|
||||
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||
return data.map((e) => ProductOriginOption.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '获取产地列表失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createOrigin(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/origins', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateOrigin(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/origins/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteOrigin(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/origins/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保质期 ──────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductShelfLifeOption>> listShelfLives() async {
|
||||
try {
|
||||
final resp = await _client.get('/product-options/shelf-lives');
|
||||
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||
return data.map((e) => ProductShelfLifeOption.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '获取保质期列表失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createShelfLife(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/shelf-lives', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateShelfLife(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/shelf-lives/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteShelfLife(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/shelf-lives/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 储存方式 ─────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductStorageOption>> listStorages() async {
|
||||
try {
|
||||
final resp = await _client.get('/product-options/storages');
|
||||
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||
return data.map((e) => ProductStorageOption.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '获取储存方式列表失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createStorage(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/storages', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStorage(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/storages/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteStorage(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/storages/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 描述文档 ─────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductDescriptionDoc>> listDescriptionDocs() async {
|
||||
try {
|
||||
final resp = await _client.get('/product-options/description-docs');
|
||||
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||
return data.map((e) => ProductDescriptionDoc.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '获取描述文档列表失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createDescriptionDoc(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/description-docs', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateDescriptionDoc(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/description-docs/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteDescriptionDoc(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/description-docs/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||