feat(server,web): add notification service (RAW/WeCom/Bark), SSRF protection and HMAC signature; integrate memo event dispatch via service

feat(web): menu MVP with local menus, order creation, orders list + filters + CSV export; menu definition import/export (#menu-def)

fix(server): nil Notification guard for tests; fix goroutine var capture in notification dispatch

build(web): add lightningcss-win32-x64-msvc for Windows dev; update package.json

docs: add and revise memos二次开发计划.md (v2 overview + phased plan)
This commit is contained in:
codex 2025-10-08 19:40:58 +08:00
parent f6e025d583
commit 8ee03a83ce
16 changed files with 1718 additions and 37 deletions

395
memos二次开发计划.md Normal file
View File

@ -0,0 +1,395 @@
# 备忘录memos扩展开发架构蓝图菜单与 Webhook 通知模块
【修订版 v2 概览与决策】
为避免重复造轮子并对齐仓库现状,本修订版确立以下方向:
- Webhook 采用“沿用现有实现 + 能力增强”的路线:
- 沿用现有用户 Webhook 存储于用户设置UserSetting.WEBHOOKS以及既有 API`/api/v1/{parent=users/*}/webhooks`。
- 沿用 Memo 事件触发派发Memo 创建/更新/删除与异步投递PostAsync
- 不再落地“新建表 `user_webhook_configs``/api/v1/webhooks` 路由”的方案;原文中相关章节标记为“废弃(不执行)”。
- 在服务层引入 NotificationService + Notifier 抽象:支持 RAW原始通用 Webhook、WeCom企业微信机器人、Bark 三类发送器RAW 继续复用现有 `plugin/webhook`WeCom/Bark 由适配器构造第三方要求的 payload 与容错响应。
- 增强安全与稳健性SSRF 防护(仅 http/https、禁止回环/内网、DNS 解析后二次 IP 校验)、可选 HMAC-SHA256 请求签名(`X-Memos-Signature`)、指数退避重试与并发限流、失败熔断、指标与日志可观测。
- 兼容策略:短期可不改 proto/store 时,将“类型”编码为 `url` 前缀(如 `wecom://...`、`bark://...`)或 `title` 约定;长期方案再演进为在 Webhook 结构中新增 `type` 字段(届时需要 proto 变更与数据迁移)。
- 前端在“设置”页增强 Webhook 管理:类型选择、签名开关、测试发送按钮;沿用现有用户 Webhook API不新增独立 `/api/v1/webhooks` 路由。
- 菜单模块采用“低侵入 MVP → 价值验证 → 正式域建模”的路线:
- MVP 阶段不新增库表与 API仅用前端拼装并创建“订单 Memo”通过约定标签/内容格式便于筛选与统计。
- 若验证有价值,再进入正式域建模:新增 proto/service/store完善权限与分享展示。
—— 本段为修订版 v2 的“优先级更高的指导”,对原文中与之冲突的设计(尤其是新表与新 REST 路径)予以废弃说明,以免与仓库现状产生偏差。——
【分阶段实施计划(按优先级)】
Phase 0对齐与清理0.5 天)
- 标注并冻结原文中“新建 `user_webhook_configs` 表与 `/api/v1/webhooks` 路由”的章节为“废弃(不执行)”。
- 在 README/开发计划中补充本文修订摘要与目标对齐说明。
Phase 1Webhook 后端增强24 天)
- 引入 NotificationService、Notifier 接口;实现 `RawNotifier`(复用 plugin/webhook、`WeComNotifier`、`BarkNotifier`。
- 安全与稳健性:
- SSRF 防护scheme 白名单http/https、IP 黑名单(回环/内网/元数据DNS 解析与二次校验。
- 可选 HMAC-SHA256 签名头 `X-Memos-Signature`(含时间戳,防重放)。
- 指数退避重试(含抖动)、单用户并发限流、失败熔断与降级。
- 统一日志与指标:成功/失败计数、耗时、目标主机,避免泄露完整 URL。
- 对第三方响应做“宽容处理”:对 WeCom/Bark 遵循各自返回规范,不再强制 `{code:0}`
Phase 2Webhook 前端增强12 天)
- 设置页新增 Webhook 管理强化:
- 类型选择RAW/WeCom/Bark、签名开关、测试发送按钮。
- 短期兼容:类型编码放入 `url` 前缀或 `title`;长期等 proto 变更后切换为独立字段。
- 沿用 `/api/v1/{parent=users/*}/webhooks` API无需新增路由。
Phase 3菜单 MVP12 天,可选先行)
- 仅前端新增“下单”UI通过拼装内容与标签创建 Memo示例`#order #menu:{id} item:{id} qty:{n}`)。
- 列表页/筛选器:基于标签/关键字的视图与导出。
Phase 4菜单正式域建模37 天,可选)
- 新增 `menu_service.proto`、后端 service/store三库迁移同步SQLite/MySQL/Postgres
- 设计字段:使用 enum 表达 visibility时间统一 `google.protobuf.Timestamp`;建立必要索引。
- 权限与分享:遵循 `users/{user}/menus/{menu}` 风格的资源名与鉴权。
【风险与成本】
- 与上游冲突风险:新增表/路由会与现状冲突——已通过“沿用现有 Webhook 模型”规避。
- 安全风险:直连第三方的 SSRF/签名/限流缺失将导致可用性与安全隐患——通过 Phase 1 加固。
- 菜单域投入产出不确定:通过 MVP 先行验证,降低沉没成本。
【验收标准(关键用例)】
- 用户在设置页创建三类 Webhook触发 Memo 事件后分别成功投递RAW/WeCom/Bark
- 可选签名开启后,第三方侧能验证 `X-Memos-Signature`
- 人工压测并发投递场景:无明显阻塞,失败具备重试与合理日志;主机/IP 黑名单生效。
- 菜单 MVP可在前端完成下单并生成可筛选的订单 Memo能导出基础统计。
【过时章节说明】
- 原文“Webhook 配置数据库模式(`user_webhook_configs`)”与“`/api/v1/webhooks` 路由”相关段落标记为废弃(不执行)。
- 原文保留作为对照,但以后端现状与本修订版为准实施。
## 第 1 节:现有 `memos` 架构分析
为了确保新增模块能够无缝集成并保持项目既有的高质量标准,首先必须对 `memos` 的核心架构、技术选型和设计哲学进行深入分析。此分析将为后续的开发工作奠定坚实的基础,确保扩展功能与原生功能在风格、性能和维护性上保持一致。
### 1.1 核心技术栈与设计哲学
`memos` 项目的核心定位是一个现代、开源、自托管的知识管理和笔记平台,其技术选型和设计哲学紧密围绕着性能、隐私和可扩展性展开 1。
- **技术栈构成**:项目采用前后端分离的架构。后端服务使用 Go 语言构建,旨在实现最佳的资源利用率和高并发性能;前端则采用 React 和 TypeScript 技术栈,提供了一个响应式且现代化的用户界面 3。这种组合在现代 Web 应用中非常普遍,兼顾了服务端的稳定高效与客户端的丰富交互体验。
- **核心设计哲学**
1. **隐私优先与数据所有权**`memos` 强调用户对数据的完全控制。所有数据都存储在用户自选的本地数据库中(支持 SQLite、PostgreSQL、MySQL并且其核心运行时不依赖任何第三方云服务 1。
2. **轻量级与高性能**:项目追求最小的系统资源占用和高效的性能表现,这体现在其 Go 后端和精简的部署要求上 3。
3. **API 优先设计**`memos` 采用 API-First 的设计原则,提供了一套完整的 RESTful API这为第三方集成和功能扩展铺平了道路 3。
- **开源许可**:项目基于 MIT 许可证开源,该许可证非常宽松,完全允许并鼓励社区在此基础上进行二次开发、修改和商业使用,为本次开发计划提供了法律保障 3。
对于本次开发任务,这些特性意味着:新增的 Webhook 通知模块虽然会引入外部依赖,但必须设计为可选的用户配置项,以维持核心应用的“零外部依赖”原则。同时,所有新功能的实现都应遵循 API 优先的原则,首先定义清晰的 API 契约。
### 1.2 后端架构 (Go)
`memos` 的后端代码库遵循了 Go 社区推崇的标准化项目布局,实现了清晰的关注点分离 6。通过分析其 Go 包文档,可以识别出几个关键的目录结构及其职责 5
- `/server`:此目录是应用的核心,包含了主要的业务逻辑、服务编排以及 HTTP 服务器的启动和配置代码。
- `/store`作为数据持久化层该目录抽象了所有与数据库的交互。它定义了数据访问对象DAO的接口并为不同的数据库如 SQLite, PostgreSQL提供了具体的实现。这种设计使得业务逻辑层无需关心底层数据库的具体类型。
- `/router/api/v1`:此目录负责定义所有 v1 版本的 RESTful API 路由和对应的处理器Handlers。它将传入的 HTTP 请求路由到 `/server` 中相应的服务逻辑进行处理。
- `/db`:包含数据库迁移脚本和特定数据库的连接逻辑,是 `/store` 层的底层支持。
这种分层结构为我们的二次开发提供了明确的指导。新的菜单和 Webhook 功能将在现有结构中进行扩展:
- 新的数据模型和数据库操作将在 `/store` 目录中定义。
- 核心业务逻辑,如菜单管理、下单处理、通知发送等,将在 `/server` 目录中以新服务的形式实现。
- 所有对外的功能都将通过在 `/router/api/v1` 目录中添加新的 API 端点来暴露。
### 1.3 前端架构 (React/TypeScript)
`memos` 的前端是一个使用 TypeScript 构建的单页应用SPA具有良好的组件化结构 3。近期的代码提交活动表明项目正在持续进行前端依赖升级和组件功能增强例如为代码块组件增加主题感知的高亮功能这反映了一个健康且现代化的前端工程实践 8。
值得注意的是,社区用户反馈中提到了对更直观的文本格式化 UI 和更好的内容组织方式(如看板视图)的需求 9。这提示我们在设计新模块的用户界面时应注重提供丰富、直观的交互体验。为了保持视觉风格的统一新组件的开发应尽可能复用项目现有的 UI 组件库和设计系统。从 `usememos/mui` 这个仓库的存在可以推断,项目可能使用了 Material-UI 或其变体作为基础 UI 框架 2。
### 1.4 架构和谐性与领域演进
本次计划开发的两个模块在架构层面代表了两种截然不同的挑战,对它们的正确认识是设计成功的关键。
- **菜单模块**:这是一个**领域扩展**。它深度集成于应用的核心数据模型,需要创建与现有 `users` 表强关联的新数据表,并且其核心功能(下单)会直接影响到另一核心领域(创建备忘录)。它的设计重点在于稳健的数据建模、与现有服务的无缝集成以及高效的数据查询。
- **Webhook 通知模块**:这是一个**横切关注点**。它本质上是一个工具性功能,应与核心业务逻辑保持松耦合。当某个事件(如“备忘录已创建”)发生时,它需要被触发,然后执行一个独立的任务(发送 HTTP 请求)。它不需要了解“备忘录”或“菜单”的内部复杂性,只需要知道事件的发生和必要的上下文数据。它的设计重点在于通用性、可扩展性和事件驱动的抽象。
这种区别引导我们采用不同的设计策略。此外,引入一个公开的“菜单”功能,意味着 `memos` 将从一个纯粹的个人知识管理工具,向一个支持多用户互动的平台演进。这个转变要求我们在设计数据模型和 API 时,必须比原始应用更加审慎地处理数据的可见性(公开 vs. 私有)和访问控制,这是确保系统安全和用户隐私的基础。
## 第 2 节:菜单模块详细开发计划
本节将提供一个完整且可执行的菜单模块开发方案,涵盖从概念设计、数据建模到前后端实现的全过程。
### 2.1 概念框架与用户故事
为了精确定义模块的功能边界和用户体验,我们采用用户故事的形式来描述需求:
- **作为用户,我想要创建一个新菜单,并为其设置标题和描述,以便我可以分享一系列菜品。**
- **作为菜单创建者,我想要向我的菜单中添加菜品,每个菜品都包含名称、价格、描述,并能上传一张图片。**
- **作为任何用户,我想要浏览系统中所有公开的菜单列表。**
- **作为任何用户,我想要查看单个菜单的详细信息,包括其所有的菜品和图片。**
- **作为浏览菜单的用户,我想要“点”一个菜品,这个操作应该在我的个人备忘录中自动创建一条新的待办事项,格式为:“点餐:来自【菜单标题】的【菜品名称】”。**
### 2.2 数据库模式扩展
`memos` 项目支持多种数据库,因此新的数据表结构设计必须使用通用的 SQL 数据类型和约束,并通过 `/store` 目录中的数据访问层进行实现,以保证兼容性 1。为了支持菜单模块需要在数据库中引入以下新表
**表 1菜单模块数据库模式**
|表名|字段名|数据类型|约束/索引|描述|
|---|---|---|---|---|
|`menus`|`id`|`INTEGER`|`PRIMARY KEY`, `AUTOINCREMENT`|菜单唯一标识符|
||`creator_id`|`INTEGER`|`NOT NULL`, `FOREIGN KEY (users.id)`, `INDEX`|创建者用户 ID|
||`title`|`TEXT`|`NOT NULL`|菜单标题|
||`description`|`TEXT`||菜单描述|
||`visibility`|`TEXT`|`NOT NULL`, `DEFAULT 'PUBLIC'`|可见性 (例如, 'PUBLIC', 'PRIVATE')|
||`created_ts`|`INTEGER`|`NOT NULL`|创建时间戳|
||`updated_ts`|`INTEGER`|`NOT NULL`|更新时间戳|
|`menu_items`|`id`|`INTEGER`|`PRIMARY KEY`, `AUTOINCREMENT`|菜品唯一标识符|
||`menu_id`|`INTEGER`|`NOT NULL`, `FOREIGN KEY (menus.id)`, `INDEX`|所属菜单 ID|
||`name`|`TEXT`|`NOT NULL`|菜品名称|
||`description`|`TEXT`||菜品描述|
||`price`|`REAL`||菜品价格|
||`image_url`|`TEXT`||菜品图片 URL|
||`created_ts`|`INTEGER`|`NOT NULL`|创建时间戳|
||`updated_ts`|`INTEGER`|`NOT NULL`|更新时间戳|
|`orders`|`id`|`INTEGER`|`PRIMARY KEY`, `AUTOINCREMENT`|订单记录唯一标识符|
||`user_id`|`INTEGER`|`NOT NULL`, `FOREIGN KEY (users.id)`, `INDEX`|下单用户 ID|
||`menu_item_id`|`INTEGER`|`NOT NULL`, `FOREIGN KEY (menu_items.id)`|所点菜品 ID|
||`created_ts`|`INTEGER`|`NOT NULL`|下单时间戳|
此模式设计通过外键关联了用户、菜单和菜品,并通过索引优化了查询性能。`orders` 表主要用于记录操作历史,可用于未来的数据分析。
### 2.3 后端开发 (Go)
#### 2.3.1 数据访问层 (`/store`)
需要在 `/store` 目录下新增与 `menus``menu_items` 表对应的 CRUD (Create, Read, Update, Delete) 操作函数。例如:
- `CreateMenu(ctx context.Context, create *Menu) (*Menu, error)`
- `FindMenus(ctx context.Context, find *MenuFind) (*Menu, error)` (支持按 `visibility` 等条件过滤)
- `GetMenuByID(ctx context.Context, id int) (*Menu, error)`
- `CreateMenuItem(ctx context.Context, create *MenuItem) (*MenuItem, error)`
- `FindMenuItems(ctx context.Context, find *MenuItemFind) (*MenuItem, error)`
- `CreateOrder(ctx context.Context, create *Order) (*Order, error)`
#### 2.3.2 服务层 (`/server`)
将在 `/server` 目录下创建一个新的服务文件,例如 `menu_service.go`,用于封装所有与菜单相关的业务逻辑。
- **图片处理**`memos` 已具备媒体集成能力 2。图片上传逻辑将复用这一能力。服务层将负责处理文件上传请求将图片保存到配置的存储位置本地文件系统或 S3 等对象存储),然后将访问 URL 或资源标识符存入 `menu_items.image_url` 字段。
- **下单逻辑**`CreateOrder` 服务函数是实现核心交互功能的关键。当接收到下单请求时,它将执行以下操作:
1. 在 `orders` 表中创建一条记录,以作审计。
2. 调用现有的 `MemoService`,为发起请求的用户创建一个新的备忘录。备忘录的内容将根据用户故事中的格式动态生成。这种跨服务的调用体现了模块间的协同工作。
#### 2.3.3 API 层 (`/router/api/v1`)
为了将后端功能暴露给前端,需要在 `/router/api/v1` 目录中定义一组新的 RESTful API 端点。这些端点构成了前后端通信的契约。
**表 2菜单模块 REST API 端点**
|方法 (Method)|路径 (Path)|描述|认证|
|---|---|---|---|
|`POST`|`/api/v1/menus`|创建一个新菜单|需要|
|`GET`|`/api/v1/menus`|获取所有公开菜单的列表|可选|
|`GET`|`/api/v1/menus/{id}`|获取单个菜单的详细信息及其菜品|可选|
|`PATCH`|`/api/v1/menus/{id}`|更新一个菜单的信息(仅限创建者)|需要|
|`DELETE`|`/api/v1/menus/{id}`|删除一个菜单(仅限创建者)|需要|
|`POST`|`/api/v1/menus/{id}/items`|向指定菜单添加一个新菜品(仅限创建者)|需要|
|`PATCH`|`/api/v1/items/{id}`|更新一个菜品的信息(仅限创建者)|需要|
|`DELETE`|`/api/v1/items/{id}`|删除一个菜品(仅限创建者)|需要|
|`POST`|`/api/v1/items/{id}/order`|为指定菜品下单(创建备忘录)|需要|
### 2.4 前端开发 (React/TypeScript)
#### 2.4.1 API 客户端
扩展现有的 API 客户端(通常是一个封装了 `fetch``axios` 的模块),添加调用上述新 API 端点的函数。
#### 2.4.2 新组件与视图
需要开发以下新的 React 组件和页面视图:
- `MenuListView.tsx`:一个新页面,用于以卡片或列表的形式展示所有公开菜单。
- `MenuDetailView.tsx`:一个新页面,用于展示单个菜单的详细信息及其包含的所有菜品。
- `MenuItemCard.tsx`:一个可复用的组件,用于展示单个菜品的信息,包括图片、名称、价格、描述以及一个“点餐”按钮。
- `MenuCreateForm.tsx`:一个表单组件,可能以模态框的形式出现,用于创建和编辑菜单及菜品,其中应包含一个文件上传控件用于上传菜品图片。
#### 2.4.3 路由与状态管理
- 在应用的前端路由器中添加新的路由规则,例如 `/menus` 指向 `MenuListView``/menus/:id` 指向 `MenuDetailView`
- 利用现有的全局状态管理方案(如 Zustand, Redux 等)来管理菜单列表、当前查看的菜单详情等状态,以实现高效的数据共享和响应式更新。
## 第 3 节Webhook 通知模块详细开发计划
本节将设计一个灵活、可扩展的通知系统,通过 Webhook 与企业微信和 Bark 等第三方服务集成。
### 3.1 架构设计:一个通用的通知服务
直接在核心业务逻辑中硬编码针对企业微信和 Bark 的通知代码,会造成系统的高度耦合和扩展困难。因此,我们将设计一个通用的、基于接口的通知服务。
**设计方案**
1. 在 Go 中定义一个 `Notifier` 接口,该接口只包含一个方法:`Send(ctx context.Context, payload interface{}) error`。
2. 创建一个 `NotificationService`它负责管理一个用户的所有已启用的通知器Notifier实例。
3. 当应用中发生需要通知的事件时(例如,“新备忘录已创建”),相关的服务会调用 `NotificationService`
4. `NotificationService` 会遍历该用户的通知器列表,并依次调用每个通知器的 `Send` 方法。
5. 为每个支持的通知平台企业微信、Bark创建一个实现了 `Notifier` 接口的具体结构体,如 `WeComNotifier``BarkNotifier`。这些结构体将封装各自平台特定的数据格式化逻辑和 HTTP 请求发送逻辑。
这种设计模式(策略模式)具有极佳的可扩展性。未来若要支持新的通知平台(如 Slack、Discord只需创建一个新的、实现了 `Notifier` 接口的结构体即可,无需修改任何现有业务逻辑。
### 3.2 后端开发 (Go)
#### 3.2.1 数据库模式
需要一张新表来存储用户配置的 Webhook 信息。
**表 3Webhook 配置数据库模式**
|表名|字段名|数据类型|约束/索引|描述|
|---|---|---|---|---|
|`user_webhook_configs`|`id`|`INTEGER`|`PRIMARY KEY`, `AUTOINCREMENT`|配置唯一标识符|
||`user_id`|`INTEGER`|`NOT NULL`, `FOREIGN KEY (users.id)`, `INDEX`|所属用户 ID|
||`name`|`TEXT`|`NOT NULL`|配置名称 (用户自定义)|
||`type`|`TEXT`|`NOT NULL`|Webhook 类型 ('WECOM', 'BARK')|
||`url`|`TEXT`|`NOT NULL`|Webhook URL (可能包含敏感信息)|
||`enabled`|`BOOLEAN`|`NOT NULL`, `DEFAULT TRUE`|是否启用|
||`created_ts`|`INTEGER`|`NOT NULL`|创建时间戳|
||`updated_ts`|`INTEGER`|`NOT NULL`|更新时间戳|
这张表允许用户为自己的账户配置多个不同类型的通知渠道,并能独立启用或禁用它们。
#### 3.2.2 API 与服务层
- **管理 API**:在 `/api/v1/webhooks` 路径下创建一套标准的 CRUD API 端点,供前端页面管理 `user_webhook_configs` 表中的数据。
- **通知器实现**
- **企业微信 (`WeComNotifier`)**:该通知器的 `Send` 方法将根据企业微信群机器人的要求构建 JSON 负载payload然后向用户配置的 URL 发送 HTTP POST 请求。用户需要按照企业微信的指引,在群聊中创建一个“群机器人”来获取这个 Webhook URL 10。虽然提供的资料中未包含确切的 JSON 格式 12但实现时应参考企业微信开发者官方文档支持发送文本或 Markdown 格式的消息。
- **Bark (`BarkNotifier`)**Bark 的通知机制更为简单,通常是通过构造一个特定的 URL 并发送 GET 或 POST 请求来实现的 13。例如URL 格式可能为 `https://api.day.app/{key}/{title}/{body}`。`BarkNotifier` 的 `Send` 方法将根据传入的 payload 构建此 URL 并发起请求。用户配置的 `url` 字段将存储 `https://api.day.app/{key}` 这部分。`bark-server` 项目本身也是用 Go 编写的,这为我们的实现提供了良好的参考和技术可行性验证 14。
- **事件触发**
- 作为初始实现,我们将修改现有的 `CreateMemo` 服务函数。在备忘录成功保存到数据库后,该函数将异步调用 `NotificationService`,为创建该备忘录的用户触发通知。
- 这种事件驱动的模式具有强大的潜力。在后续的开发中,菜单模块的 `CreateOrder` 服务函数也可以触发同一个 `NotificationService`。这样就可以实现一个强大的联动功能:当有顾客下单时,菜单的创建者可以立即通过 Bark 收到一条推送通知,从而将两个新模块有机地连接起来。
### 3.3 前端开发 (React/TypeScript)
#### 3.3.1 新组件与视图
- 在用户的“设置”页面中,创建一个新的标签页或区域,命名为“通知”或“集成”。
- `WebhookConfigList.tsx`:一个用于展示用户当前已配置的所有 Webhook 列表的组件,每行包含名称、类型、状态,以及编辑和删除按钮。
- `WebhookEditForm.tsx`:一个用于添加或编辑 Webhook 配置的表单组件。表单应包含以下字段一个用于自定义的名称输入框一个用于选择类型企业微信、Bark的下拉菜单以及一个用于粘贴 Webhook URL 的文本输入框。
## 第 4 节:分阶段实施路线图与质量保证
为了确保项目能够平稳、高效地推进,并交付高质量的功能,特制定以下分阶段的实施与测试计划。
### 4.1 实施分期
将整个开发过程分解为多个逻辑清晰、可独立测试的阶段,有助于管理复杂性并实现价值的增量交付。
- **第一阶段:后端基础建设**
- 任务:实现两个模块的数据库模式变更(表 1 和表 3。编写并提交数据库迁移脚本。在 `/store` 包中实现所有新表的底层 CRUD 函数。
- 目标:完成数据持久化层,为上层业务逻辑提供数据操作接口。
- **第二阶段:菜单模块 - 后端**
- 任务:构建菜单模块的服务层逻辑和 API 端点(如表 2 所定义)。实现图片上传与处理逻辑。
- 目标:完成菜单模块的所有后端功能,并通过 API 测试工具(如 Postman验证其正确性。
- **第三阶段:菜单模块 - 前端**
- 任务:开发用于创建、浏览、查看和点餐的 React 组件与视图。将前端组件与第二阶段开发的 API 对接。
- 目标:交付功能完整的菜单模块用户界面。
- **第四阶段Webhook 模块 - 后端**
- 任务:实现通用的 `NotificationService``Notifier` 接口。具体实现 `WeComNotifier``BarkNotifier`。开发用于管理 Webhook 配置的 API。将第一个事件触发点集成到 `CreateMemo` 服务中。
- 目标:完成 Webhook 通知模块的后端核心功能。
- **第五阶段Webhook 模块 - 前端**
- 任务:在用户设置页面中构建用于管理 Webhook 配置的用户界面。
- 目标:允许用户通过界面自主配置和管理他们的通知渠道。
- **第六阶段:集成与端到端测试**
- 任务:将“下单成功”事件连接到通知服务。对所有新开发的用户流程进行全面的端到端测试。
- 目标:确保两个模块协同工作正常,并修复所有在集成过程中发现的问题。
### 4.2 测试与验证策略
- **单元测试 (Go)**:所有在后端新增的服务函数、数据访问方法和工具函数都必须编写相应的单元测试,以确保其逻辑的正确性。
- **集成测试 (Go)**:对所有新增的 API 端点进行集成测试,验证请求处理、认证逻辑、数据校验和响应格式的正确性。
- **组件测试 (React)**:使用 Jest 和 React Testing Library 等框架,对核心的前端组件(如表单、卡片)进行隔离测试。
- **端到端 (E2E) 测试**:使用 Cypress 或 Playwright 等自动化测试框架,为以下关键用户流程创建 E2E 测试用例:
1. 用户 A 成功创建一个包含菜品的公开菜单。
2. 用户 B 浏览菜单列表,进入用户 A 创建的菜单详情页,并成功下单。
3. 验证用户 B 的备忘录列表中出现了一条新的待办事项。
4. 用户 C 配置一个 Bark Webhook然后创建一条新的备忘录验证其手机收到了 Bark 推送通知。
### 4.3 部署与配置
- **配置管理**:新的功能可能需要引入新的环境变量,特别是当菜品图片使用云存储(如 S3时。需要在文档中明确说明这些新的配置项及其作用。
- **数据库迁移**:必须提供一个可靠的、非破坏性的数据库迁移脚本。该脚本负责在现有的 `memos` 实例上安全地应用新的数据表结构(表 1 和表 3确保用户升级过程平滑数据无损。

View File

@ -2,10 +2,18 @@ package webhook
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"encoding/hex"
"fmt"
"io"
"log/slog"
"os"
"net/http"
"net"
"net/url"
"strings"
"time"
"github.com/pkg/errors"
@ -31,6 +39,10 @@ type WebhookRequestPayload struct {
// Post posts the message to webhook endpoint.
func Post(requestPayload *WebhookRequestPayload) error {
// 基础 SSRF 防护:仅允许 http/https 且禁止回环/内网等目标。
if err := validateOutboundURL(requestPayload.URL); err != nil {
return errors.Wrapf(err, "invalid webhook target: %s", requestPayload.URL)
}
body, err := json.Marshal(requestPayload)
if err != nil {
return errors.Wrapf(err, "failed to marshal webhook request to %s", requestPayload.URL)
@ -42,6 +54,16 @@ func Post(requestPayload *WebhookRequestPayload) error {
}
req.Header.Set("Content-Type", "application/json")
// 可选 HMAC 签名:设置 MEMOS_OUTBOUND_WEBHOOK_HMAC_SECRET 即可启用。
if secret := strings.TrimSpace(os.Getenv("MEMOS_OUTBOUND_WEBHOOK_HMAC_SECRET")); secret != "" {
ts := time.Now().Unix()
msg := append([]byte(fmt.Sprintf("%d.", ts)), body...)
h := hmac.New(sha256.New, []byte(secret))
h.Write(msg)
sig := hex.EncodeToString(h.Sum(nil))
req.Header.Set("X-Memos-Signature", fmt.Sprintf("t=%d,v1=%s", ts, sig))
req.Header.Set("X-Memos-Source", "memos")
}
client := &http.Client{
Timeout: timeout,
}
@ -88,3 +110,63 @@ func PostAsync(requestPayload *WebhookRequestPayload) {
}
}()
}
// validateOutboundURL 基础 SSRF 防护(与 server/notification 略重复,保持插件自包含)。
func validateOutboundURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return errors.Errorf("unsupported scheme: %s", scheme)
}
host := u.Hostname()
if host == "" {
return errors.Errorf("empty host")
}
ips, err := net.LookupIP(host)
if err != nil {
return errors.Wrap(err, "dns lookup failed")
}
for _, ip := range ips {
if isDisallowedIP(ip) {
return errors.Errorf("disallowed target ip: %s", ip.String())
}
}
return nil
}
func isDisallowedIP(ip net.IP) bool {
if ip.IsLoopback() {
return true
}
privateCIDRs := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"127.0.0.0/8",
"169.254.169.254/32",
}
for _, cidr := range privateCIDRs {
_, block, _ := net.ParseCIDR(cidr)
if block.Contains(ip) {
return true
}
}
if ip.To4() == nil {
v6Blocks := []string{
"::1/128",
"fc00::/7",
"fe80::/10",
}
for _, c := range v6Blocks {
_, block, _ := net.ParseCIDR(c)
if block.Contains(ip) {
return true
}
}
}
return false
}

View File

@ -0,0 +1,50 @@
package notification
// 中文注释Bark 推送适配。
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"strings"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
func sendBark(ctx context.Context, base string, memo *v1pb.Memo, activity string) error {
// 允许用户直接粘贴 https://api.day.app/{key} 或自建 bark-server 根地址。
if err := validateOutboundURL(base); err != nil {
return err
}
u, err := url.Parse(base)
if err != nil {
return err
}
title := activityTitle(activity)
body := memo.GetSnippet()
if body == "" {
body = memo.GetContent()
if len([]rune(body)) > 64 {
body = string([]rune(body)[:64]) + "..."
}
}
// 拼接 /{title}/{body}
u.Path = path.Join(u.Path, url.PathEscape(strings.TrimSpace(title)), url.PathEscape(strings.TrimSpace(body)))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return err
}
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Bark 返回 200 视作成功,不强制解析 body。
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("bark status: %d", resp.StatusCode)
}
return nil
}

View File

@ -0,0 +1,12 @@
package notification
// 中文注释:类型与公共辅助。
type webhookType string
const (
webhookTypeRAW webhookType = "RAW"
webhookTypeWeCom webhookType = "WECOM"
webhookTypeBark webhookType = "BARK"
)

View File

@ -0,0 +1,71 @@
package notification
// 中文注释:企业微信机器人适配。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
var httpTimeout = 30 * time.Second
type weComTextPayload struct {
MsgType string `json:"msgtype"`
Text weComContent `json:"text"`
}
type weComContent struct {
Content string `json:"content"`
}
type weComResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func sendWeCom(ctx context.Context, url string, memo *v1pb.Memo, activity string) error {
if err := validateOutboundURL(url); err != nil {
return err
}
title := activityTitle(activity)
text := fmt.Sprintf("%s\nCreator: %s\nSnippet: %s", title, memo.GetCreator(), memo.GetSnippet())
if memo.GetSnippet() == "" {
// 兜底:直接截断 content
c := memo.GetContent()
if len([]rune(c)) > 64 {
c = string([]rune(c)[:64]) + "..."
}
text = fmt.Sprintf("%s\nCreator: %s\nSnippet: %s", title, memo.GetCreator(), c)
}
payload := weComTextPayload{
MsgType: "text",
Text: weComContent{Content: text},
}
b, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var r weComResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return err
}
if r.ErrCode != 0 {
return fmt.Errorf("wecom error: %d %s", r.ErrCode, r.ErrMsg)
}
return nil
}

View File

@ -0,0 +1,206 @@
package notification
// Notification service: central dispatch for memo-related webhooks (RAW/WeCom/Bark).
import (
"context"
"fmt"
"log/slog"
"math/rand"
"net/url"
"strings"
"sync"
"time"
"github.com/usememos/memos/plugin/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
type Service struct {
store *store.Store
}
func NewService(store *store.Store) *Service {
return &Service{store: store}
}
// DispatchMemoWebhooks sends notifications based on user webhooks.
func (s *Service) DispatchMemoWebhooks(ctx context.Context, memo *v1pb.Memo, activityType string) error {
creatorID, err := ExtractUserIDFromName(memo.GetCreator())
if err != nil {
return fmt.Errorf("invalid memo creator: %w", err)
}
hooks, err := s.store.GetUserWebhooks(ctx, creatorID)
if err != nil {
return err
}
for _, h := range hooks {
typ, target := classifyWebhook(h)
hostKey := hostKeyFor(target)
release := acquire(hostKey)
go func(typ webhookType, target string, hostKey string, release func()) {
defer release()
start := time.Now()
var err error
switch typ {
case webhookTypeWeCom:
err = sendWithRetry(ctx, hostKey, func() error { return sendWeCom(ctx, target, memo, activityType) })
case webhookTypeBark:
err = sendWithRetry(ctx, hostKey, func() error { return sendBark(ctx, target, memo, activityType) })
default:
payload, perr := convertMemoToWebhookPayload(memo)
if perr != nil {
slog.Warn("convert payload failed", slog.Any("err", perr))
return
}
payload.ActivityType = activityType
payload.URL = target
err = sendWithRetry(ctx, hostKey, func() error { return webhook.Post(payload) })
}
duration := time.Since(start)
if err != nil {
slog.Warn("Webhook dispatch failed", slog.String("type", string(typ)), slog.String("url", target), slog.Duration("latency", duration), slog.Any("err", err))
} else {
slog.Info("Webhook dispatched", slog.String("type", string(typ)), slog.String("url", target), slog.Duration("latency", duration))
}
}(typ, target, hostKey, release)
}
return nil
}
func classifyWebhook(h *storepb.WebhooksUserSetting_Webhook) (webhookType, string) {
raw := strings.TrimSpace(h.GetUrl())
if raw == "" {
return webhookTypeRAW, raw
}
if strings.HasPrefix(raw, "wecom://") {
return webhookTypeWeCom, strings.TrimPrefix(raw, "wecom://")
}
if strings.HasPrefix(raw, "bark://") {
return webhookTypeBark, strings.TrimPrefix(raw, "bark://")
}
if u, err := url.Parse(raw); err == nil {
host := strings.ToLower(u.Host)
if strings.Contains(host, "qyapi.weixin.qq.com") {
return webhookTypeWeCom, raw
}
if strings.Contains(host, "api.day.app") {
return webhookTypeBark, raw
}
}
return webhookTypeRAW, raw
}
// ExtractUserIDFromName parses "users/{id}" and returns id.
func ExtractUserIDFromName(name string) (int32, error) {
parts := strings.Split(name, "/")
if len(parts) != 2 || parts[0] != "users" {
return 0, fmt.Errorf("invalid user resource name: %s", name)
}
var id int32
var v int
_, err := fmt.Sscanf(parts[1], "%d", &v)
if err != nil {
return 0, fmt.Errorf("invalid user id: %s", parts[1])
}
id = int32(v)
return id, nil
}
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
creatorID, err := ExtractUserIDFromName(memo.GetCreator())
if err != nil {
return nil, fmt.Errorf("invalid memo creator: %w", err)
}
return &webhook.WebhookRequestPayload{
Creator: fmt.Sprintf("users/%d", creatorID),
Memo: memo,
}, nil
}
// --- limiter, retry, circuit breaker ---
var (
limiterMap sync.Map // key -> chan struct{}
cbMap sync.Map // key -> *cbState
maxConcurrentPerHost = 2
)
type cbState struct {
FailCount int
OpenUntil time.Time
mu sync.Mutex
}
func hostKeyFor(target string) string {
if u, err := url.Parse(target); err == nil {
return strings.ToLower(u.Host)
}
return target
}
func acquire(key string) func() {
chAny, _ := limiterMap.LoadOrStore(key, make(chan struct{}, maxConcurrentPerHost))
ch := chAny.(chan struct{})
ch <- struct{}{}
return func() { <-ch }
}
func sendWithRetry(ctx context.Context, key string, fn func() error) error {
if isOpen(key) {
return fmt.Errorf("circuit open for %s", key)
}
var err error
backoffs := []time.Duration{500 * time.Millisecond, 1 * time.Second, 2 * time.Second}
for i := 0; i < len(backoffs)+1; i++ {
err = fn()
if err == nil {
recordSuccess(key)
return nil
}
recordFailure(key)
if i == len(backoffs) {
break
}
d := backoffs[i]
jitter := time.Duration(rand.Int63n(int64(d / 2)))
select {
case <-time.After(d + jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return err
}
func isOpen(key string) bool {
v, _ := cbMap.LoadOrStore(key, &cbState{})
s := v.(*cbState)
s.mu.Lock()
defer s.mu.Unlock()
return time.Now().Before(s.OpenUntil)
}
func recordFailure(key string) {
v, _ := cbMap.LoadOrStore(key, &cbState{})
s := v.(*cbState)
s.mu.Lock()
defer s.mu.Unlock()
s.FailCount++
if s.FailCount >= 3 {
s.OpenUntil = time.Now().Add(1 * time.Minute)
s.FailCount = 0
}
}
func recordSuccess(key string) {
v, _ := cbMap.LoadOrStore(key, &cbState{})
s := v.(*cbState)
s.mu.Lock()
defer s.mu.Unlock()
s.FailCount = 0
s.OpenUntil = time.Time{}
}

View File

@ -0,0 +1,91 @@
package notification
// 中文注释:安全与工具函数(基础 SSRF 防护、活动标题辅助)。
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
)
// validateOutboundURL 基础 SSRF 防护:
// - 仅允许 http/https
// - 禁止回环/内网/链路本地/元数据网段
func validateOutboundURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("unsupported scheme: %s", scheme)
}
host := u.Hostname()
if host == "" {
return errors.New("empty host")
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("dns lookup failed: %w", err)
}
for _, ip := range ips {
if isDisallowedIP(ip) {
return fmt.Errorf("disallowed target ip: %s", ip.String())
}
}
return nil
}
func isDisallowedIP(ip net.IP) bool {
// 回环
if ip.IsLoopback() {
return true
}
// 私网/链路本地/多播等
privateCIDRs := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16", // 链路本地
"127.0.0.0/8",
// 常见云元数据
"169.254.169.254/32",
}
for _, cidr := range privateCIDRs {
_, block, _ := net.ParseCIDR(cidr)
if block.Contains(ip) {
return true
}
}
// IPv6 本地/链路本地
if ip.To4() == nil {
v6Blocks := []string{
"::1/128", // loopback
"fc00::/7", // unique local
"fe80::/10", // link local
}
for _, c := range v6Blocks {
_, block, _ := net.ParseCIDR(c)
if block.Contains(ip) {
return true
}
}
}
return false
}
func activityTitle(activity string) string {
switch strings.ToLower(activity) {
case "memos.memo.created":
return "Memo Created"
case "memos.memo.updated":
return "Memo Updated"
case "memos.memo.deleted":
return "Memo Deleted"
default:
return activity
}
}

View File

@ -17,7 +17,6 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/usememos/memos/plugin/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/runner/memopayload"
@ -804,38 +803,18 @@ func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *v1p
}
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error {
creatorID, err := ExtractUserIDFromName(memo.Creator)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID)
if err != nil {
return err
}
for _, hook := range webhooks {
payload, err := convertMemoToWebhookPayload(memo)
if err != nil {
return errors.Wrap(err, "failed to convert memo to webhook payload")
}
payload.ActivityType = activityType
payload.URL = hook.Url
// Use asynchronous webhook dispatch
webhook.PostAsync(payload)
}
return nil
// 改造:通过集中式通知服务分发(支持 RAW/WeCom/Bark内置基础防护
// 在测试环境或未初始化情况下Notification 可能为 nil需容错。
if s.Notification == nil {
return nil
}
if err := s.Notification.DispatchMemoWebhooks(ctx, memo, activityType); err != nil {
return err
}
return nil
}
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
creatorID, err := ExtractUserIDFromName(memo.Creator)
if err != nil {
return nil, errors.Wrap(err, "invalid memo creator")
}
return &webhook.WebhookRequestPayload{
Creator: fmt.Sprintf("%s%d", UserNamePrefix, creatorID),
Memo: memo,
}, nil
}
// 旧的 payload 转换函数已由 server/notification/service.go 中的实现取代。
func getMemoContentSnippet(content string) (string, error) {
doc, err := gomark.Parse(content)

View File

@ -15,6 +15,7 @@ import (
"google.golang.org/grpc/reflection"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/server/notification"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
@ -37,6 +38,8 @@ type APIV1Service struct {
Profile *profile.Profile
Store *store.Store
Notification *notification.Service
grpcServer *grpc.Server
}
@ -46,6 +49,7 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
Secret: secret,
Profile: profile,
Store: store,
Notification: notification.NewService(store),
grpcServer: grpcServer,
}
grpc_health_v1.RegisterHealthServer(grpcServer, apiv1Service)

View File

@ -38,6 +38,7 @@
"i18next": "^25.5.2",
"katex": "^0.16.22",
"leaflet": "^1.9.4",
"lightningcss-win32-x64-msvc": "1.30.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"mermaid": "^11.11.0",
@ -92,4 +93,4 @@
"esbuild"
]
}
}
}

View File

@ -3,6 +3,7 @@ import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -19,6 +20,7 @@ interface Props {
interface State {
displayName: string;
url: string;
type: "RAW" | "WECOM" | "BARK";
}
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
@ -27,6 +29,7 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
const [state, setState] = useState<State>({
displayName: "",
url: "",
type: "RAW",
});
const requestState = useLoading(false);
const isCreating = webhookName === undefined;
@ -42,9 +45,11 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
.then((response) => {
const webhook = response.webhooks.find((w) => w.name === webhookName);
if (webhook) {
const { type, rawUrl } = deriveTypeAndUrl(webhook.url);
setState({
displayName: webhook.displayName,
url: webhook.url,
url: rawUrl,
type,
});
}
});
@ -83,12 +88,15 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
try {
requestState.setLoading();
// 根据类型构造存储的 URL。兼容短期方案为 WeCom/Bark 显式添加自定义前缀,后端将解析并派发。
const urlForStore = buildUrlForStore(state.type, state.url);
if (isCreating) {
await userServiceClient.createUserWebhook({
parent: currentUser.name,
webhook: {
displayName: state.displayName,
url: state.url,
url: urlForStore,
},
});
} else {
@ -96,7 +104,7 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
webhook: {
name: webhookName,
displayName: state.displayName,
url: state.url,
url: urlForStore,
},
updateMask: ["display_name", "url"],
});
@ -112,6 +120,50 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
}
};
const handleCopyTest = async () => {
// 复制一条示例 curl方便用户测试。
const sample = buildTestCommand(state.type, state.url, state.displayName);
try {
await navigator.clipboard.writeText(sample);
toast.success(t("common.copied") ?? "Copied");
} catch {
toast.error("Failed to copy test command");
}
};
const deriveTypeAndUrl = (storedUrl: string): { type: State["type"]; rawUrl: string } => {
if (storedUrl.startsWith("wecom://")) {
return { type: "WECOM", rawUrl: storedUrl.replace(/^wecom:\/\//, "") };
}
if (storedUrl.startsWith("bark://")) {
return { type: "BARK", rawUrl: storedUrl.replace(/^bark:\/\//, "") };
}
return { type: "RAW", rawUrl: storedUrl };
};
const buildUrlForStore = (type: State["type"], rawUrl: string) => {
const u = rawUrl.trim();
if (type === "WECOM") return `wecom://${u}`;
if (type === "BARK") return `bark://${u}`;
return u;
};
const buildTestCommand = (type: State["type"], rawUrl: string, name: string) => {
if (type === "WECOM") {
// 企业微信机器人文本消息示例
const real = rawUrl.trim();
const content = `Test from Memos webhook: ${name}`;
return `curl -X POST -H "Content-Type: application/json" -d '{"msgtype":"text","text":{"content":"${content}"}}' "${real}"`;
}
if (type === "BARK") {
const base = rawUrl.trim().replace(/\/$/, "");
return `curl "${base}/Test%20from%20Memos/${encodeURIComponent(name)}"`;
}
// RAW示例发送通用 JSON。
const real = rawUrl.trim();
return `curl -X POST -H "Content-Type: application/json" -d '{"activityType":"memos.memo.test","creator":"users/1","memo":{}}' "${real}"`;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
@ -123,6 +175,19 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="type">{t("common.type")}</Label>
<Select value={state.type} onValueChange={(val) => setPartialState({ type: val as State["type"] })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="RAW">RAW</SelectItem>
<SelectItem value="WECOM">WeCom</SelectItem>
<SelectItem value="BARK">Bark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="displayName">
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
@ -146,12 +211,22 @@ function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Pro
value={state.url}
onChange={handleUrlInputChange}
/>
<p className="text-xs text-muted-foreground">
{state.type === "RAW"
? "RAW你的服务需接收通用 JSON。"
: state.type === "WECOM"
? "企业微信:请输入机器人完整链接,例如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
: "Bark请输入 https://api.day.app/{key} 或自建 bark-server 根地址"}
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button variant="outline" disabled={!state.url} onClick={handleCopyTest}>
curl
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")}
</Button>

View File

@ -0,0 +1,280 @@
import { useEffect, useMemo, useState } from "react";
import memoStore from "@/store/memo";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Link } from "react-router-dom";
type ParsedOrderItem = { name: string; qty: number; price?: number };
type ParsedOrder = {
memo: Memo;
menuId: string | null;
items: ParsedOrderItem[];
amount?: number; // sum of qty*price if present
totalQty: number;
};
function parseOrderContent(content: string): { menuId: string | null; items: ParsedOrderItem[] } {
const lines = content.split(/\r?\n/);
let menuId: string | null = null;
if (lines.length > 0) {
const m = lines[0].match(/#menu:([A-Za-z0-9_-]+)/);
if (m) menuId = m[1];
}
const items: ParsedOrderItem[] = [];
const itemRegex = /^\s*-\s*name:"([^"]+)"\s+qty:(\d+)(?:\s+price:(\d+(?:\.\d+)?))?/;
for (const l of lines) {
const m = l.match(itemRegex);
if (m) {
const name = m[1];
const qty = Number(m[2]);
const price = m[3] ? Number(m[3]) : undefined;
items.push({ name, qty, price });
}
}
return { menuId, items };
}
function isOrderMemo(m: Memo): boolean {
return (m.tags || []).includes("order") || /#order\b/.test(m.content || "");
}
const ALL_VALUE = "__all__";
export default function MenuOrdersView(props: { selectedMenuId?: string | "" }) {
const [orders, setOrders] = useState<ParsedOrder[]>([]);
const [nextToken, setNextToken] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [onlySelected, setOnlySelected] = useState(false);
const [dateStart, setDateStart] = useState<string>("");
const [dateEnd, setDateEnd] = useState<string>("");
const [menuFilter, setMenuFilter] = useState<string>(ALL_VALUE);
const fetchPage = async (token?: string) => {
setLoading(true);
try {
const { memos, nextPageToken } = (await memoStore.fetchMemos({ pageToken: token })) || { memos: [], nextPageToken: "" };
const newOrders: ParsedOrder[] = [];
for (const m of memos || []) {
if (!isOrderMemo(m)) continue;
const { menuId, items } = parseOrderContent(m.content || "");
const amount = items.reduce((s, it) => s + (it.price ? it.price * it.qty : 0), 0);
const totalQty = items.reduce((s, it) => s + it.qty, 0);
newOrders.push({ memo: m, menuId, items, amount: amount || undefined, totalQty });
}
setOrders((prev) => (token ? [...prev, ...newOrders] : newOrders));
setNextToken(nextPageToken || undefined);
} finally {
setLoading(false);
}
};
useEffect(() => {
// 初次加载第一页
fetchPage(undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() => {
let cur = orders;
if (onlySelected && props.selectedMenuId) {
cur = cur.filter((o) => o.menuId === props.selectedMenuId);
}
if (menuFilter && menuFilter !== ALL_VALUE) {
cur = cur.filter((o) => o.menuId === menuFilter);
}
return cur;
}, [orders, onlySelected, props.selectedMenuId, menuFilter]);
const filteredByDate = useMemo(() => {
if (!dateStart && !dateEnd) return filtered;
const startTs = dateStart ? new Date(dateStart + "T00:00:00").getTime() : -Infinity;
const endTs = dateEnd ? new Date(dateEnd + "T23:59:59.999").getTime() : Infinity;
return filtered.filter((o) => {
const t = o.memo.createTime ? new Date(o.memo.createTime).getTime() : 0;
return t >= startTs && t <= endTs;
});
}, [filtered, dateStart, dateEnd]);
const aggregate = useMemo(() => {
const byItem = new Map<string, { qty: number; revenue: number }>();
for (const o of filteredByDate) {
for (const it of o.items) {
const key = it.name;
const prev = byItem.get(key) || { qty: 0, revenue: 0 };
prev.qty += it.qty;
if (it.price) prev.revenue += it.price * it.qty;
byItem.set(key, prev);
}
}
return Array.from(byItem.entries()).map(([name, v]) => ({ name, ...v }));
}, [filteredByDate]);
const allMenuIds = useMemo(() => {
const s = new Set<string>();
for (const o of orders) if (o.menuId) s.add(o.menuId);
return Array.from(s.values()).sort();
}, [orders]);
const setPresetDays = (days: number) => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - (days - 1));
setDateStart(start.toISOString().slice(0, 10));
setDateEnd(end.toISOString().slice(0, 10));
};
// CSV 导出
const toCsv = (rows: string[][]) => rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const downloadCsv = (name: string, csv: string) => {
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
const exportOrders = () => {
const header = ["time", "menuId", "item", "qty", "price", "amount"];
const rows: string[][] = [header];
for (const o of filteredByDate) {
const timeStr = o.memo.createTime ? new Date(o.memo.createTime).toLocaleString() : "";
for (const it of o.items) {
const amt = it.price ? (it.price * it.qty).toFixed(2) : "";
rows.push([timeStr, o.menuId ?? "", it.name, String(it.qty), it.price != null ? String(it.price) : "", amt]);
}
}
downloadCsv("orders.csv", toCsv(rows));
};
const exportAggregate = () => {
const header = ["item", "qty", "revenue"];
const rows: string[][] = [header];
for (const row of aggregate) {
rows.push([row.name, String(row.qty), row.revenue ? row.revenue.toFixed(2) : ""]);
}
downloadCsv("orders_aggregate.csv", toCsv(rows));
};
return (
<div className="border rounded-xl p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<span></span>
<input type="date" value={dateStart} onChange={(e) => setDateStart(e.target.value)} />
<span></span>
<input type="date" value={dateEnd} onChange={(e) => setDateEnd(e.target.value)} />
</div>
<div className="flex items-center gap-2 text-sm">
<span></span>
<Button variant="outline" size="sm" onClick={() => setPresetDays(1)}></Button>
<Button variant="outline" size="sm" onClick={() => setPresetDays(7)}>7</Button>
<Button variant="outline" size="sm" onClick={() => setPresetDays(30)}>30</Button>
<Button variant="outline" size="sm" onClick={() => { setDateStart(""); setDateEnd(""); }}></Button>
</div>
<label className="text-sm inline-flex items-center gap-1">
<input type="checkbox" checked={onlySelected} onChange={(e) => setOnlySelected(e.target.checked)} />
</label>
<div className="text-sm inline-flex items-center gap-2">
<span></span>
<Select value={menuFilter} onValueChange={(v) => setMenuFilter(v)}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem key={ALL_VALUE} value={ALL_VALUE}></SelectItem>
{allMenuIds.map((id) => (
<SelectItem key={id} value={id}>{id}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={exportOrders}> CSV</Button>
<Button variant="outline" onClick={exportAggregate}> CSV</Button>
{nextToken && (
<Button variant="outline" disabled={loading} onClick={() => fetchPage(nextToken)}>
{loading ? "加载中..." : "加载更多"}
</Button>
)}
</div>
</div>
{/* 列表 */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead>
<tr>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredByDate.map((o) => (
<tr key={o.memo.name}>
<td className="px-3 py-2 text-sm">
{o.memo.createTime ? (
<Link
className="hover:underline"
to={`/memos/${o.memo.name.replace(/^memos\//, "")}`}
target="_blank"
>
{new Date(o.memo.createTime).toLocaleString()}
</Link>
) : (
""
)}
</td>
<td className="px-3 py-2 text-sm">{o.menuId ?? "?"}</td>
<td className="px-3 py-2 text-sm">{o.items.length}</td>
<td className="px-3 py-2 text-sm">{o.totalQty}</td>
<td className="px-3 py-2 text-sm">{o.amount != null ? o.amount.toFixed(2) : "-"}</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td className="px-3 py-2 text-sm text-muted-foreground" colSpan={5}>
#order
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 汇总 */}
<div className="mt-2">
<div className="font-medium mb-1"></div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead>
<tr>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
<th className="px-3 py-2 text-left text-sm font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{aggregate.map((row) => (
<tr key={row.name}>
<td className="px-3 py-2 text-sm">{row.name}</td>
<td className="px-3 py-2 text-sm">{row.qty}</td>
<td className="px-3 py-2 text-sm">{row.revenue ? row.revenue.toFixed(2) : "-"}</td>
</tr>
))}
{aggregate.length === 0 && (
<tr>
<td className="px-3 py-2 text-sm text-muted-foreground" colSpan={3}>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon, UtensilsCrossedIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { NavLink } from "react-router-dom";
@ -54,6 +54,12 @@ const Navigation = observer((props: Props) => {
title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
};
const menuNavLink: NavLinkItem = {
id: "header-menu",
path: Routes.MENU,
title: "Menu",
icon: <UtensilsCrossedIcon className="w-6 h-auto shrink-0" />,
};
const signInNavLink: NavLinkItem = {
id: "header-auth",
path: Routes.AUTH,
@ -61,7 +67,7 @@ const Navigation = observer((props: Props) => {
icon: <UserCircleIcon className="w-6 h-auto shrink-0" />,
};
const navLinks: NavLinkItem[] = currentUser ? [homeNavLink, exploreNavLink, attachmentsNavLink] : [exploreNavLink, signInNavLink];
const navLinks: NavLinkItem[] = currentUser ? [homeNavLink, exploreNavLink, attachmentsNavLink, menuNavLink] : [exploreNavLink, signInNavLink];
return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}>

View File

@ -63,6 +63,9 @@ const WebhookSection = () => {
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-foreground">
{t("common.name")}
</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-foreground">
{t("common.type")}
</th>
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-foreground">
{t("setting.webhook-section.url")}
</th>
@ -75,6 +78,7 @@ const WebhookSection = () => {
{webhooks.map((webhook) => (
<tr key={webhook.name}>
<td className="whitespace-nowrap px-3 py-2 text-sm text-foreground">{webhook.displayName}</td>
<td className="whitespace-nowrap px-3 py-2 text-sm text-foreground">{deriveType(webhook.url)}</td>
<td className="max-w-[200px] px-3 py-2 text-sm text-foreground truncate" title={webhook.url}>
{webhook.url}
</td>
@ -122,4 +126,11 @@ const WebhookSection = () => {
);
};
// 简易类型推断(与后端一致的前缀识别)。
function deriveType(url: string): string {
if (url.startsWith("wecom://")) return "WeCom";
if (url.startsWith("bark://")) return "Bark";
return "RAW";
}
export default WebhookSection;

408
web/src/pages/MenuMVP.tsx Normal file
View File

@ -0,0 +1,408 @@
import { DownloadIcon, PlusIcon, TrashIcon, UploadIcon, FilePlusIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import memoStore from "@/store/memo";
import { Visibility } from "@/types/proto/api/v1/memo_service";
import { toast } from "react-hot-toast";
import MenuOrdersView from "@/components/MenuOrdersView";
type MenuItem = { id: string; name: string; price?: number };
type Menu = { id: string; name: string; items: MenuItem[] };
const STORAGE_KEY = "memos.menu.mvp";
function loadMenus(): Menu[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const data = JSON.parse(raw);
if (Array.isArray(data)) return data as Menu[];
} catch {
// ignore
}
return [];
}
function saveMenus(menus: Menu[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(menus));
}
function slugify(s: string) {
return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
const MenuMVP = () => {
const [menus, setMenus] = useState<Menu[]>([]);
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [newMenuName, setNewMenuName] = useState("");
// 订单构建状态itemId -> qty
const [qtyMap, setQtyMap] = useState<Record<string, number>>({});
const [note, setNote] = useState("");
const [showBulk, setShowBulk] = useState(false);
const [bulkText, setBulkText] = useState("");
const [isImportOpen, setIsImportOpen] = useState(false);
const [importCandidates, setImportCandidates] = useState<any[]>([]);
useEffect(() => {
const ms = loadMenus();
setMenus(ms);
if (ms.length > 0) setSelectedMenuId(ms[0].id);
}, []);
const selectedMenu = useMemo(() => menus.find((m) => m.id === selectedMenuId), [menus, selectedMenuId]);
const addMenu = () => {
const name = newMenuName.trim();
if (!name) return;
const id = slugify(name) || `menu-${Date.now()}`;
if (menus.some((m) => m.id === id)) {
toast.error("ID 已存在,请更换名称");
return;
}
const next = [...menus, { id, name, items: [] }];
setMenus(next);
saveMenus(next);
setSelectedMenuId(id);
setNewMenuName("");
};
const deleteMenu = (id: string) => {
const next = menus.filter((m) => m.id !== id);
setMenus(next);
saveMenus(next);
if (selectedMenuId === id) setSelectedMenuId(next[0]?.id ?? "");
};
const addItem = () => {
if (!selectedMenu) return;
const newItem: MenuItem = { id: `i-${Date.now()}`, name: "" };
const next = menus.map((m) => (m.id === selectedMenu.id ? { ...m, items: [...m.items, newItem] } : m));
setMenus(next);
saveMenus(next);
};
const updateItem = (itemId: string, patch: Partial<MenuItem>) => {
if (!selectedMenu) return;
const next = menus.map((m) =>
m.id === selectedMenu.id
? { ...m, items: m.items.map((it) => (it.id === itemId ? { ...it, ...patch } : it)) }
: m,
);
setMenus(next);
saveMenus(next);
};
const deleteItem = (itemId: string) => {
if (!selectedMenu) return;
const next = menus.map((m) =>
m.id === selectedMenu.id ? { ...m, items: m.items.filter((it) => it.id !== itemId) } : m,
);
setMenus(next);
saveMenus(next);
};
const setQty = (itemId: string, qty: number) => {
setQtyMap((prev) => ({ ...prev, [itemId]: qty }));
};
const generateContent = () => {
if (!selectedMenu) return "";
const header = `#order #menu:${selectedMenu.id}`;
const lines: string[] = [header, "", "- items:"];
for (const it of selectedMenu.items) {
const qty = Math.max(0, Number(qtyMap[it.id] || 0));
if (qty > 0) {
const pricePart = it.price != null ? ` price:${it.price}` : "";
lines.push(` - name:"${it.name}" qty:${qty}${pricePart}`);
}
}
if (note.trim()) {
lines.push(`- note: ${note.trim()}`);
}
return lines.join("\n");
};
const submitOrder = async () => {
if (!selectedMenu) {
toast.error("请先创建并选择菜单");
return;
}
const content = generateContent();
if (!/qty:\s*\d+/.test(content)) {
toast.error("请为至少一项设置数量");
return;
}
try {
await memoStore.createMemo({
memo: {
content,
visibility: Visibility.PRIVATE,
},
memoId: "",
validateOnly: false,
requestId: "",
});
toast.success("已创建订单备忘录");
// 重置选项但保留菜单
setQtyMap({});
setNote("");
} catch (err: any) {
console.error(err);
toast.error(err?.details ?? "创建失败");
}
};
// —— 菜单定义导入/导出(通过 Memo 实现跨设备共享)——
const exportMenusToMemo = async () => {
try {
const payload = {
version: 1,
menus,
};
const json = JSON.stringify(payload, null, 2);
const content = `#menu-def\n\n\`\`\`json\n${json}\n\`\`\``;
await memoStore.createMemo({
memo: {
content,
visibility: Visibility.PRIVATE,
},
memoId: "",
validateOnly: false,
requestId: "",
});
toast.success("已导出为菜单定义备忘录(#menu-def");
} catch (err: any) {
console.error(err);
toast.error(err?.details ?? "导出失败");
}
};
const stripCodeFence = (src: string) => {
const m = src.match(/```\s*json\s*([\s\S]*?)```/i);
if (m) return m[1];
// fallback找第一个 { 或 [ 开始的 JSON
const i = Math.min(
...[src.indexOf("{"), src.indexOf("[")].filter((x) => x >= 0),
);
if (isFinite(i as number) && (i as number) >= 0) return src.slice(i as number);
return src;
};
const importMenusFromMemos = async () => {
try {
// 最多读取 5 页,列出所有含 #menu-def 的候选供选择
let token: string | undefined = undefined;
const candidates: any[] = [];
let loop = 0;
while (loop < 5) {
const resp = (await memoStore.fetchMemos({ pageToken: token })) || { memos: [], nextPageToken: "" };
const { memos, nextPageToken } = resp;
for (const m of memos || []) {
const c = m.content || "";
if (!/#menu-def\b/.test(c)) continue;
try {
const raw = stripCodeFence(c);
const data = JSON.parse(raw);
candidates.push({ memo: m, data });
} catch {
// ignore parse errors
}
}
if (!nextPageToken) break;
token = nextPageToken;
loop++;
}
if (candidates.length === 0) {
toast.error("未找到 #menu-def 菜单定义备忘录");
return;
}
setImportCandidates(candidates);
setIsImportOpen(true);
} catch (err: any) {
console.error(err);
toast.error("导入失败");
}
};
const applyImportData = (payload: any) => {
const importedMenus: Menu[] = Array.isArray(payload?.menus)
? payload.menus
: Array.isArray(payload) ? payload : [];
if (importedMenus.length === 0) {
toast.error("菜单定义内容为空或格式不正确");
return;
}
const existingIds = new Set(menus.map((m) => m.id));
const merged: Menu[] = [...menus];
for (const im of importedMenus) {
let id = im.id || slugify(im.name || "menu");
while (existingIds.has(id)) id = `${id}-imported`;
existingIds.add(id);
merged.push({
id,
name: im.name || id,
items: (im.items || []).map((it: any) => ({ id: it.id || slugify(it.name || "item"), name: it.name || "", price: it.price }))
});
}
setMenus(merged);
saveMenus(merged);
setIsImportOpen(false);
toast.success(`已导入 ${importedMenus.length} 个菜单`);
};
const bulkAddItems = () => {
if (!selectedMenu) return;
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return;
const newItems: MenuItem[] = [];
for (const line of lines) {
const m = line.match(/^([^,]+?)(?:\s*,\s*(\d+(?:\.\d+)?))?$/);
if (!m) continue;
const name = m[1].trim();
const price = m[2] ? Number(m[2]) : undefined;
newItems.push({ id: `i-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name, price });
}
const next = menus.map((m) => (m.id === selectedMenu.id ? { ...m, items: [...m.items, ...newItems] } : m));
setMenus(next);
saveMenus(next);
setShowBulk(false);
setBulkText("");
toast.success(`已添加 ${newItems.length} 条目`);
};
return (
<div className="w-full max-w-5xl mx-auto p-4 space-y-4">
<h2 className="text-lg font-semibold">MVP</h2>
<div className="grid md:grid-cols-3 gap-4">
{/* 菜单列表 */}
<div className="border rounded-xl p-3">
<div className="flex items-center gap-2">
<Input placeholder="新菜单名称" value={newMenuName} onChange={(e) => setNewMenuName(e.target.value)} />
<Button onClick={addMenu}>
<PlusIcon className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="mt-3 space-y-2">
{menus.map((m) => (
<div key={m.id} className={`flex items-center justify-between px-2 py-1 rounded ${m.id === selectedMenuId ? "bg-accent" : ""}`}>
<button className="text-left grow" onClick={() => setSelectedMenuId(m.id)}>
<div className="font-medium">{m.name}</div>
<div className="text-xs text-muted-foreground">ID: {m.id}</div>
</button>
<Button variant="ghost" onClick={() => deleteMenu(m.id)}>
<TrashIcon className="w-4 h-4 text-destructive" />
</Button>
</div>
))}
{menus.length === 0 && <div className="text-sm text-muted-foreground"></div>}
</div>
</div>
{/* 菜单明细编辑 */}
<div className="border rounded-xl p-3 md:col-span-2">
<div className="flex items-center justify-between">
<div className="font-medium">{selectedMenu ? `编辑菜单:${selectedMenu.name}` : "请选择菜单"}</div>
{selectedMenu && (
<Button variant="outline" onClick={addItem}>
<PlusIcon className="w-4 h-4 mr-1" />
</Button>
)}
</div>
{selectedMenu && (
<div className="mt-3 space-y-2">
{selectedMenu.items.map((it) => (
<div key={it.id} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<Label className="text-xs"></Label>
<Input value={it.name} onChange={(e) => updateItem(it.id, { name: e.target.value })} />
</div>
<div className="col-span-3">
<Label className="text-xs">()</Label>
<Input
type="number"
value={it.price ?? ""}
onChange={(e) => updateItem(it.id, { price: e.target.value === "" ? undefined : Number(e.target.value) })}
/>
</div>
<div className="col-span-3">
<Label className="text-xs"></Label>
<Input
type="number"
min={0}
value={qtyMap[it.id] ?? 0}
onChange={(e) => setQty(it.id, Math.max(0, Number(e.target.value)))}
/>
</div>
<div className="col-span-1 flex items-end">
<Button variant="ghost" onClick={() => deleteItem(it.id)}>
<TrashIcon className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
{selectedMenu.items.length === 0 && <div className="text-sm text-muted-foreground"></div>}
<div className="mt-2">
<Label className="text-xs"></Label>
<Input placeholder="如:少辣、走葱" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
<div className="mt-3 flex items-center gap-2">
<Button onClick={submitOrder}></Button>
<Button variant="outline" onClick={() => navigator.clipboard.writeText(generateContent())}></Button>
<div className="grow" />
<Button variant="outline" onClick={importMenusFromMemos}>
<UploadIcon className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" onClick={exportMenusToMemo}>
<DownloadIcon className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" onClick={() => setShowBulk((v) => !v)}>
<FilePlusIcon className="w-4 h-4 mr-1" />
</Button>
</div>
{showBulk && (
<div className="mt-2 border rounded-lg p-2">
<div className="text-sm text-muted-foreground mb-1">[,],28</div>
<textarea className="w-full h-28 rounded-md border bg-background p-2" value={bulkText} onChange={(e) => setBulkText(e.target.value)} />
<div className="mt-2 flex items-center gap-2">
<Button onClick={bulkAddItems}></Button>
<Button variant="ghost" onClick={() => setShowBulk(false)}></Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
<Dialog open={isImportOpen} onOpenChange={setIsImportOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto space-y-2">
{importCandidates.map((c, idx) => (
<div key={idx} className="border rounded-lg p-2">
<div className="text-sm">{c.memo.createTime ? new Date(c.memo.createTime).toLocaleString() : ""}</div>
<div className="text-sm">{Array.isArray(c.data?.menus) ? c.data.menus.map((m: any) => m.name).filter(Boolean).slice(0,3).join("") : "(未知格式)"}</div>
<div className="mt-2">
<Button onClick={() => applyImportData(c.data)}></Button>
</div>
</div>
))}
{importCandidates.length === 0 && <div className="text-sm text-muted-foreground"></div>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsImportOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<MenuOrdersView selectedMenuId={selectedMenu?.id} />
</div>
);
};
export default MenuMVP;

View File

@ -20,6 +20,7 @@ const SignIn = lazy(() => import("@/pages/SignIn"));
const SignUp = lazy(() => import("@/pages/SignUp"));
const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
const MenuMVP = lazy(() => import("@/pages/MenuMVP"));
export enum Routes {
ROOT = "/",
@ -28,6 +29,7 @@ export enum Routes {
ARCHIVED = "/archived",
SETTING = "/setting",
EXPLORE = "/explore",
MENU = "/menu",
AUTH = "/auth",
}
@ -118,6 +120,14 @@ const router = createBrowserRouter([
</Suspense>
),
},
{
path: Routes.MENU,
element: (
<Suspense fallback={<Loading />}>
<MenuMVP />
</Suspense>
),
},
{
path: Routes.INBOX,
element: (