20260818172816
This commit is contained in:
1 parent
b90ae89b0e
commit
f565437125
21 files changed
+3281
-14
No files matched your search
+120
-14
@@ -150,7 +150,7 @@
|
||||
- 普通业务页面可以由通用运行时生成,复杂业务允许代码扩展。
|
||||
- IT 开发人员可以方便地增加特殊 Vue 页面、业务 Handler 和特殊 POST 接口。
|
||||
- 每次模块配置保存都生成不可变配置版本,默认可以立即生效,也可以先预览验证再激活,并支持回滚、导出、导入和跨环境迁移。
|
||||
- 菜单、动作、数据范围和配置管理权限由统一权限中心处理,字段级隐藏和脱敏作为后续扩展。
|
||||
- 菜单、动作、数据范围、字段级访问和配置管理权限由统一权限中心处理;首期支持按用户、角色或用户组配置字段查看和导出权限,复杂条件脱敏作为后续扩展。
|
||||
- 菜单、页面、字段、动作、字典和主数据支持统一多语言解析与回退。
|
||||
- 技术可观测日志和客户可见的业务操作日志分层存储并可关联追踪。
|
||||
- 系统元数据具备完整业务语义,可直接供 AI 理解和调用。
|
||||
@@ -281,9 +281,11 @@ entities:
|
||||
- ocean.booking
|
||||
- ocean.shipment
|
||||
- ocean.container
|
||||
routes:
|
||||
- /ocean/bookings
|
||||
- /ocean/shipments
|
||||
pages:
|
||||
- ocean.booking.default-list
|
||||
- ocean.booking.default-form
|
||||
menus:
|
||||
- ocean.booking
|
||||
```
|
||||
|
||||
模块依赖必须形成有向无环图,不能循环依赖。每个实体、动作、事件和特殊页面只能有一个所属模块;其他模块通过声明依赖后使用公开实体、查询、动作或事件,不能绕过通用运行时直接修改其他模块的数据表。
|
||||
@@ -306,7 +308,18 @@ routes:
|
||||
默认表单:ocean.booking.default-form
|
||||
```
|
||||
|
||||
一个实体可以有多个列表、表单和详情页面。页面配置不能反过来决定实体的数据结构。
|
||||
一个实体可以有多个列表、表单和详情页面。页面配置不能反过来决定实体的数据结构。页面是稳定的访问入口,负责绑定路由、视图/表单或特殊组件以及页面访问权限;菜单只负责组织页面入口,不重复维护内部页面路由。
|
||||
|
||||
页面和菜单的配置关系如下:
|
||||
|
||||
```text
|
||||
实体
|
||||
|- 视图 / 表单
|
||||
|- 页面(page_key + route + permission)
|
||||
`- 菜单(menu_key + parent + page_key + sort + visible)
|
||||
```
|
||||
|
||||
菜单类型至少包括 `directory`、`page` 和 `external`。目录菜单不直接代表业务页面;只有子页面存在且当前用户有页面访问权限时,目录才显示。内部 `page` 菜单的路由始终从页面资源解析,外链菜单才允许单独配置 URL。
|
||||
|
||||
### 7.2 字段的稳定标识
|
||||
|
||||
@@ -396,6 +409,8 @@ writeMode: direct
|
||||
|
||||
客户、港口等共享主数据通常是普通引用,删除业务单不能删除客户;订舱明细、箱信息等可能属于组合关系,需要明确随主单归档或删除。`saveobjt` 的多实体事务必须按照关系元数据检查外键、所有权和删除顺序。
|
||||
|
||||
组合关系可以作为主实体编辑表单中的子表区域使用。子表区域引用一个 `meta_relation`,并绑定子实体的列表列配置和编辑配置;通用运行时负责维护主外键、行排序、增删改和主子表事务。复杂的跨行计算、跨实体联动和状态变化仍通过动作或特殊 Handler 实现。
|
||||
|
||||
### 7.6 删除策略
|
||||
|
||||
不采用所有实体统一软删除。`meta_entity` 必须声明 `deletePolicy`,通用保存和动作引擎根据实体类型、引用关系和业务状态决定是否允许删除:
|
||||
@@ -433,10 +448,13 @@ writeMode: direct
|
||||
| `meta_relation` | 实体关系和引用规则 |
|
||||
| `meta_dictionary` | 固定字典和选项 |
|
||||
| `meta_query_source` | 复杂查询数据源 |
|
||||
| `meta_page` | 页面入口、路由、页面类型、组件和访问权限 |
|
||||
| `meta_menu` | 菜单层级、页面/外链绑定、权限、图标、顺序和可见性 |
|
||||
| `meta_view` | 列表、详情等视图定义 |
|
||||
| `meta_view_field` | 列表字段、顺序、宽度和格式 |
|
||||
| `meta_form` | 表单定义 |
|
||||
| `meta_form_field` | 表单分组、控件、校验和布局 |
|
||||
| `meta_form_relation` | 表单中的主子关系区域、子表视图和编辑行为 |
|
||||
| `meta_action` | 通用动作和业务动作 |
|
||||
| `meta_i18n` | 多语言资源 |
|
||||
| `meta_change_set` | 配置和结构变更集 |
|
||||
@@ -496,6 +514,46 @@ updated_by
|
||||
|
||||
模块 bundle 必须携带多语言资源。售后可以补充和修改翻译,配置发布时一同生成变更集。AI 可以生成批量翻译草稿,但需要保留来源、模型版本并经过人工确认后发布。
|
||||
|
||||
### 8.2 页面与菜单配置
|
||||
|
||||
页面和菜单都使用稳定业务 key,并随模块配置版本一起发布。推荐的页面配置如下:
|
||||
|
||||
```yaml
|
||||
page:
|
||||
key: ocean.booking.default-list
|
||||
module: ocean
|
||||
type: runtime
|
||||
route: /ocean/bookings
|
||||
view: ocean.booking.default-list
|
||||
permission: ocean.booking.view
|
||||
visible: true
|
||||
```
|
||||
|
||||
菜单配置只描述导航树,不重复描述页面布局:
|
||||
|
||||
```yaml
|
||||
menu:
|
||||
key: ocean.booking
|
||||
module: ocean
|
||||
parent: ocean
|
||||
type: page
|
||||
page: ocean.booking.default-list
|
||||
nameKey: menu.ocean.booking
|
||||
icon: ship
|
||||
sort: 10
|
||||
visible: true
|
||||
```
|
||||
|
||||
配置校验必须保证:
|
||||
|
||||
1. 页面引用的视图、表单、组件和权限资源存在且属于当前模块或其依赖模块。
|
||||
2. `page` 菜单必须绑定页面,`directory` 菜单不能绑定页面或 URL,`external` 菜单必须提供外部 URL 和菜单权限。
|
||||
3. 同一个激活 release 内,页面 route 唯一,菜单 key、页面 key 和父子关系无循环。
|
||||
4. 菜单显示继承页面访问权限;目录菜单下没有可访问页面时不显示。
|
||||
5. 页面、菜单名称和图标等显示信息通过统一 i18n 和资源配置解析。
|
||||
|
||||
新增或修改页面、菜单、顺序、可见性和路由时,只生成元数据变更集;新增特殊 Vue 组件仍需代码部署。
|
||||
|
||||
## 9. Select 与引用关系
|
||||
|
||||
Select 选项应当是一等模型,不再要求为每一个下拉框创建数据库视图。
|
||||
@@ -861,6 +919,30 @@ removableAfterVersion
|
||||
|
||||
例如把已有的 `varchar(50)` 扩大到 `varchar(200)`,或把平台已支持的整数字段迁移为 decimal,属于配置发布和数据库迁移,不属于增加代码能力。变更前仍需完成数据可转换检查、依赖分析、备份检查和影响预览。缩短长度、改变精度或物理删除字段应采用第 11.3 节的渐进式迁移,不能因为“不需要重启”就直接执行危险 DDL。
|
||||
|
||||
### 12.6 拖拽式主表单与子表配置
|
||||
|
||||
普通主表单和主子表页面由配置中心通过拖拽设计器生成。设计器提供字段、关系、分组和标准控件的组件面板,技术人员可以把字段拖入表单,把组合关系拖入子表区域,并配置顺序、宽度、分组、只读、必填、默认控件和子表操作。
|
||||
|
||||
推荐的元数据关系如下:
|
||||
|
||||
```text
|
||||
meta_form
|
||||
|- meta_form_field 主实体字段项
|
||||
|- meta_form_relation 主子关系区域
|
||||
| |- relation_key
|
||||
| |- child_view_key 子表列配置
|
||||
| |- child_form_key 子表编辑配置(可选)
|
||||
| |- display_mode table / form / tabs
|
||||
| |- editable
|
||||
| |- allow_add
|
||||
| |- allow_delete
|
||||
| `- allow_sort
|
||||
```
|
||||
|
||||
字段、关系、顺序、可见性、宽度和控件类型等稳定属性使用结构化元数据保存;控件特有的属性和布局扩展使用经过 Schema 校验的 JSON 配置。设计器保存时生成 `change_set` 和候选 release,支持预览、校验、发布和回滚,不直接修改当前运行版本。
|
||||
|
||||
主表和子表保存使用同一个 `saveobjt` 原子事务。请求可以同时提交主实体和子实体的新增、修改、删除,运行时根据 `meta_relation` 校验所有权、外键、唯一性、删除策略、字段权限、乐观锁和业务校验。子表不是简单地把前端数组覆盖到数据库,正式业务状态下的删除、拆分、合并和跨行计算必须调用动作或特殊 Handler。
|
||||
|
||||
## 13. 通用数据运行时
|
||||
|
||||
内部 ERP 以灵活性和开发效率为优先。后端保留少量、稳定、全部使用 POST 的通用能力接口,不为海运订舱、空运单、客户等每个普通实体新增 Controller 和 URL。
|
||||
@@ -964,6 +1046,8 @@ page -> 返回当前页、总数、页码和分页大小
|
||||
}
|
||||
```
|
||||
|
||||
对于主子表编辑,前端可以在一次请求中提交主实体和其组合关系下的子实体变更。通用运行时按关系元数据决定保存顺序、主键回填、行排序、删除策略和事务边界;涉及跨行汇总、状态变更、拆分合并或外部副作用时,必须改用 `action.execute` 或特殊 Handler。
|
||||
|
||||
通用运行时根据当前已发布的元数据完成:
|
||||
|
||||
- 字段解析和类型转换
|
||||
@@ -1087,15 +1171,17 @@ MetadataController
|
||||
- 字典和分类
|
||||
- 普通业务数据列表
|
||||
- 简单主表编辑
|
||||
- 主表编辑表单和一对多子表编辑表格
|
||||
- 通用查询和导出
|
||||
|
||||
复杂页面仍由开发人员编写 Vue 组件,例如:
|
||||
复杂页面和复杂交互仍由 IT 开发人员编写 Vue 组件或 Handler,例如:
|
||||
|
||||
- 海运工作台
|
||||
- 订舱操作台
|
||||
- 多箱、多提单联动编辑
|
||||
- 费用结算
|
||||
- 拖拽式单证编排
|
||||
- 多层嵌套、跨行公式和复杂状态联动
|
||||
|
||||
复杂页面仍然使用相同的实体、字段、引用、动作和通用数据能力,不应建立另一套数据定义。
|
||||
|
||||
@@ -1116,7 +1202,7 @@ MetadataController
|
||||
-> 随前端版本部署
|
||||
```
|
||||
|
||||
特殊页面适合海运工作台、多箱多提单联动、费用结算、拖拽编排和高交互批量操作。不能为了追求完全配置化而降低开发人员实现复杂页面的效率。
|
||||
特殊页面适合海运工作台、多箱多提单联动、费用结算、拖拽编排和高交互批量操作。IT 开发人员可以继续复用同一套实体、关系、通用保存、动作和权限能力;不能为了追求完全配置化而降低开发人员实现复杂页面和复杂业务逻辑的效率。
|
||||
|
||||
### 14.2 特殊页面注册
|
||||
|
||||
@@ -1251,11 +1337,12 @@ public class SplitBookingHandler {
|
||||
统一权限中心
|
||||
= RBAC 功能授权
|
||||
+ ABAC 数据策略
|
||||
+ 字段级访问策略
|
||||
+ 配置管理权限
|
||||
+ AI 工具权限
|
||||
```
|
||||
|
||||
即:角色负责授予用户可以使用哪些功能,数据策略决定用户可以操作哪些记录,配置权限决定谁能修改实体、页面、权限和 Schema。字段级隐藏和脱敏暂不作为第一阶段要求。
|
||||
即:角色负责授予用户可以使用哪些功能,数据策略决定用户可以操作哪些记录,字段级访问策略决定用户可以查看、导出或脱敏哪些字段,配置权限决定谁能修改实体、页面、权限和 Schema。首期只做基于用户、角色或用户组的静态字段策略,不引入复杂的条件脱敏规则。
|
||||
|
||||
### 16.1 权限主体
|
||||
|
||||
@@ -1294,6 +1381,8 @@ public class SplitBookingHandler {
|
||||
- 页面可以单独配置 `visible = false`,表示允许通过链接访问但不显示在菜单中。
|
||||
- 菜单只是页面和模块的导航表现,不是最终权限判断点。
|
||||
|
||||
菜单资源本身保存父子关系、顺序、图标、页面绑定和可见性;页面资源保存 route 和页面访问权限。权限中心返回可访问页面后,菜单运行时按当前 release 构建树并过滤无权页面,目录节点在没有可见子节点时自动隐藏。这样新增普通页面和菜单只需要提交配置变更集,不需要修改前端路由代码;特殊页面仍需先部署组件,再发布对应页面和菜单元数据。
|
||||
|
||||
建议的页面权限 key:
|
||||
|
||||
```text
|
||||
@@ -1380,15 +1469,27 @@ AND
|
||||
|
||||
### 16.6 字段显示与写入边界
|
||||
|
||||
第一阶段不建设复杂的字段隐藏和脱敏权限。列表、表单是否显示某字段以及是否渲染为只读,由页面配置决定;后端是否允许写入由实体字段的 `writeMode` 决定。
|
||||
字段的页面展示、后端写入和权限可见性是三层不同概念。首期支持按用户、角色或用户组配置字段查看和导出权限,并预留基础脱敏方式;按记录条件、表达式或不同场景动态脱敏暂不纳入首期。
|
||||
|
||||
```text
|
||||
页面显示或只读 -> meta_view_field / meta_form_field
|
||||
字段查看、导出和脱敏 -> auth_field_policy / 统一权限中心
|
||||
后端允许普通写入 -> meta_field.writeMode
|
||||
状态变化和受控字段 -> action.execute
|
||||
```
|
||||
|
||||
页面配置只是交互表现,不能把 `actionOnly`、`computed` 或 `system` 字段变成可写。以后出现采购底价、证件号等确实需要按角色隐藏或脱敏的字段时,再在统一权限中心增加可选字段策略,并让页面、数据 API、导出、报表和 AI 使用同一个策略;当前架构不预先实现这部分复杂度。
|
||||
字段策略至少支持以下维度:
|
||||
|
||||
```text
|
||||
view allow / deny
|
||||
export allow / deny
|
||||
mask none / partial / full
|
||||
subject 用户、角色或用户组
|
||||
```
|
||||
|
||||
多个策略同时存在时,拒绝优先于允许;没有配置字段策略的普通字段按实体默认规则处理,敏感字段必须显式配置。字段权限必须由后端统一执行:列表、详情、通用查询、导入导出、报表、后台任务、特殊 Handler 和 AI 工具都必须经过同一个字段权限服务,不能只在前端隐藏列。查询引擎应在生成 SQL 投影或响应序列化阶段裁剪无权字段,导出和 AI 返回也不得通过别的接口绕过策略。
|
||||
|
||||
页面配置只是交互表现,不能把 `actionOnly`、`computed` 或 `system` 字段变成可写。字段可见性、导出权限和 `writeMode` 彼此独立;例如用户可以查看成本但不能导出,也可以导出脱敏后的值但不能修改。复杂的按数据条件脱敏、字段级表达式策略和细粒度临时授权留待后续扩展。
|
||||
|
||||
### 16.7 配置管理权限
|
||||
|
||||
@@ -1452,9 +1553,10 @@ platform.ai.configure
|
||||
| `auth_role_permission` | 角色功能授权 |
|
||||
| `auth_data_policy` | 数据范围策略 |
|
||||
| `auth_role_data_policy` | 角色数据策略关系 |
|
||||
| `auth_field_policy` | 字段查看、导出和脱敏策略 |
|
||||
| `auth_permission_snapshot` | 可选的编译缓存 |
|
||||
|
||||
权限定义、角色模板和数据策略应包含在模块 bundle 中,通过稳定 key 跨环境迁移。具体用户与角色的分配属于环境或租户数据,不应随模块定义覆盖。
|
||||
权限定义、角色模板、数据策略和字段策略应包含在模块 bundle 中,通过稳定 key 跨环境迁移。具体用户与角色的分配属于环境或租户数据,不应随模块定义覆盖。
|
||||
|
||||
### 16.11 AI 权限继承
|
||||
|
||||
@@ -1464,7 +1566,7 @@ AI Gateway 必须继承发起用户的身份和权限上下文:
|
||||
用户发起 AI 请求
|
||||
-> AI Gateway 获取用户权限快照
|
||||
-> 根据动作权限生成可用工具
|
||||
-> 查询工具自动追加数据权限
|
||||
-> 查询工具自动追加数据权限和字段访问策略
|
||||
-> 动作工具再次检查动作权限和业务前置条件
|
||||
```
|
||||
|
||||
@@ -2285,7 +2387,7 @@ created_at
|
||||
| 元数据 | 稳定 key、类型、关系、引用、公式和依赖合法性 |
|
||||
| Schema Compiler | 空库安装、快照差异计算、失败恢复、重复执行、结构对账和回滚兼容检查 |
|
||||
| 通用运行时 | 类型转换、关系保存、`writeMode`、删除策略、乐观锁、事务和批量操作 |
|
||||
| 权限 | 菜单、动作、数据范围、导出、后台任务和 AI 使用相同决策 |
|
||||
| 权限 | 菜单、动作、数据范围、字段查看/导出/基础脱敏、后台任务和 AI 使用相同决策 |
|
||||
| 数据源上下文 | 保持现有 Web 请求注入机制,并验证任务、发布和消息入口会正确设置及清理 `OrgContext` |
|
||||
| 幂等与任务 | 超时重试、重复回调、Worker 崩溃接管、死信和人工重跑 |
|
||||
| 模块兼容 | bundle 双向差异、稳定 key 冲突、特殊页面与 Handler 的 manifest 契约 |
|
||||
@@ -2348,7 +2450,7 @@ created_at
|
||||
| 复杂业务页面 | 编写代码 | 调整允许开放的配置 |
|
||||
| 复杂业务动作 | 编写 Handler | 绑定和配置动作 |
|
||||
| 特殊业务接口 | 使用扩展 SDK 开发 | 配置权限、菜单和参数 |
|
||||
| 权限中心 | 提供决策引擎和策略类型 | 配置角色、菜单、动作和数据范围 |
|
||||
| 权限中心 | 提供决策引擎和策略类型 | 配置角色、菜单、动作、数据范围和字段可见性 |
|
||||
| 双层日志 | 提供追踪、日志和审计框架 | 配置字段审计和客户可见范围 |
|
||||
| 模块迁移机制 | 提供 Compiler 和发布工具 | 导出、导入和发布变更集 |
|
||||
| AI 能力 | 提供工具和扩展点 | 使用 AI 生成配置候选版本 |
|
||||
@@ -2394,6 +2496,7 @@ created_at
|
||||
|
||||
- 沿用并固化现有 `orgId -> OrgContext -> OrgRoutingDataSource` 数据源注入机制。
|
||||
- 完成模块、实体、字段、关系、字典模型。
|
||||
- 完成页面入口和菜单树模型,明确页面路由、菜单绑定、排序、可见性和权限继承规则。
|
||||
- 建立稳定 key 规则和语义描述规范。
|
||||
- 确定真实列与扩展字段的边界。
|
||||
- 实现不可变配置 release、当前指针、候选预览、回滚和变更集。
|
||||
@@ -2416,9 +2519,12 @@ created_at
|
||||
- 实现 `writeMode`、乐观锁、请求幂等和数据库唯一约束。
|
||||
- 实现字典和引用字段。
|
||||
- 实现默认列表、表单和查询页面。
|
||||
- 实现页面运行时路由解析、按权限过滤菜单树和目录节点裁剪。
|
||||
- 实现拖拽式表单设计器、主子关系区域和一对多子表编辑表格。
|
||||
- 实现运行时多语言解析、租户默认语言和用户语言切换。
|
||||
- 实现售后配置中心。
|
||||
- 实现动作和数据范围权限决策引擎。
|
||||
- 实现按用户、角色或用户组的字段查看、导出和基础脱敏策略,并在查询、保存、导出、报表、特殊 Handler 和 AI 工具中统一执行。
|
||||
- 实现用户权限快照、缓存失效和权限解释。
|
||||
- 实现统一 request/trace 上下文和结构化技术日志。
|
||||
- 实现业务审计事件、字段变更和客户时间线。
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fms-mvp-demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "fms-mvp-demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"lucide-react": "^1.31.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
Generated
+1213
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,280 @@
|
||||
:root {
|
||||
--ink: #26313c;
|
||||
--muted: #74808a;
|
||||
--muted-2: #98a2aa;
|
||||
--line: #e0e5e7;
|
||||
--line-dark: #d3dade;
|
||||
--paper: #ffffff;
|
||||
--canvas: #f7f9f9;
|
||||
--sidebar: #26353c;
|
||||
--sidebar-2: #30434b;
|
||||
--teal: #157d78;
|
||||
--teal-dark: #0b625e;
|
||||
--teal-soft: #e8f4f2;
|
||||
--amber: #bd7627;
|
||||
--amber-soft: #fff3e1;
|
||||
--red: #b95252;
|
||||
--shadow: 0 9px 30px rgba(31, 49, 56, .07);
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100vh; color: var(--ink); background: #eef1f2; }
|
||||
.topbar { height: 72px; display: flex; align-items: center; padding: 0 28px; background: #fff; border-bottom: 1px solid var(--line); gap: 34px; }
|
||||
.brand-block { display: flex; align-items: center; gap: 11px; width: 238px; flex: 0 0 238px; }
|
||||
.brand-mark { width: 32px; height: 32px; display: grid; place-items: center; background: var(--teal); color: #fff; font-weight: 800; font-size: 18px; border-radius: 6px; letter-spacing: -.06em; }
|
||||
.brand-name { font-weight: 760; letter-spacing: .04em; font-size: 14px; color: #304047; }
|
||||
.brand-name span { color: var(--teal); margin: 0 2px; }
|
||||
.brand-subtitle { color: var(--muted-2); text-transform: uppercase; font-size: 9px; letter-spacing: .16em; margin-top: 2px; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 9px; color: #66747d; font-size: 13px; flex: 1; }
|
||||
.breadcrumb strong { color: var(--ink); font-weight: 650; }
|
||||
.breadcrumb .slash { color: #b3bdc2; margin: 0 2px; }
|
||||
.muted { color: var(--muted-2); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.local-badge { display: inline-flex; align-items: center; gap: 7px; font-size: 10px; color: #49766f; letter-spacing: .08em; border: 1px solid #c6e0dc; background: #f4fbfa; padding: 6px 9px; border-radius: 5px; }
|
||||
.status-pulse, .release-dot, .green-dot { width: 6px; height: 6px; border-radius: 50%; background: #39a17b; display: inline-block; }
|
||||
.status-pulse { box-shadow: 0 0 0 3px rgba(57, 161, 123, .13); }
|
||||
.icon-button { width: 32px; height: 32px; border: 1px solid transparent; border-radius: 5px; display: inline-grid; place-items: center; color: #77838b; background: transparent; padding: 0; }
|
||||
.icon-button:hover, .icon-button.is-active { color: var(--teal); border-color: #c9dedb; background: var(--teal-soft); }
|
||||
.avatar { width: 31px; height: 31px; border-radius: 50%; display: grid; place-items: center; color: #fff; background: #cf7a54; font-size: 11px; font-weight: 750; }
|
||||
|
||||
.workspace { min-height: calc(100vh - 72px); display: flex; }
|
||||
.sidebar { width: 248px; flex: 0 0 248px; background: var(--sidebar); color: #c4d0d2; padding: 25px 13px 15px; display: flex; flex-direction: column; }
|
||||
.sidebar-section-label { font-size: 10px; letter-spacing: .16em; color: #809198; padding: 0 12px 10px; font-weight: 700; }
|
||||
.sidebar-section-label.with-action { display: flex; justify-content: space-between; align-items: center; padding-top: 27px; }
|
||||
.sidebar-section-label.with-action .icon-button { color: #8da0a5; width: 24px; height: 24px; }
|
||||
.sidebar-section-label.with-action .icon-button:hover { background: #3c535b; color: #fff; }
|
||||
.nav-item, .tree-item { border: 0; background: transparent; color: inherit; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 38px; padding: 0 12px; border-radius: 5px; text-align: left; font-size: 13px; }
|
||||
.nav-item:hover, .tree-item:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||
.nav-item.active { color: #fff; background: #36555b; box-shadow: inset 3px 0 0 #75c7bd; }
|
||||
.nav-item .nav-count { margin-left: auto; font-size: 10px; color: #8fa4a7; }
|
||||
.nav-item.active .nav-count { color: #b5e3dc; }
|
||||
.nav-dot { margin-left: auto; width: 5px; height: 5px; border-radius: 50%; background: #e3a24f; }
|
||||
.sidebar-divider { height: 1px; background: rgba(218, 236, 235, .12); margin: 18px 12px 0; }
|
||||
.module-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tree-item { color: #aebfc1; }
|
||||
.tree-item.open { color: #e0ecec; }
|
||||
.tree-item.child { padding-left: 37px; font-size: 12px; }
|
||||
.tree-item.selected { color: #fff; background: #314950; }
|
||||
.tree-icon { color: #7cc7bc; }
|
||||
.tree-icon.finance { color: #e3ad67; }
|
||||
.tree-icon.master { color: #93a9d2; }
|
||||
.tree-state { margin-left: auto; font-size: 9px; letter-spacing: .08em; color: #8bd3c9; }
|
||||
.sidebar-footer { margin-top: auto; border-top: 1px solid rgba(218, 236, 235, .12); padding: 17px 8px 2px; }
|
||||
.tenant-row { display: flex; align-items: center; gap: 9px; color: #deebeb; }
|
||||
.tenant-icon { width: 28px; height: 28px; display: grid; place-items: center; background: #38565c; color: #9dd7cf; border-radius: 5px; }
|
||||
.tenant-row > div:nth-child(2) { flex: 1; min-width: 0; }
|
||||
.tenant-row strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 600; }
|
||||
.tenant-label { display: block; font-size: 8px; letter-spacing: .12em; color: #7f979b; margin-bottom: 3px; }
|
||||
.sidebar-footer-links { display: flex; justify-content: space-between; align-items: center; color: #779095; font-size: 9px; margin: 11px 2px 0; }
|
||||
.sidebar-footer-links span:first-child { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.main-content { flex: 1; min-width: 0; padding: 30px 33px 48px; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 25px; max-width: 1510px; margin: 0 auto; }
|
||||
.eyebrow, .panel-kicker { display: flex; align-items: center; gap: 8px; color: #839198; font-size: 10px; letter-spacing: .15em; font-weight: 750; }
|
||||
.eyebrow-line { width: 18px; height: 2px; background: #d3984e; display: inline-block; }
|
||||
.page-head h1 { margin: 8px 0 4px; font-size: 29px; line-height: 1.12; letter-spacing: -.035em; font-weight: 730; color: #26373d; }
|
||||
.page-head p { margin: 0; color: #7e8a91; font-size: 13px; }
|
||||
.head-actions { display: flex; align-items: center; gap: 8px; padding-bottom: 3px; }
|
||||
.button { height: 34px; border-radius: 5px; padding: 0 12px; border: 1px solid transparent; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: #54636a; background: transparent; font-size: 12px; font-weight: 650; white-space: nowrap; }
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button.subtle:hover { color: var(--teal); background: #eaf3f2; }
|
||||
.button.outline { border-color: #d4dddf; background: #fff; box-shadow: 0 2px 7px rgba(40,60,65,.04); }
|
||||
.button.outline:hover { border-color: #a6cbc5; color: var(--teal); }
|
||||
.button.primary { background: var(--teal); color: #fff; box-shadow: 0 4px 10px rgba(21,125,120,.16); }
|
||||
.button.primary:hover { background: var(--teal-dark); }
|
||||
.button.small { height: 30px; padding: 0 9px; font-size: 11px; }
|
||||
.button.full { width: 100%; }
|
||||
.mode-strip { max-width: 1510px; margin: 27px auto 14px; height: 43px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||
.mode-tabs { display: flex; align-items: center; gap: 18px; height: 100%; }
|
||||
.mode-tabs button { height: 100%; padding: 0 2px; display: flex; align-items: center; gap: 7px; border: 0; background: transparent; color: #909ba0; font-size: 12px; font-weight: 650; position: relative; }
|
||||
.mode-tabs button.active { color: var(--teal); }
|
||||
.mode-tabs button.active::after { content: ''; position: absolute; height: 2px; bottom: -1px; left: 0; right: 0; background: var(--teal); }
|
||||
.release-summary { display: flex; align-items: center; gap: 9px; color: #8b969c; font-size: 11px; }
|
||||
.release-label { display: flex; align-items: center; gap: 5px; color: #5e958a; letter-spacing: .08em; font-size: 9px; font-weight: 750; }
|
||||
.release-summary strong { color: #56646b; font-size: 11px; }
|
||||
.release-divider { height: 14px; width: 1px; background: #dce2e3; }
|
||||
|
||||
.builder-grid { display: grid; grid-template-columns: 235px minmax(500px, 1fr) 268px; gap: 13px; max-width: 1510px; margin: 0 auto; align-items: start; }
|
||||
.builder-panel { background: var(--paper); border: 1px solid var(--line); border-radius: 7px; box-shadow: var(--shadow); min-width: 0; }
|
||||
.palette-panel, .inspector-panel { padding: 18px 16px; }
|
||||
.canvas-panel { padding: 18px 21px 23px; min-height: 740px; }
|
||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.panel-heading h2 { margin: 4px 0 0; font-size: 15px; letter-spacing: -.01em; color: #324047; font-weight: 720; }
|
||||
.panel-kicker { font-size: 9px; letter-spacing: .15em; color: #91a0a5; }
|
||||
.count-chip { color: #78868d; background: #f0f4f4; border-radius: 4px; padding: 4px 7px; font-size: 9px; }
|
||||
.panel-intro { margin: 13px 0 14px; color: #8a969b; line-height: 1.5; font-size: 11px; }
|
||||
.palette-search { height: 31px; border: 1px solid #dce3e4; border-radius: 4px; display: flex; align-items: center; gap: 7px; color: #9ba6aa; padding: 0 9px; background: #fbfcfc; }
|
||||
.palette-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #57666c; font-size: 11px; width: 100%; }
|
||||
.palette-group { margin-top: 18px; }
|
||||
.palette-group-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; font-size: 9px; letter-spacing: .1em; color: #a0abae; font-weight: 750; }
|
||||
.palette-group-title span { font-size: 9px; color: #bbc3c5; letter-spacing: 0; }
|
||||
.palette-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 4px; color: #5b6a70; text-align: left; font-size: 11px; }
|
||||
.palette-item:hover { background: #edf6f5; color: var(--teal-dark); }
|
||||
.palette-item.is-dragging { opacity: .42; }
|
||||
.palette-item small { margin-left: auto; color: #adb7ba; font-size: 9px; }
|
||||
.palette-icon { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid #dce8e6; background: #f6fbfa; color: var(--teal); border-radius: 4px; }
|
||||
.palette-divider { height: 1px; background: #edf0f0; margin: 17px 0; }
|
||||
.relation-palette { width: 100%; display: flex; align-items: center; gap: 8px; border: 0; background: transparent; padding: 4px 2px; color: #55666d; text-align: left; }
|
||||
.relation-palette:hover { color: var(--teal-dark); }
|
||||
.relation-icon { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 4px; color: #bd7627; background: #fff4e6; }
|
||||
.relation-palette span:nth-child(2) { flex: 1; }
|
||||
.relation-palette strong { display: block; font-size: 11px; font-weight: 650; }
|
||||
.relation-palette small { display: block; color: #a1abad; font-size: 9px; margin-top: 2px; }
|
||||
.new-field-button { width: 100%; display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 21px; border: 1px dashed #cbd9d8; color: #42847e; background: #f8fcfb; height: 31px; border-radius: 4px; font-size: 10px; font-weight: 650; }
|
||||
.new-field-button:hover { border-color: #83bcb5; background: #edf7f5; }
|
||||
.canvas-heading { align-items: center; }
|
||||
.canvas-tools { display: flex; align-items: center; gap: 4px; }
|
||||
.saved-indicator { display: flex; align-items: center; gap: 5px; color: #859397; font-size: 10px; margin-right: 5px; }
|
||||
.saved-check { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; color: #fff; background: #43a17d; }
|
||||
.canvas-meta { display: flex; align-items: center; gap: 15px; border-bottom: 1px solid #edf0f1; padding: 14px 0 13px; color: #99a4a8; font-size: 10px; }
|
||||
.canvas-meta span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.form-canvas { padding-top: 17px; }
|
||||
.canvas-section { border: 1px solid #dfe6e7; border-radius: 6px; background: #fbfcfc; overflow: hidden; }
|
||||
.canvas-section + .canvas-section { margin-top: 15px; }
|
||||
.section-heading { min-height: 52px; padding: 0 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e8edef; background: #fff; }
|
||||
.section-heading > div:first-child { display: flex; align-items: center; gap: 10px; }
|
||||
.section-heading-actions { display: flex; align-items: center; gap: 9px; }
|
||||
.layout-chip { display: inline-flex; align-items: center; gap: 5px; color: #7d9995; background: #f0f8f6; border: 1px solid #d9ebe8; border-radius: 4px; padding: 4px 6px; font-size: 9px; }
|
||||
.section-heading strong { display: block; font-size: 12px; color: #3b4b52; font-weight: 700; }
|
||||
.section-heading small { display: block; color: #9aa5a9; font-size: 10px; margin-top: 2px; }
|
||||
.section-number { width: 23px; height: 23px; display: grid; place-items: center; color: #398f87; background: #e8f4f2; border-radius: 4px; font-size: 9px; font-weight: 760; }
|
||||
.section-number.warm { color: #b87428; background: #fff1dd; }
|
||||
.section-menu { border: 0; background: transparent; color: #a4afb2; padding: 5px; }
|
||||
.section-menu:hover { color: var(--teal); }
|
||||
.drop-zone { padding: 12px; min-height: 230px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; align-items: stretch; }
|
||||
.field-card { display: flex; align-items: center; gap: 8px; min-height: 61px; padding: 0 10px 0 5px; border: 1px solid #e2e8e9; background: #fff; border-radius: 5px; cursor: pointer; transition: border-color .15s, box-shadow .15s; }
|
||||
.field-card:hover { border-color: #b8d5d1; box-shadow: 0 2px 8px rgba(22, 102, 97, .06); }
|
||||
.field-card.is-selected { border-color: #67aaa2; box-shadow: 0 0 0 2px #e7f4f2; }
|
||||
.field-card.is-dragging, .column-chip.is-dragging { opacity: .48; }
|
||||
.drag-handle, .column-grip { border: 0; background: transparent; color: #bcc6c8; padding: 5px; display: grid; place-items: center; cursor: grab; }
|
||||
.drag-handle:active, .column-grip:active { cursor: grabbing; }
|
||||
.field-card-main { flex: 1; min-width: 0; }
|
||||
.field-card-title { display: flex; align-items: center; gap: 7px; color: #47575e; font-size: 11px; font-weight: 650; }
|
||||
.required-mark { color: #c87940; font-size: 8px; font-weight: 700; }
|
||||
.field-card-meta { display: flex; gap: 9px; align-items: center; margin-top: 4px; color: #a1acae; font-size: 9px; }
|
||||
.field-card-meta code, .selected-object code { color: #77928f; background: #eef7f5; padding: 2px 4px; border-radius: 3px; font-size: 9px; }
|
||||
.visibility-dot { color: #74aba3; display: grid; place-items: center; }
|
||||
.visibility-dot.off { color: #bbc5c6; }
|
||||
.drop-hint { grid-column: 1 / -1; min-height: 38px; border: 1px dashed #c9d9d8; color: #99aaa9; background: #fafdfc; border-radius: 4px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 10px; }
|
||||
.relation-section { background: #fff; }
|
||||
.relation-actions { display: flex; align-items: center; gap: 11px; }
|
||||
.relation-status { display: flex; align-items: center; gap: 5px; color: #6b9d91; font-size: 9px; }
|
||||
.relation-config-line { display: flex; align-items: center; gap: 13px; min-height: 39px; padding: 0 15px; color: #879699; font-size: 10px; border-bottom: 1px solid #edf0f0; }
|
||||
.relation-config-line span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.relation-config-line button { margin-left: auto; border: 0; background: transparent; color: #4a9891; display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
.relation-config-line button:hover { color: var(--teal-dark); }
|
||||
.columns-drop-zone { display: flex; align-items: center; gap: 5px; padding: 12px 13px 7px; overflow-x: auto; }
|
||||
.column-chip { height: 32px; flex: 0 0 auto; display: flex; align-items: center; gap: 4px; border: 1px solid #d9e5e4; background: #f7fbfa; color: #58746f; border-radius: 4px; padding: 0 5px 0 1px; cursor: pointer; font-size: 10px; }
|
||||
.column-chip:hover, .column-chip.is-selected { border-color: #71afa8; background: #ecf7f5; color: #337d76; }
|
||||
.column-chip small { color: #a0b2b0; font-size: 8px; margin-left: auto; }
|
||||
.column-add { height: 32px; min-width: 31px; border: 1px dashed #c7d8d6; color: #86aaa5; display: grid; place-items: center; border-radius: 4px; }
|
||||
.relation-preview-table { margin: 0 13px; border: 1px solid #e3e9e9; border-radius: 4px; overflow: hidden; }
|
||||
.preview-row { min-height: 32px; display: flex; align-items: center; border-top: 1px solid #edf1f1; color: #68797e; font-size: 10px; background: #fff; }
|
||||
.preview-row:first-child { border-top: 0; }
|
||||
.preview-row span { flex: 0 0 auto; padding: 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-row.preview-header { min-height: 30px; color: #8d9a9e; background: #f7f9f9; font-size: 9px; font-weight: 700; }
|
||||
.preview-row .row-actions, .preview-row .row-actions-head { width: 36px; color: #abb7b9; margin-left: auto; }
|
||||
.add-row-button { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: #3c8c84; font-size: 10px; margin: 10px 13px 13px; padding: 2px 0; }
|
||||
.add-row-button:hover { color: var(--teal-dark); }
|
||||
.inspector-panel { min-height: 450px; }
|
||||
.selected-object { display: flex; align-items: center; gap: 8px; background: #f7fafa; border: 1px solid #e1eaea; border-radius: 5px; padding: 11px 10px; margin-top: 16px; }
|
||||
.object-icon { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 4px; }
|
||||
.field-object { color: #3b8e86; background: #e3f2f0; }
|
||||
.table-object { color: #ad6b26; background: #fff0da; }
|
||||
.selected-object > div:nth-child(2) { flex: 1; min-width: 0; }
|
||||
.selected-object strong { display: block; color: #495960; font-size: 11px; }
|
||||
.selected-object code { display: inline-block; margin-top: 4px; }
|
||||
.object-type { align-self: flex-start; color: #a6b0b2; font-size: 8px; letter-spacing: .09em; }
|
||||
.inspector-form { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.inspector-form label { display: flex; flex-direction: column; gap: 6px; color: #7e8e92; font-size: 10px; }
|
||||
.inspector-form input, .select-like, .number-input { height: 32px; border: 1px solid #dce5e5; background: #fff; color: #53646a; border-radius: 4px; padding: 0 9px; outline: none; font-size: 11px; }
|
||||
.inspector-form input:focus, .number-input:focus-within { border-color: #72b2aa; box-shadow: 0 0 0 2px #e8f5f3; }
|
||||
.select-like, .number-input { display: flex; align-items: center; justify-content: space-between; }
|
||||
.number-input input { border: 0; padding: 0; height: 100%; min-width: 0; width: 100%; }
|
||||
.number-input span { color: #a0abad; font-size: 9px; }
|
||||
.switch-row { flex-direction: row !important; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.switch-row span:first-child { display: flex; flex-direction: column; gap: 3px; }
|
||||
.switch-row strong { color: #56666c; font-size: 11px; font-weight: 650; }
|
||||
.switch-row small { color: #a3afb1; font-size: 9px; }
|
||||
.switch { width: 28px; height: 16px; border: 0; background: #d7dfe0; border-radius: 20px; padding: 2px; transition: background .15s; }
|
||||
.switch span { display: block; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.16); }
|
||||
.switch.on { background: #55a49b; }
|
||||
.switch.on span { transform: translateX(12px); }
|
||||
.inspector-divider { height: 1px; background: #edf0f0; margin: 20px 0 14px; }
|
||||
.permission-callout { display: flex; gap: 9px; padding: 11px 10px; background: #f1f8f6; border: 1px solid #d7ebe7; border-radius: 5px; }
|
||||
.permission-callout.amber { background: #fff8ed; border-color: #f2dfbf; }
|
||||
.callout-icon { color: #4b958a; padding-top: 1px; }
|
||||
.permission-callout.amber .callout-icon { color: #ba7628; }
|
||||
.permission-callout strong { color: #4e6968; font-size: 10px; }
|
||||
.permission-callout p { color: #7d9694; margin: 4px 0 7px; font-size: 9px; line-height: 1.45; }
|
||||
.permission-callout button { border: 0; padding: 0; background: transparent; color: #3e8e86; display: inline-flex; align-items: center; gap: 4px; font-size: 9px; }
|
||||
.inspector-note { display: flex; align-items: center; gap: 6px; color: #9ba8ab; font-size: 9px; margin-top: 16px; }
|
||||
.inspector-note b { color: #6c7d82; }
|
||||
.empty-inspector { min-height: 200px; display: grid; place-items: center; align-content: center; gap: 8px; color: #aab5b7; font-size: 11px; text-align: center; }
|
||||
.empty-inspector p { max-width: 150px; line-height: 1.45; }
|
||||
.drag-overlay { min-height: 34px; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: #274e50; color: #fff; border-radius: 4px; box-shadow: 0 7px 20px rgba(21, 65, 64, .25); font-size: 11px; }
|
||||
|
||||
.runtime-shell { max-width: 1050px; margin: 0 auto; }
|
||||
.runtime-topline { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; padding: 17px 0 19px; }
|
||||
.runtime-topline h2 { margin: 7px 0 4px; color: #2d3e44; font-size: 23px; letter-spacing: -.025em; }
|
||||
.runtime-topline p { margin: 0; color: #879499; font-size: 11px; }
|
||||
.runtime-topline p b { color: #4d827a; font-weight: 650; }
|
||||
.runtime-actions { display: flex; align-items: center; gap: 7px; }
|
||||
.runtime-card { background: #fff; border: 1px solid var(--line); border-radius: 7px; box-shadow: var(--shadow); padding: 23px 26px 26px; }
|
||||
.runtime-card-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.runtime-card-head h3 { margin: 5px 0 0; color: #405158; font-size: 14px; font-weight: 720; }
|
||||
.runtime-card-head h3 small { color: #9aa7aa; font-size: 10px; font-weight: 500; margin-left: 6px; }
|
||||
.draft-chip { color: #ba7627; background: #fff2df; border: 1px solid #f1dfc1; border-radius: 4px; padding: 5px 7px; font-size: 9px; letter-spacing: .08em; font-weight: 750; }
|
||||
.runtime-form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 19px 16px; margin-top: 23px; }
|
||||
.runtime-field { display: flex; flex-direction: column; gap: 7px; color: #708087; font-size: 10px; }
|
||||
.runtime-field > span b { color: #c5723e; margin-left: 3px; }
|
||||
.runtime-input { width: 100%; height: 36px; border: 1px solid #dce5e5; border-radius: 4px; padding: 0 10px; outline: none; color: #506169; background: #fff; font-size: 11px; }
|
||||
.runtime-input:focus { border-color: #69ada4; box-shadow: 0 0 0 2px #e8f6f3; }
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
.input-wrap svg { position: absolute; right: 10px; color: #9facaf; pointer-events: none; }
|
||||
.input-wrap .runtime-input { padding-right: 28px; }
|
||||
.runtime-divider { height: 1px; background: #e8edef; margin: 28px 0 22px; }
|
||||
.compact-head { align-items: flex-end; }
|
||||
.runtime-table-wrap { border: 1px solid #e0e7e7; border-radius: 5px; overflow: auto; margin-top: 16px; }
|
||||
.runtime-table { min-width: 780px; width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.runtime-table th, .runtime-table td { text-align: left; border-bottom: 1px solid #edf1f1; padding: 0 10px; height: 40px; font-size: 10px; color: #66777d; }
|
||||
.runtime-table th { background: #f7f9f9; color: #88979b; font-weight: 700; }
|
||||
.runtime-table tr:last-child td { border-bottom: 0; }
|
||||
.runtime-table td input { height: 28px; width: 100%; border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 6px; color: #56696f; font-size: 10px; outline: 0; }
|
||||
.runtime-table td input:focus { border-color: #9dccca; background: #f7fcfb; }
|
||||
.runtime-footnote { display: flex; align-items: center; gap: 7px; margin-top: 13px; color: #85979a; font-size: 10px; }
|
||||
.runtime-footnote svg { color: #4e988d; }
|
||||
.runtime-footnote button { border: 0; background: transparent; color: #3d8c84; font-size: 10px; display: flex; align-items: center; gap: 4px; margin-left: auto; }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(29, 47, 52, .34); z-index: 20; }
|
||||
.help-modal { width: 375px; background: #fff; border-radius: 7px; box-shadow: 0 24px 65px rgba(24, 48, 53, .24); padding: 23px; }
|
||||
.modal-title { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.modal-title h2 { margin: 6px 0 0; font-size: 18px; color: #35464c; }
|
||||
.help-modal > p { margin: 19px 0; color: #75858a; line-height: 1.65; font-size: 12px; }
|
||||
.help-points { display: flex; flex-direction: column; gap: 9px; color: #65847e; font-size: 11px; margin-bottom: 20px; }
|
||||
.help-points span { display: flex; align-items: center; gap: 8px; }
|
||||
.help-points svg { color: #4e9b90; }
|
||||
.release-modal { width: 430px; background: #fff; border-radius: 7px; box-shadow: 0 24px 65px rgba(24, 48, 53, .24); padding: 23px; }
|
||||
.release-list { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; }
|
||||
.release-item { min-height: 57px; display: flex; align-items: center; gap: 9px; border: 1px solid #e2e8e8; border-radius: 5px; padding: 0 11px; }
|
||||
.release-item.current { border-color: #cfe5e1; background: #f5fbfa; }
|
||||
.release-icon { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #51a391; }
|
||||
.release-icon.old { color: #9ca9ac; background: #f0f3f3; }
|
||||
.release-item > div:nth-child(2) { flex: 1; }
|
||||
.release-item strong { display: block; color: #52636a; font-size: 11px; }
|
||||
.release-item span { display: block; color: #9aa8ab; font-size: 9px; margin-top: 3px; }
|
||||
.release-pill { color: #4f9b8e !important; background: #e1f2ee; border-radius: 3px; padding: 4px 5px; font-size: 8px !important; letter-spacing: .07em; font-weight: 750; margin: 0 !important; }
|
||||
.text-button { display: flex; align-items: center; gap: 4px; border: 0; background: transparent; color: #3e8e86; font-size: 10px; }
|
||||
.text-button:hover { color: var(--teal-dark); }
|
||||
.release-note { color: #9aa7aa; line-height: 1.5; font-size: 10px; margin: 16px 1px 0; }
|
||||
.toast { position: fixed; right: 28px; bottom: 25px; z-index: 30; min-width: 315px; max-width: 400px; display: flex; gap: 10px; padding: 13px 15px; background: #27474d; color: #fff; border-radius: 5px; box-shadow: 0 12px 35px rgba(23, 49, 55, .24); }
|
||||
.toast-icon { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; background: #3c9788; flex: 0 0 auto; }
|
||||
.toast strong { display: block; font-size: 11px; font-weight: 700; }
|
||||
.toast span { display: block; color: #b7cccb; font-size: 10px; line-height: 1.4; margin-top: 2px; }
|
||||
|
||||
@media (max-width: 1260px) {
|
||||
body { min-width: 1020px; }
|
||||
.main-content { padding-inline: 20px; }
|
||||
.builder-grid { grid-template-columns: 210px minmax(480px, 1fr) 245px; }
|
||||
.page-head { align-items: flex-start; flex-direction: column; gap: 14px; }
|
||||
.head-actions { align-self: flex-end; }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
useDraggable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Box,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Columns3,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileClock,
|
||||
GripVertical,
|
||||
LayoutDashboard,
|
||||
Layers3,
|
||||
ListFilter,
|
||||
LockKeyhole,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import './App.css'
|
||||
|
||||
type FieldType = 'text' | 'reference' | 'date' | 'select' | 'number'
|
||||
type Field = {
|
||||
id: string
|
||||
label: string
|
||||
key: string
|
||||
type: FieldType
|
||||
required?: boolean
|
||||
visible: boolean
|
||||
helper?: string
|
||||
role?: string
|
||||
}
|
||||
type Column = { id: string; label: string; key: string; width: number; visible: boolean }
|
||||
type Toast = { title: string; message: string }
|
||||
|
||||
const initialFields: Field[] = [
|
||||
{ id: 'booking-no', label: '订舱编号', key: 'booking_no', type: 'text', required: true, visible: true, helper: '系统自动生成', role: '系统字段' },
|
||||
{ id: 'customer', label: '客户', key: 'customer_id', type: 'reference', required: true, visible: true, helper: '选择客户主数据', role: '基础资料' },
|
||||
{ id: 'carrier', label: '船公司', key: 'carrier_id', type: 'reference', required: true, visible: true, helper: '合作伙伴 / 承运人', role: '基础资料' },
|
||||
{ id: 'etd', label: '预计开船日', key: 'etd', type: 'date', required: true, visible: true, role: '业务字段' },
|
||||
{ id: 'pol', label: '起运港', key: 'pol_id', type: 'reference', visible: true, role: '业务字段' },
|
||||
{ id: 'pod', label: '目的港', key: 'pod_id', type: 'reference', visible: true, role: '业务字段' },
|
||||
{ id: 'trade-term', label: '贸易条款', key: 'trade_term', type: 'select', visible: true, role: '字典字段' },
|
||||
{ id: 'notes', label: '操作备注', key: 'notes', type: 'text', visible: true, helper: '内部业务人员可见', role: '扩展字段' },
|
||||
]
|
||||
|
||||
const initialColumns: Column[] = [
|
||||
{ id: 'container-no', label: '箱号', key: 'container_no', width: 156, visible: true },
|
||||
{ id: 'container-type', label: '箱型', key: 'container_type', width: 110, visible: true },
|
||||
{ id: 'weight', label: '毛重 (KG)', key: 'weight', width: 122, visible: true },
|
||||
{ id: 'packages', label: '件数', key: 'packages', width: 90, visible: true },
|
||||
{ id: 'seal-no', label: '封条号', key: 'seal_no', width: 140, visible: true },
|
||||
]
|
||||
|
||||
const fieldTypeLabel: Record<FieldType, string> = {
|
||||
text: '单行文本',
|
||||
reference: '引用选择',
|
||||
date: '日期选择',
|
||||
select: '固定字典',
|
||||
number: '数值',
|
||||
}
|
||||
|
||||
const initialRows = [
|
||||
{ id: '1', container_no: 'MSCU 482913 6', container_type: '40HQ', weight: '12,480', packages: '32', seal_no: 'SL-902183' },
|
||||
{ id: '2', container_no: 'MSCU 482917 0', container_type: '40HQ', weight: '10,260', packages: '26', seal_no: 'SL-902184' },
|
||||
]
|
||||
|
||||
function IconButton({ label, children, onClick, active = false }: { label: string; children: ReactNode; onClick?: () => void; active?: boolean }) {
|
||||
return <button className={`icon-button ${active ? 'is-active' : ''}`} aria-label={label} title={label} onClick={onClick}>{children}</button>
|
||||
}
|
||||
|
||||
function SortableField({ field, selected, onSelect }: { field: Field; selected: boolean; onSelect: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `field:${field.id}`, data: { kind: 'field', id: field.id } })
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition }
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`field-card ${selected ? 'is-selected' : ''} ${isDragging ? 'is-dragging' : ''}`} onClick={onSelect}>
|
||||
<button className="drag-handle" {...attributes} {...listeners} aria-label={`拖动${field.label}`}><GripVertical size={15} /></button>
|
||||
<div className="field-card-main">
|
||||
<div className="field-card-title"><span>{field.label}</span>{field.required && <span className="required-mark">必填</span>}</div>
|
||||
<div className="field-card-meta"><code>{field.key}</code><span>{fieldTypeLabel[field.type]}</span></div>
|
||||
</div>
|
||||
<span className={`visibility-dot ${field.visible ? 'on' : 'off'}`} title={field.visible ? '可见' : '已隐藏'}>{field.visible ? <Eye size={14} /> : <EyeOff size={14} />}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableColumn({ column, selected, onSelect }: { column: Column; selected: boolean; onSelect: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `column:${column.id}`, data: { kind: 'column', id: column.id } })
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition, width: column.width }
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`column-chip ${selected ? 'is-selected' : ''} ${isDragging ? 'is-dragging' : ''}`} onClick={onSelect}>
|
||||
<button className="column-grip" {...attributes} {...listeners} aria-label={`拖动${column.label}`}><GripVertical size={14} /></button>
|
||||
<span>{column.label}</span>
|
||||
<small>{column.width}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaletteField({ field, onAdd }: { field: Field; onAdd: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `palette:${field.id}`, data: { kind: 'palette', field } })
|
||||
const onClick = (event: React.MouseEvent) => { event.stopPropagation(); onAdd() }
|
||||
return (
|
||||
<button ref={setNodeRef} className={`palette-item ${isDragging ? 'is-dragging' : ''}`} {...attributes} {...listeners} onClick={onClick}>
|
||||
<span className="palette-icon"><Plus size={14} /></span><span>{field.label}</span><small>{fieldTypeLabel[field.type]}</small>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [mode, setMode] = useState<'builder' | 'preview'>('builder')
|
||||
const [activeNav, setActiveNav] = useState<'builder' | 'runtime' | 'releases'>('builder')
|
||||
const [fields, setFields] = useState(initialFields)
|
||||
const [columns, setColumns] = useState(initialColumns)
|
||||
const [selected, setSelected] = useState<{ kind: 'field' | 'column'; id: string }>({ kind: 'field', id: 'carrier' })
|
||||
const [toast, setToast] = useState<Toast | null>(null)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showReleases, setShowReleases] = useState(false)
|
||||
const [release, setRelease] = useState(42)
|
||||
const [candidate, setCandidate] = useState(false)
|
||||
const [rows, setRows] = useState(initialRows)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }))
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem('fms-mvp-layout')
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved) as { fields?: Field[]; columns?: Column[]; release?: number }
|
||||
if (data.fields) setFields(data.fields)
|
||||
if (data.columns) setColumns(data.columns)
|
||||
if (data.release) setRelease(data.release)
|
||||
} catch { /* Use the stable fixture when local data is incomplete. */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleFields = useMemo(() => fields.filter((field) => field.visible), [fields])
|
||||
const activeItem = selected.kind === 'field' ? fields.find((field) => field.id === selected.id) : columns.find((column) => column.id === selected.id)
|
||||
|
||||
function notify(title: string, message: string) {
|
||||
setToast({ title, message })
|
||||
window.setTimeout(() => setToast(null), 3200)
|
||||
}
|
||||
|
||||
function saveCandidate() {
|
||||
window.localStorage.setItem('fms-mvp-layout', JSON.stringify({ fields, columns, release }))
|
||||
setCandidate(true)
|
||||
notify('候选版本已保存', '变更已保存到 Release 42 · 候选版本,可进入预览验证。')
|
||||
}
|
||||
|
||||
function publish() {
|
||||
const next = release + 1
|
||||
window.localStorage.setItem('fms-mvp-layout', JSON.stringify({ fields, columns, release: next }))
|
||||
setRelease(next)
|
||||
setCandidate(false)
|
||||
notify('配置已发布', `Release ${next} 已激活,当前租户运行时已刷新。`)
|
||||
}
|
||||
|
||||
function rollback() {
|
||||
setFields(initialFields)
|
||||
setColumns(initialColumns)
|
||||
setCandidate(false)
|
||||
window.localStorage.setItem('fms-mvp-layout', JSON.stringify({ fields: initialFields, columns: initialColumns, release: 41 }))
|
||||
setRelease(41)
|
||||
notify('已回滚配置', '当前运行时已切换回 Release 41。')
|
||||
}
|
||||
|
||||
function addField(field: Field) {
|
||||
if (fields.some((item) => item.id === field.id)) {
|
||||
setSelected({ kind: 'field', id: field.id })
|
||||
return
|
||||
}
|
||||
setFields((current) => [...current, { ...field, visible: true }])
|
||||
setSelected({ kind: 'field', id: field.id })
|
||||
notify('字段已加入表单', `${field.label} 已添加到主表表单。`)
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) { setActiveId(String(event.active.id)) }
|
||||
function handleDragCancel() { setActiveId(null) }
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null)
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const activeData = active.data.current
|
||||
if (activeData?.kind === 'palette') {
|
||||
addField(activeData.field as Field)
|
||||
return
|
||||
}
|
||||
const activeKind = String(active.id).split(':')[0]
|
||||
const overKind = String(over.id).split(':')[0]
|
||||
if (activeKind !== overKind) return
|
||||
if (activeKind === 'field') {
|
||||
const oldIndex = fields.findIndex((item) => `field:${item.id}` === active.id)
|
||||
const newIndex = fields.findIndex((item) => `field:${item.id}` === over.id)
|
||||
if (oldIndex !== -1 && newIndex !== -1) setFields((current) => arrayMove(current, oldIndex, newIndex))
|
||||
}
|
||||
if (activeKind === 'column') {
|
||||
const oldIndex = columns.findIndex((item) => `column:${item.id}` === active.id)
|
||||
const newIndex = columns.findIndex((item) => `column:${item.id}` === over.id)
|
||||
if (oldIndex !== -1 && newIndex !== -1) setColumns((current) => arrayMove(current, oldIndex, newIndex))
|
||||
}
|
||||
}
|
||||
|
||||
function updateField(patch: Partial<Field>) {
|
||||
if (selected.kind !== 'field') return
|
||||
setFields((current) => current.map((field) => field.id === selected.id ? { ...field, ...patch } : field))
|
||||
}
|
||||
|
||||
function updateColumn(patch: Partial<Column>) {
|
||||
if (selected.kind !== 'column') return
|
||||
setColumns((current) => current.map((column) => column.id === selected.id ? { ...column, ...patch } : column))
|
||||
}
|
||||
|
||||
function selectNav(nav: typeof activeNav) {
|
||||
setActiveNav(nav)
|
||||
if (nav === 'builder') { setMode('builder'); return }
|
||||
if (nav === 'runtime') { setMode('preview'); return }
|
||||
setShowReleases(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand-block"><div className="brand-mark">F</div><div><div className="brand-name">FMS <span>/</span> 工作台</div><div className="brand-subtitle">元数据工作台</div></div></div>
|
||||
<div className="breadcrumb"><span>海运</span><ChevronRight size={14} /><strong>订舱</strong><span className="slash">/</span><span className="muted">默认编辑表单</span></div>
|
||||
<div className="topbar-actions"><span className="local-badge"><span className="status-pulse" /> 本地演示</span><IconButton label="帮助" onClick={() => setShowHelp(true)}><CircleHelp size={17} /></IconButton><div className="avatar">YL</div></div>
|
||||
</header>
|
||||
|
||||
<div className="workspace">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section-label">工作区</div>
|
||||
<button className={`nav-item ${activeNav === 'builder' ? 'active' : ''}`} onClick={() => selectNav('builder')}><LayoutDashboard size={17} /><span>表单设计器</span><span className="nav-count">01</span></button>
|
||||
<button className={`nav-item ${activeNav === 'runtime' ? 'active' : ''}`} onClick={() => selectNav('runtime')}><Play size={17} /><span>运行时预览</span></button>
|
||||
<button className={`nav-item ${activeNav === 'releases' ? 'active' : ''}`} onClick={() => selectNav('releases')}><FileClock size={17} /><span>版本历史</span><span className="nav-dot" /></button>
|
||||
<div className="sidebar-divider" />
|
||||
<div className="sidebar-section-label with-action"><span>模块</span><IconButton label="添加模块"><Plus size={14} /></IconButton></div>
|
||||
<div className="module-tree"><button className="tree-item open"><ChevronDown size={14} /><Box size={16} className="tree-icon ocean" /><span>海运</span></button><button className="tree-item child selected"><Table2 size={15} /><span>订舱</span><span className="tree-state">已发布</span></button><button className="tree-item child"><PackageCheck size={15} /><span>海运单</span></button><button className="tree-item child"><Columns3 size={15} /><span>集装箱</span></button><button className="tree-item"><ChevronRight size={14} /><Box size={16} className="tree-icon finance" /><span>财务</span></button><button className="tree-item"><ChevronRight size={14} /><Box size={16} className="tree-icon master" /><span>基础资料</span></button></div>
|
||||
<div className="sidebar-footer"><div className="tenant-row"><div className="tenant-icon"><Database size={16} /></div><div><span className="tenant-label">当前租户</span><strong>北极星物流</strong></div><ChevronDown size={15} /></div><div className="sidebar-footer-links"><span><ShieldCheck size={13} /> 独立业务数据库</span><span>org_northstar</span></div></div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
<div className="page-head"><div><div className="eyebrow"><span className="eyebrow-line" /> 配置 / 实体</div><h1>订舱工作台</h1><p>配置订舱表单和集装箱子表,无需编写代码。</p></div><div className="head-actions"><button className="button subtle" onClick={() => setMode(mode === 'builder' ? 'preview' : 'builder')}><Play size={15} /> {mode === 'builder' ? '预览运行时' : '返回设计器'}</button><button className="button outline" onClick={saveCandidate}><Save size={15} /> 保存候选版本</button><button className="button primary" onClick={publish}><PackageCheck size={15} /> 发布 <ChevronDown size={14} /></button></div></div>
|
||||
<div className="mode-strip"><div className="mode-tabs"><button className={mode === 'builder' ? 'active' : ''} onClick={() => setMode('builder')}><Settings2 size={15} /> 设计器</button><button className={mode === 'preview' ? 'active' : ''} onClick={() => setMode('preview')}><Play size={15} /> 运行时预览</button></div><div className="release-summary"><span className="release-label"><span className="release-dot" /> 当前版本</span><strong>Release {release}</strong><span className="release-divider" /><span>{candidate ? '候选版本已保存' : '所有变更已发布'}</span></div></div>
|
||||
|
||||
{mode === 'builder' ? <DndContext sensors={sensors} onDragStart={handleDragStart} onDragCancel={handleDragCancel} onDragEnd={handleDragEnd}>
|
||||
<div className="builder-grid">
|
||||
<section className="builder-panel palette-panel"><div className="panel-heading"><div><span className="panel-kicker">组件</span><h2>字段面板</h2></div><span className="count-chip">{fields.length} 个字段</span></div><p className="panel-intro">将字段拖入画布,或点击字段将其加入表单。</p><div className="palette-search"><ListFilter size={15} /><input placeholder="搜索字段" /></div><div className="palette-group"><div className="palette-group-title">订舱字段 <span>8</span></div>{fields.map((field) => <PaletteField key={field.id} field={field} onAdd={() => addField(field)} />)}</div><div className="palette-divider" /><div className="palette-group"><div className="palette-group-title">关系 <span>01</span></div><button className="relation-palette"><span className="relation-icon"><Table2 size={15} /></span><span><strong>集装箱</strong><small>一对多 · 可编辑表格</small></span><Plus size={15} /></button></div><button className="new-field-button" onClick={() => notify('字段设计器', 'MVP 中可从字段面板添加标准字段类型。')}><Plus size={16} /> 新建字段</button></section>
|
||||
|
||||
<section className="builder-panel canvas-panel"><div className="panel-heading canvas-heading"><div><span className="panel-kicker">表单 / OCEAN.BOOKING</span><h2>默认编辑表单</h2></div><div className="canvas-tools"><span className="saved-indicator"><span className="saved-check"><Check size={12} /></span> 刚刚保存</span><IconButton label="复制表单"><Copy size={16} /></IconButton><IconButton label="更多操作"><MoreHorizontal size={18} /></IconButton></div></div><div className="canvas-meta"><span><Layers3 size={14} /> 2 个分组</span><span><Columns3 size={14} /> 每行 2 个字段</span><span><Table2 size={14} /> 1 个关系表</span><span><LockKeyhole size={14} /> 已接入权限</span></div><div className="form-canvas"><div className="canvas-section"><div className="section-heading"><div><span className="section-number">01</span><div><strong>订舱信息</strong><small>拖拽字段调整行和列</small></div></div><div className="section-heading-actions"><span className="layout-chip"><Columns3 size={13} /> 2 列布局</span><button className="section-menu"><MoreHorizontal size={17} /></button></div></div><div className="drop-zone"><SortableContext items={fields.map((field) => `field:${field.id}`)} strategy={rectSortingStrategy}>{fields.map((field) => <SortableField key={field.id} field={field} selected={selected.kind === 'field' && selected.id === field.id} onSelect={() => setSelected({ kind: 'field', id: field.id })} />)}</SortableContext><div className="drop-hint"><Plus size={15} /><span>将字段拖到这里,自动放入当前行</span></div></div></div><div className="canvas-section relation-section"><div className="section-heading"><div><span className="section-number warm">02</span><div><strong>集装箱</strong><small>一对多关系 · 可编辑表格</small></div></div><div className="relation-actions"><span className="relation-status"><span className="green-dot" /> 可编辑</span><button className="section-menu"><MoreHorizontal size={17} /></button></div></div><div className="relation-config-line"><span><Database size={14} /> ocean.container</span><span><Columns3 size={14} /> {columns.filter((column) => column.visible).length} 个可见列</span><button onClick={() => notify('子表设置', '子表配置已绑定到 ocean.booking.containers。')}>配置关系 <ArrowRight size={14} /></button></div><div className="columns-drop-zone"><SortableContext items={columns.map((column) => `column:${column.id}`)} strategy={horizontalListSortingStrategy}>{columns.filter((column) => column.visible).map((column) => <SortableColumn key={column.id} column={column} selected={selected.kind === 'column' && selected.id === column.id} onSelect={() => setSelected({ kind: 'column', id: column.id })} />)}</SortableContext><div className="column-add"><Plus size={15} /></div></div><div className="relation-preview-table"><div className="preview-row preview-header">{columns.filter((column) => column.visible).map((column) => <span key={column.id} style={{ width: column.width }}>{column.label}</span>)}<span className="row-actions-head" /></div>{initialRows.slice(0, 2).map((row) => <div className="preview-row" key={row.id}>{columns.filter((column) => column.visible).map((column) => <span key={column.id} style={{ width: column.width }}>{row[column.key as keyof typeof row]}</span>)}<span className="row-actions"><MoreHorizontal size={15} /></span></div>)}</div><button className="add-row-button" onClick={() => notify('子表配置', '运行时可以在此关系区域新增集装箱。')}><Plus size={15} /> 新增集装箱行</button></div></div></section>
|
||||
|
||||
<aside className="builder-panel inspector-panel"><div className="panel-heading"><div><span className="panel-kicker">属性面板</span><h2>配置属性</h2></div><IconButton label="关闭属性"><X size={17} /></IconButton></div>{activeItem ? <>{selected.kind === 'field' ? <><div className="selected-object"><div className="object-icon field-object"><Settings2 size={17} /></div><div><strong>{(activeItem as Field).label}</strong><code>{(activeItem as Field).key}</code></div><span className="object-type">字段</span></div><div className="inspector-form"><label>显示名称<input value={(activeItem as Field).label} onChange={(event) => updateField({ label: event.target.value })} /></label><label>字段类型<div className="select-like"><span>{fieldTypeLabel[(activeItem as Field).type]}</span><ChevronDown size={15} /></div></label><label className="switch-row"><span><strong>在表单中显示</strong><small>仅控制默认布局</small></span><button className={`switch ${((activeItem as Field).visible) ? 'on' : ''}`} onClick={() => updateField({ visible: !(activeItem as Field).visible })}><span /></button></label><label className="switch-row"><span><strong>必填字段</strong><small>保存前校验</small></span><button className={`switch ${((activeItem as Field).required) ? 'on' : ''}`} onClick={() => updateField({ required: !(activeItem as Field).required })}><span /></button></label></div><div className="inspector-divider" /><div className="permission-callout"><div className="callout-icon"><ShieldCheck size={16} /></div><div><strong>已接入权限</strong><p>运行时会按角色检查字段权限。当前字段对<b>操作团队</b>可见。</p><button onClick={() => notify('字段权限', '已打开 auth_field_policy 配置。')}>编辑字段权限 <ArrowRight size={13} /></button></div></div><div className="inspector-note"><LockKeyhole size={14} /><span>后端写入模式:<b>{(activeItem as Field).id === 'booking-no' ? 'system' : 'direct'}</b></span></div></> : <><div className="selected-object"><div className="object-icon table-object"><Table2 size={17} /></div><div><strong>{(activeItem as Column).label}</strong><code>{(activeItem as Column).key}</code></div><span className="object-type">列</span></div><div className="inspector-form"><label>列名称<input value={(activeItem as Column).label} onChange={(event) => updateColumn({ label: event.target.value })} /></label><label>列宽 <div className="number-input"><input type="number" value={(activeItem as Column).width} onChange={(event) => updateColumn({ width: Number(event.target.value) })} /><span>px</span></div></label><label className="switch-row"><span><strong>在表格中显示</strong><small>布局之后仍会检查角色权限</small></span><button className={`switch ${((activeItem as Column).visible) ? 'on' : ''}`} onClick={() => updateColumn({ visible: !(activeItem as Column).visible })}><span /></button></label></div><div className="inspector-divider" /><div className="permission-callout amber"><div className="callout-icon"><Eye size={16} /></div><div><strong>导出权限</strong><p>列可以在表格中显示,同时限制导出权限。</p><button onClick={() => notify('列权限', '导出权限配置入口已准备。')}>配置权限 <ArrowRight size={13} /></button></div></div></>}</> : <div className="empty-inspector"><Settings2 size={22} /><p>选择字段或列查看配置。</p></div>}</aside>
|
||||
</div><DragOverlay>{activeId ? <div className="drag-overlay"><GripVertical size={15} /> {activeId.split(':')[1]}</div> : null}</DragOverlay>
|
||||
</DndContext> : <RuntimePreview fields={visibleFields} columns={columns.filter((column) => column.visible)} rows={rows} setRows={setRows} onBack={() => setMode('builder')} onNotify={notify} />}
|
||||
</main>
|
||||
</div>
|
||||
{showHelp && <div className="modal-backdrop" onClick={() => setShowHelp(false)}><div className="help-modal" onClick={(event) => event.stopPropagation()}><div className="modal-title"><div><span className="panel-kicker">关于本演示</span><h2>元数据驱动的配置流程</h2></div><IconButton label="关闭" onClick={() => setShowHelp(false)}><X size={17} /></IconButton></div><p>这是一个不接后端的 FMS 配置运行时演示。左侧字段可以拖入表单,中间区域支持排序,主子表列和运行时预览共用同一份本地元数据。</p><div className="help-points"><span><Check size={14} /> 候选版本</span><span><Check size={14} /> 主子表单</span><span><Check size={14} /> 字段权限</span></div><button className="button primary full" onClick={() => setShowHelp(false)}>知道了</button></div></div>}
|
||||
{showReleases && <div className="modal-backdrop" onClick={() => setShowReleases(false)}><div className="release-modal" onClick={(event) => event.stopPropagation()}><div className="modal-title"><div><span className="panel-kicker">配置历史</span><h2>版本历史</h2></div><IconButton label="关闭" onClick={() => setShowReleases(false)}><X size={17} /></IconButton></div><div className="release-list"><div className="release-item current"><div className="release-icon"><Check size={15} /></div><div><strong>Release {release}</strong><span>当前版本 · 刚刚激活</span></div><span className="release-pill">当前</span></div><div className="release-item"><div className="release-icon old"><FileClock size={15} /></div><div><strong>Release 41</strong><span>上一版本 · 2 小时前</span></div><button className="text-button" onClick={rollback}><RotateCcw size={13} /> 回滚</button></div></div><p className="release-note">回滚会将本地演示切换到上一份元数据快照,不会影响生产数据。</p></div></div>}
|
||||
{toast && <div className="toast"><div className="toast-icon"><Check size={15} /></div><div><strong>{toast.title}</strong><span>{toast.message}</span></div></div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimePreview({ fields, columns, rows, setRows, onBack, onNotify }: { fields: Field[]; columns: Column[]; rows: typeof initialRows; setRows: React.Dispatch<React.SetStateAction<typeof initialRows>>; onBack: () => void; onNotify: (title: string, message: string) => void }) {
|
||||
const addRow = () => { setRows((current) => [...current, { id: String(current.length + 1), container_no: `MSCU 48292${current.length}`, container_type: '40HQ', weight: '—', packages: '—', seal_no: '—' }]); onNotify('子表行已新增', '新的集装箱行已加入当前草稿。') }
|
||||
return <section className="runtime-shell"><div className="runtime-topline"><div><span className="eyebrow"><span className="eyebrow-line" /> 运行时预览 / 候选版本</span><h2>新建订舱</h2><p>当前以<b>操作团队</b>身份预览 Release 42 · 候选版本。</p></div><div className="runtime-actions"><button className="button subtle" onClick={onBack}><ArrowLeft size={15} /> 返回设计器</button><button className="button outline" onClick={() => onNotify('草稿已保存', '模拟保存成功,尚未触发业务动作。')}><Save size={15} /> 保存草稿</button><button className="button primary" onClick={() => onNotify('动作已执行', '确认订舱需要满足 status = draft。')}><Check size={15} /> 确认订舱</button></div></div><div className="runtime-card"><div className="runtime-card-head"><div><span className="panel-kicker">OCEAN.BOOKING</span><h3>订舱信息</h3></div><span className="draft-chip">草稿</span></div><div className="runtime-form-grid">{fields.slice(0, 6).map((field) => <label className="runtime-field" key={field.id}><span>{field.label}{field.required && <b>*</b>}</span>{renderRuntimeInput(field)}</label>)}</div><div className="runtime-divider" /><div className="runtime-card-head compact-head"><div><span className="panel-kicker">关系 / OCEAN.CONTAINER</span><h3>集装箱 <small>{rows.length} 行</small></h3></div><button className="button small outline" onClick={addRow}><Plus size={14} /> 新增集装箱</button></div><div className="runtime-table-wrap"><table className="runtime-table"><thead><tr>{columns.map((column) => <th key={column.id} style={{ width: column.width }}>{column.label}</th>)}<th aria-label="操作" /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}>{columns.map((column) => <td key={column.id}><input value={row[column.key as keyof typeof row]} onChange={(event) => setRows((current) => current.map((item) => item.id === row.id ? { ...item, [column.key]: event.target.value } : item))} /></td>)}<td><IconButton label="更多行操作"><MoreHorizontal size={16} /></IconButton></td></tr>)}</tbody></table></div></div><div className="runtime-footnote"><ShieldCheck size={15} /><span>字段可见性和导出权限由当前用户的权限快照统一决定。</span><button onClick={() => onNotify('权限解释', '操作团队可以查看所有订舱字段,导出权限单独控制。')}>解释权限 <ArrowRight size={13} /></button></div></section>
|
||||
}
|
||||
|
||||
function renderRuntimeInput(field: Field) {
|
||||
if (field.type === 'reference') return <div className="input-wrap"><input className="runtime-input" value={field.id === 'carrier' ? '长荣海运' : field.id === 'customer' ? '北极星贸易' : '请选择'} readOnly /><ChevronDown size={15} /></div>
|
||||
if (field.type === 'date') return <input className="runtime-input" value="2026-09-18" readOnly />
|
||||
if (field.type === 'select') return <div className="input-wrap"><input className="runtime-input" value="FOB" readOnly /><ChevronDown size={15} /></div>
|
||||
return <input className="runtime-input" value={field.id === 'booking-no' ? 'BK-2026-0918-0084' : ''} placeholder={field.label} readOnly={field.id === 'booking-no'} />
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #26313c;
|
||||
background: #eef1f2;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 1120px; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
#root { min-height: 100vh; }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
Reference in new issue
Block a user