Compare commits

...

51 Commits

Author SHA1 Message Date
wangjia 8a62b5e174 chore: release v1.0.30
Deploy / build-windows (push) Failing after 48s
Deploy / build-macos (push) Has been cancelled
Deploy / build-android (push) Has been cancelled
Deploy / build-ios (push) Has been cancelled
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:06:32 +08:00
wangjia 47b3c89460 feat(web): 新增更新日志、服务条款、隐私政策页面;关于页更新日志链接修正
- web/dist/changelog/ 版本历史页,收录 v1.0.0–v1.0.29 全部发版记录
- web/dist/terms/ 服务条款页(付费/授权/数据/免责等)
- web/dist/privacy/ 隐私政策页(数据收集/使用/共享/用户权利等)
- 全站页脚补充 更新日志、服务条款、隐私政策三个链接
- about_screen 「更新日志」按钮链接由 /downloads/ 改为 /changelog/
- 新增 backend/cmd/issue 工具,用于签发指定门店的授权码 token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:56:22 +08:00
wangjia bc85b2c8ad fix(backend): license/info 返回 phase 字段,前端横幅/弹窗依赖此字段
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:30:25 +08:00
wangjia 13fc9b7aaf test(backend): 授权系统端到端验证测试 (21H)
util/license_key_test.go:
- TestLicenseKeyRoundTrip: 签发→验签完整流程
- TestVerifyLicenseToken_TamperedPayload: 签名被篡改后被拒绝
- TestVerifyLicenseToken_WrongKey: 用错误公钥验签失败
- TestVerifyLicenseToken_InvalidFormat: 格式非法被拒绝

middleware/license_guard_test.go:
- TestCalcLicensePhase: 覆盖全部 phase 边界
  (perpetual→normal / future→normal / <7d→grace /
   7-15d→readonly / >15d→locked)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 07:10:15 +08:00
wangjia 97ebb18fd7 feat(client): 授权 Tab 激活 UI + 到期横幅/弹窗 (21F+21G)
21F — 激活与设备管理 UI:
- settings_screen: 重构授权 Tab,按 phase 展示色阶状态
  (normal=绿/grace=橙/readonly|locked=红)
- 新增「激活授权码」卡片:文本框 + 激活按钮,调用 LicenseRepository.activate
- 显示设备上限字段 (maxDevices)

21G — gating 横幅 + 到期弹窗:
- app_shell: 引入 licenseProvider,needsAttention 时展示顶部横幅
  (grace=深橙/readonly=深红/locked=红),横幅含「去激活」快捷入口
- 每 session 首次进入时弹出到期提醒 dialog(postFrameCallback 模式)
  内容随 phase 调整文案,提供「稍后处理」/「立即前往设置」两个按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 07:08:35 +08:00
wangjia ebf79d0355 feat: LicenseGuard 中间件 + Flutter license model/repo (21D+21E)
21D — 后端:
- Claims 新增 LicenseExpiresAt (*int64 unix 秒),写入 JWT 避免每次查库
- middleware/license_guard.go: CalcLicensePhase / LicenseGuard / GetLicensePhase
  - grace(0-7d): 允许通行
  - readonly(7-15d): 拦截非 GET 写操作 → 403
  - locked(15d+): 全部拦截 → 403
- auth.go: issueTokens 在 JWT 中嵌入 license expires_at;Login 检查 locked 拒绝登录
- router: license/* 路由豁免 LicenseGuard(锁定时仍可激活/查状态)

21E — Flutter 前端:
- models/license.dart: 新 LicenseInfo,含 phase/maxDevices,去掉旧 activatedAt
- core/device/device_id.dart: 持久化 UUID-v4 作为设备 ID(SharedPreferences)
- repositories/license_repository.dart: getInfo/activate/deactivate,激活时携带设备信息
- providers/license_provider.dart: 改接 LicenseRepository
- settings_screen.dart: activatedAt 改为显示 maxDevices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:52:07 +08:00
wangjia c402ea0070 feat(backend): license_devices 表 + 注册自动签发 trial (21B+21C)
21B — DB & model:
- licenses: 扩展 license_key 为 2048 字节(容纳 Ed25519 JWT),
  新增 max_devices INT DEFAULT 3,device_id/activated_at 标为 deprecated
- 新增 license_devices 表(license_id+device_id 唯一索引)
- model/license_device.go:LicenseDevice struct
- main.go AutoMigrate 加入 LicenseDevice
- testutil/setup.go 同步 SQLite DDL

21C — trial at register:
- config: 新增 Ed25519PrivateKey 配置项(LICENSE_ED25519_PRIVATE_KEY 环境变量)
- service/license.go: createTrialLicense(tx, shopID) — 签发 30d trial,
  私钥未配置时静默跳过(开发/测试不影响)
- service/auth.go: Register 事务末尾调用 createTrialLicense

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:42:56 +08:00
wangjia 40a80467ce feat(backend): Ed25519 license signing utilities (21A)
- util/license_key.go: LicensePayload struct, GenerateEd25519KeyPair,
  IssueLicenseToken (sign), VerifyLicenseToken (verify offline)
- cmd/genkey: one-shot keypair generator with demo sign+verify
- config: add Ed25519PublicKey field (LICENSE_ED25519_PUBLIC_KEY env)

Token format: base64url(header).base64url(payload).base64url(ed25519-sig)
Private key stored in Bitwarden; public key embedded in config/client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:28:55 +08:00
wangjia 18cd2497c1 feat(todo): 子任务系统 — 复杂任务支持子任务拆解与依赖追踪
- 新增 `sub add <parent_id>` / `sub status <sid>` CLI 命令
- SID 格式 {parentId}{Letter},支持依赖声明(deps)与校验
- HTML 新增子任务展开块、进度徽章、依赖色标(绿=完成/红=未完成)
- 父任务全部子任务完成时自动转为「待验收」;手动设 done 时检查子任务
- 更新 /todo 命令文档补充 sub 子命令说明
- #21 已录入 8 个实现子任务(21A–21H),含完整依赖链

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:16:10 +08:00
wangjia 8925646eef docs(architecture): 授权信息功能完整方案设计文档
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:53:35 +08:00
wangjia afa1a0bcd2 feat(client): 商品详情页展示所有字段,补全分类/库存预警/备注/自定义字段/描述文档
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:50:47 +08:00
wangjia 0ec609255c chore: release v1.0.29
Deploy / build-linux-web (push) Failing after 10m44s
Deploy / build-windows (push) Failing after 10m45s
Deploy / build-macos (push) Has been skipped
Deploy / build-android (push) Has been skipped
Deploy / build-ios (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:12:26 +08:00
wangjia 5ad1289419 chore: release v1.0.28
Deploy / build-windows (push) Successful in 1m54s
Deploy / build-linux-web (push) Failing after 13m1s
Deploy / build-macos (push) Has been skipped
Deploy / build-android (push) Has been skipped
Deploy / build-ios (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:35:01 +08:00
wangjia daa83bd408 chore: release v1.0.27
Deploy / build-windows (push) Successful in 1m56s
Deploy / build-android (push) Has been cancelled
Deploy / build-ios (push) Has been cancelled
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-macos (push) Failing after 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 00:15:46 +08:00
wangjia 65d6b12a21 fix(ci): macOS 打包改用 ditto 保留 symlink 和文件权限,修复解压失败
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:51:18 +08:00
wangjia b0a74f3607 chore: release v1.0.26
Deploy / build-linux-web (push) Successful in 55s
Deploy / build-windows (push) Successful in 2m2s
Deploy / build-macos (push) Successful in 1m17s
Deploy / build-android (push) Successful in 1m30s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:40:54 +08:00
wangjia 543bd2712f chore: update todo list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:40:17 +08:00
wangjia fab7bd21dd feat: #13 公开商品 API 补充 code/barcode/batch.quantity,报错反馈携带商品和门店信息
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:39:59 +08:00
wangjia 3b2ebc0202 fix(web): 注册页 API URL 改用 site.appBaseUrl,修正成功提示导航路径
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:33:16 +08:00
wangjia ff32c8a845 fix(client): 公开页页脚「关于岩美」链接改为 AppConfig.aboutUrl 配置项
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:27:38 +08:00
wangjia af64e29459 fix(client): 公开页页脚「关于岩美」链接修正为 jiu.51yanmei.com
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:19:33 +08:00
wangjia f788a7ab74 chore: release v1.0.25
Deploy / build-windows (push) Successful in 1m56s
Deploy / build-linux-web (push) Failing after 13m56s
Deploy / build-macos (push) Has been skipped
Deploy / build-android (push) Has been skipped
Deploy / build-ios (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:20:48 +08:00
wangjia 8a87d4a82c feat(client): #14/#18 产地/保质期/储存方式/描述文档 provider & repo 及公开页意见反馈
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:20:00 +08:00
wangjia 0d17533120 feat(client): 基础数据管理新增产地/保质期/储存方式/描述文档 4 个字典维护 Tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:06:21 +08:00
wangjia 4e64e5eb47 feat: 入库单/商品详情展示产地保质期储存方式,商品详情显示建议零售价
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:57:10 +08:00
wangjia 1ec1d4209a chore: release v1.0.24
Deploy / build-linux-web (push) Successful in 59s
Deploy / build-windows (push) Successful in 1m50s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m26s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:31:55 +08:00
wangjia 22f07ed805 feat(client): 入库表单关联商品属性字典(产地/保质期/储存方式/描述文档)
- product_option.dart: 新增 4 个字典 model(Origin/ShelfLife/Storage/DescriptionDoc)
- product_option_repository.dart: 新增 4 个 list 方法(listOrigins/ShelfLives/Storages/DescriptionDocs)
- product_option_provider.dart: 新增 4 个 FutureProvider 供表单使用
- product_repository.dart: findOrCreate 加 originId/shelfLifeId/storageId/descriptionDocId 可选参数
- stock_in_form_screen.dart: _ItemRow 加 4 个可选 ID 字段;新增 4 个 SearchableOptionField 方法;移动端卡片视图增加 4 个可选字段;提交时传入属性 ID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:03:07 +08:00
wangjia 45b6ec832b feat(client): 公开商品页改读真实数据,移除茅台 mock 硬编码
- _DescriptionSection: 接受 title/body/keywords 三参数,移除 _mockBody/_mockKeywords 和茅台专属文案
- _ParamsCard: 新增 origin/shelfLife/storage 参数;产地空时不显示,生产日期无时不显示,保质期/储存方式用后端返回值(含默认话术)
- 数据提取段:新增 origin/shelfLife/storage/descriptionTitle/descriptionKeywords 从 API 响应读取
- 删除未使用的 _extractKeywords 方法

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:58:28 +08:00
wangjia ba127826b2 feat(backend): 新增商品属性字典表(产地/保质期/储存方式/描述文档)
- model/product_attr.go: 新增 4 张字典表 struct(TenantBase 范式)
- model/product.go: Product 增加 4 个可空外键 + belongs-to 关联
- handler/product_attr.go: 4 组 16 个 CRUD handler(shop_id 隔离)
- handler/product.go: FindOrCreate/Update 支持写入 4 个属性 ID
- handler/public.go: Preload 4 表 + 三级回退介绍 + 默认话术常量
- router/router.go: /product-options/{origins,shelf-lives,storages,description-docs} 路由
- main.go: AutoMigrate 注册 4 个新 model
- testutil/setup.go: SQLite 测试库补齐新列与新表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:56:16 +08:00
wangjia 0e2a4086e1 chore(todo): 补录 product 公开页 6 条待办(价格展示、硬编码清理、死交互等) 2026-06-08 02:12:54 +08:00
wangjia 99f34a6223 chore(web): 删除 scan.html,清理 /scan/ nginx 路由和文档
scan.html 从未被实际触发——二维码 URL 格式为 /product/<uuid>,
由 Flutter Web PublicProductScreen 处理,scan.html 是孤立死页。

- 删除 web/scan.html
- 移除 nginx-jiu.conf 中 /scan/ location 块
- 更新文档路由表(/scan/<id> → /product/<uuid>)
- 标记 todo #4 完成

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 02:07:19 +08:00
wangjia ce92d619ed chore(todo): 标记 #1 两套 CSS 设计令牌问题已完成(v1.0.23) 2026-06-08 01:48:41 +08:00
wangjia 22a31f6eb8 fix(web): 合并两套 CSS 设计令牌,清理 features 冗余样式
- 删除 colors_and_type.css(与 color.css 完全相同,改 scan.html 引用 color.css)
- 删除 shared.css(无任何页面引用,已被 base.njk+style.css 替代)
- 移除 features-inventory.css / features-approval.css 头部 105 行重复代码
  (reset、.btn、.container、.section、.eyebrow、.topnav 均由 base.njk 提供,features 页已走模板)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:48:32 +08:00
wangjia 1c901432a6 chore(todo): 删除不再需要的旧版备份 todo.legacy.html 2026-06-08 01:40:17 +08:00
wangjia e124ed7c65 feat(todo): 新增 /todo slash command,支持 add/done/list/reopen/rm
- todo/todo.json:结构化 JSON 真相源(id/标题/重要度3级/平台标签/版本)
- todo/todo.mjs:Node 零依赖单文件 CLI,写操作自动重渲染 HTML
- todo/todo.html:重新设计布局(未完成置顶/已完成置底/sticky 筛选栏)
- .claude/commands/todo.md:/todo slash command
- CLAUDE.md:追加 TODO 管理规范(唯一真相源、完成即标记、禁止使用其它 todo 工具)

旧 todo.html 备份为 todo.legacy.html;存量 6 条待办已导入 todo.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:31:41 +08:00
wangjia 46304ab961 chore: release v1.0.23
Deploy / build-windows (push) Successful in 3m34s
Deploy / build-macos (push) Has been cancelled
Deploy / build-android (push) Has been cancelled
Deploy / build-ios (push) Has been cancelled
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Has been cancelled
2026-06-08 01:29:11 +08:00
wangjia d1260e8258 refactor(web): features 页改为 njk 模板,接入 base.njk 统一导航页脚
- inventory.html / approval.html → .njk,使用 layout: base.njk
- 内联 CSS 提取为独立文件 assets/features-{name}.css(pageExtraCss 加载)
- 移除各自硬编码的 nav/footer/script,改由 nav.njk、footer.njk、nav.js 统一提供
- permalink 保持 /features/*.html,现有链接无需修改
- .eleventy.js 移除 features/ passthrough

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:05:35 +08:00
wangjia 5b980ec58b fix(web): 同步 features 页导航与 nav.njk 一致(补「库存」「审核流」链接)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:47:52 +08:00
wangjia 2fc5776435 chore: release v1.0.22
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-windows (push) Successful in 1m54s
Deploy / build-macos (push) Successful in 1m19s
Deploy / build-android (push) Successful in 1m21s
Deploy / build-ios (push) Successful in 8s
Deploy / release-deploy (push) Successful in 1m42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:24:52 +08:00
wangjia bc252d1fcb chore: 更新 release 命令支持自动版本号,添加 web todo 清单
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:22:52 +08:00
wangjia c0001f08c2 fix(web): 官网 todo 清单全量修复(联系方式/死链/按钮/导航/下载)
- 联系方式:site.json email 改为真实地址,移除假电话,页脚条件渲染
- 死链:功能页 /download.html → /download/,页脚 href="#" 全部落地或移除
- 按钮指向:价格「购买」→ /register/,「联系销售/演示」→ mailto
- 导航:新增库存管理、审核流直链;模块卡加「了解更多」入口
- iOS 矛盾:首页平台卡标注「TestFlight 内测中」与下载页对齐
- 下载:API 域名抽到 site.json;接口失败时按钮降级为禁用态
- FAQ:试用版/单店版口径统一,明确「注册即试用 30 天」
- 文档:⌘K/Ctrl+K 绑定聚焦搜索框;反馈按钮改为本地 UI,不调不存在的接口
- dist:清理旧 docs.html 残留

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:13:19 +08:00
wangjia 46ef7db480 fix(web): 删除官网未实现功能描述,修正营销文案
- approval.html: 删除红冲/Block 5 整块、移除调拨单引用、删除「允许超量出库」bullet、中性化「录错了不慌」场景
- inventory.html: 删除「库存预警推送/SMS」块、「过期预警」bullet、移除「v1.6.2 新增」标注
- index.njk: Hero 版本号改为动态、信任指标替换为真实数据、移动端功能列表去虚假功能

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:39:39 +08:00
wangjia 0c96d5e325 feat(web): 官网移动端适配
- 导航栏:≤768 隐藏横向链接,显示汉堡按钮;点击展开下拉菜单,含全部链接和 CTA
- 新增 ≤600 手机断点:footer 单列、grid 单列、container 内边距收窄、标题字号下调
- 首页:≤768 隐藏 Hero mock 预览(装饰性),≤600 CTA 按钮纵向排列
- features 页(库存/审批):hamburger 导航、≤768 隐藏 composite 视觉、≤600 footer 单列
- 文档页(docs.css):≤600 表格横向可滚
- 下载页:≤600 平台卡片单列排列

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:22:00 +08:00
wangjia ced407ea87 feat(client): 移动端布局适配 + 信息架构整理
- 入库/出库详情页:窄屏商品明细切换为卡片竖排,顶部操作收入溢出菜单
- 基础数据:新增「仓库」tab,使用 DataTableCard + mobileCards 适配窄屏
- 系统设置:移除仓库 tab(已迁入基础数据),新增「授权」tab,数据导入改名「数据管理」
- 关于我们:移除授权信息,补充帮助与文档/扫码防伪/法律信息/系统信息等模块
- AppInfo 新增 phone/wechat/termsUrl/privacyUrl/docsUrl 字段(可选,空则不渲染)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:16:34 +08:00
wangjia e550b73d6d chore(commands): build/test/release 流程统一用 claude-sonnet-4-6 模型,新增 /build /test 命令
- release.md 补 frontmatter(model: claude-sonnet-4-6)
- 新增 /build:go build/vet + flutter analyze 编译静态检查
- 新增 /test:go test + flutter test
- 三者均通过 frontmatter 指定 sonnet 4.6

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:05:56 +08:00
wangjia fa02fdbfd8 chore: release v1.0.21
Deploy / build-linux-web (push) Successful in 54s
Deploy / build-windows (push) Successful in 1m57s
Deploy / build-macos (push) Successful in 1m16s
Deploy / build-android (push) Successful in 1m30s
Deploy / build-ios (push) Successful in 9s
Deploy / release-deploy (push) Successful in 1m43s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:53:04 +08:00
wangjia 211fafc7e1 feat(client): 商品详情页支持拍照添加照片(Android/iOS)
- 移动端加图改用 image_picker,底部弹出「拍照 / 从相册选择」二选一;
  桌面/Web 保持 file_picker 文件选择
- 用 defaultTargetPlatform 判断移动端,避免在参与 Web 编译的文件引入 dart:io
- iOS:Info.plist 增加 NSCameraUsageDescription / NSPhotoLibraryUsageDescription
- Android:拍照走系统相机、相册走 Photo Picker,均无需声明权限

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:47:08 +08:00
wangjia 5278bea940 chore: release v1.0.20
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-macos (push) Successful in 1m14s
Deploy / build-android (push) Successful in 1m12s
Deploy / build-ios (push) Successful in 8s
Deploy / build-windows (push) Failing after 12m32s
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:40:43 +08:00
wangjia 0bc3f4b702 fix(client): Android release 包缺少 INTERNET 权限导致登录页「服务不可达」
release 包走 main AndroidManifest,Flutter 模板默认只在 debug/profile 清单
注入 INTERNET 权限,主清单不带;强制 release 签名后联网权限缺失,所有网络
请求失败。在主清单显式声明 android.permission.INTERNET。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:39:23 +08:00
wangjia cfeb8f0bba chore: release v1.0.19
Deploy / build-linux-web (push) Successful in 54s
Deploy / build-windows (push) Successful in 1m47s
Deploy / build-macos (push) Successful in 1m18s
Deploy / build-android (push) Successful in 1m3s
Deploy / build-ios (push) Successful in 8s
Deploy / release-deploy (push) Successful in 1m35s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:50:34 +08:00
wangjia 480ce836bb chore: release v1.0.18
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-windows (push) Successful in 1m48s
Deploy / build-macos (push) Successful in 1m17s
Deploy / build-android (push) Successful in 4m13s
Deploy / build-ios (push) Successful in 9s
Deploy / release-deploy (push) Successful in 1m37s
移动端响应式适配(抽屉导航/列表卡片/弹窗自适应)、Android 正式签名与 APK 发布、
iOS(TestFlight) 工程与 CI、多平台构建流水线、相关文档同步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:55:33 +08:00
172 changed files with 20437 additions and 2211 deletions
+35
View File
@@ -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`)。
任一失败则停止,指出失败的命令和原因。
+19 -6
View File
@@ -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 通知。
+36
View File
@@ -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)。
+158
View File
@@ -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 分组)。
+65 -1
View File
@@ -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:
+113
View File
@@ -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
### 新功能
+42 -2
View File
@@ -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/CDForgejo)收到 tag 后自动:编译 → 测试 → 创建 Release → 部署 EC2 → Telegram 通知。
CI/CDForgejo`.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/CDForgejo)收到 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 <子命令> [参数]`
---
### 搜索实现(拼音)
商品名搜索同时支持汉字、全拼、首字母:
+59
View File
@@ -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))
}
+72
View File
@@ -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)")
}
}
+5 -1
View File
@@ -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")
+1 -1
View File
@@ -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"
+23 -21
View File
@@ -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 {
+9 -1
View File
@@ -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
+57 -27
View File
@@ -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
+268
View File
@@ -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"})
}
+191 -10
View File
@@ -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,
})
}
+2
View File
@@ -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
+3 -1
View File
@@ -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"})
+9 -6
View File
@@ -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 07 days: writable, show banner
PhaseReadOnly = "readonly" // expired 715 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))
}
+3 -2
View File
@@ -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"`
+10 -8
View File
@@ -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"`
}
+16
View File
@@ -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" }
+16 -6
View File
@@ -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"`
}
+34
View File
@@ -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"`
}
+1
View File
@@ -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"`
+38 -10
View File
@@ -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)
}
// 超级管理员专属
+36 -6
View File
@@ -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),
+39
View File
@@ -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)
}
}
+106
View File
@@ -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)
}
+75
View File
@@ -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)
}
+5
View File
@@ -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{},
+28 -12
View File
@@ -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 -1
View File
@@ -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,
+6 -6
View File
@@ -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
+29 -6
View File
@@ -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
+4
View File
@@ -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
+6 -1
View File
@@ -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/"
}
+34
View File
@@ -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
+24
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -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
+22
View File
@@ -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
+756
View File
@@ -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 &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<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>
+10
View File
@@ -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
}
+16
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

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>
+74
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -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.
}
}
+5
View File
@@ -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';
+10
View File
@@ -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');
}
+53
View File
@@ -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;
}
}
+11 -1
View File
@@ -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(),
+40
View File
@@ -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});
+56
View File
@@ -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';
}
+20
View File
@@ -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() => {
+92
View File
@@ -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?,
);
}
+3
View File
@@ -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
View File
@@ -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() => {
+7 -50
View File
@@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More