20260812221200

This commit is contained in:
oneao committed 2026-08-12 22:12:01 +08:00
1 parent 87b2d3c482
commit 4bd730f649
47 files changed
+6286 -1881

No files matched your search

+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+82
View File
@@ -0,0 +1,82 @@
# AI Context — 模块设计器原型
## 项目目标
这是一个 **FMS 模块管理系统的"字段设计器"交互原型**,用 React 实现,用于验证设计思路和交互方案。
解决的问题:原有系统将字段的列表、编辑、查询配置分散在 4 个 Tab 中,操作碎片化。目标是把所有配置围绕"字段"聚合,所见即所得。
## 技术栈
| 项 | 值 |
|---|-----|
| 框架 | React 19 + TypeScript 6 |
| 构建 | Vite 8 |
| 样式 | Tailwind CSS v4 (`@tailwindcss/vite`) |
| 拖拽 | `@dnd-kit/core` + `@dnd-kit/sortable` |
| 包管理 | pnpm |
## 文件结构
```
src/
├── types/index.ts # 所有 TS 类型定义 + FieldTypeDef 常量
├── data/moduleData.ts # Mock 数据 + 工厂函数
├── components/
│ ├── ModuleSettings.tsx # 顶部模块设置面板(编码/名称/编辑模式等)
│ ├── FormPreview.tsx # 左侧:表单预览(按分组排列,可拖拽排序)
│ ├── TablePreview.tsx # 左侧:表格预览(可拖拽列排序 + 列宽调整)
│ ├── FieldConfigList.tsx # 右侧:字段配置列表(L/E/Q 拨动开关 + 展开快捷编辑)
│ └── DataSourceDrawer.tsx # 下拉数据源配置抽屉(视图/静态选项)
├── App.tsx # 主布局 + 全局状态
├── main.tsx # 入口
└── index.css # Tailwind + 自定义 CSS(toggle、drawer 等)
```
## 核心设计理念
1. **两栏布局**:左侧实时预览(表单/表格切换),右侧字段配置列表
2. **零点击高频操作**:列表/编辑/查询开关做成拨动开关(toggle),直接拨不用展开
3. **内联快捷编辑**:点击字段行 → 下方展开标签/编码/类型/分组/列宽等基础配置
4. **拖拽排序**:表单模式和表格模式都支持拖拽调整字段顺序
5. **表格列宽调整**:表格预览下拖拽列头右边缘可调整列宽
6. **数据源配置**:下拉字段才有的复杂配置,用右侧抽屉单独处理
## 数据结构
### ModuleField(核心)
```ts
{
id: number, code: string, label: string, type: string,
groupId: string | null, order: number,
// 列表
listOn: boolean, listW: number, listAlign: "left"|"center"|"right",
// 编辑
editOn: boolean, editReq: boolean, editRO: boolean,
// 查询
queryOn: boolean, queryOp: string, queryDef: string,
// 数据源(下拉字段专用)
ds: { type:"view"|"static", ... } | null
}
```
### FieldGroup
```ts
{ id: string, name: string }
```
## 当前状态
- [ ] 基本功能:模块设置、表单预览、表格预览、字段配置列表、数据源抽屉 均已实现
- [ ] 无 lint 错误
- [ ] 表单预览可拖拽排序
- [ ] 表格预览可拖拽列排序 + 列宽调整
- [ ] 右侧 L/E/Q 拨动开关正常
- [x] 安装依赖 `pnpm install`
- [x] 启动 `pnpm dev`(端口 3000)
## 注意事项
- `@dnd-kit/modifiers` 不可用,用本地 `Modifier` 函数替代了 `restrictToVerticalAxis` / `restrictToHorizontalAxis`
- 所有样式在 `src/index.css` 中,使用 class 名而非 Tailwind 原子类的地方(toggle、drawer、field-row 等)是因为这些需要状态驱动的复杂样式
- 类型定义在 `src/types/index.ts`,修改字段结构时记得同步更新 `src/data/moduleData.ts` 中的 `makeField()` 工厂函数
+32
View File
@@ -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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<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</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "test-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/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@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",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}
+1209
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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

+24
View File
@@ -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

+151
View File
@@ -0,0 +1,151 @@
import { useState, useCallback, useRef } from "react";
import type { ModuleField, FieldGroup, EditMode, FieldDataSource } from "./types";
import { defaultFields, defaultGroups, makeField } from "./data/moduleData";
import ModuleSettings from "./components/ModuleSettings";
import FieldConfigList from "./components/FieldConfigList";
import DataSourceDrawer from "./components/DataSourceDrawer";
export default function App() {
const [fields, setFields] = useState<ModuleField[]>(defaultFields);
const [groups, setGroups] = useState<FieldGroup[]>(defaultGroups);
const [editMode, setEditMode] = useState<EditMode>("form");
const [activeId, setActiveId] = useState<number | null>(null);
const [dsDrawerFieldId, setDsDrawerFieldId] = useState<number | null>(null);
const nextGroupId = useRef(10);
const dsDrawerField = dsDrawerFieldId != null ? fields.find((f) => f.id === dsDrawerFieldId) ?? null : null;
// ======== Field mutations ========
const updateField = useCallback((id: number, key: string, value: string | number) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, [key]: value } : f)));
}, []);
const toggleField = useCallback((id: number, key: string) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, [key]: !(f as any)[key] } : f)));
}, []);
const onTypeChange = useCallback((id: number, newType: string) => {
setFields((prev) =>
prev.map((f) => {
if (f.id !== id) return f;
let ds = f.ds;
if (newType === "select" && !ds) {
ds = { type: "view", view: "", vf: "b_id", lf: "b_name" };
}
if (newType !== "select") ds = null;
return { ...f, type: newType, ds };
})
);
}, []);
const addField = useCallback(() => {
const firstGroup = groups[0]?.id ?? null;
const order = (fields.length + 1) * 10;
const f = makeField(firstGroup, "input", "新字段");
f.order = order;
setFields((prev) => [...prev, f]);
setActiveId(f.id);
}, [fields.length, groups]);
const deleteField = useCallback(
(id: number) => {
const f = fields.find((f) => f.id === id);
if (!f || !window.confirm(`删除「${f.label}」(${f.code})?`)) return;
setFields((prev) => prev.filter((f) => f.id !== id));
if (activeId === id) setActiveId(null);
},
[fields, activeId]
);
// ======== Drag reorder ========
const reorderFields = useCallback((activeId: number, overId: number) => {
setFields((prev) => {
const src = prev.find((f) => f.id === activeId);
const tgt = prev.find((f) => f.id === overId);
if (!src || !tgt) return prev;
const tmp = src.order;
return prev.map((f) => {
if (f.id === activeId) return { ...f, order: tgt.order };
if (f.id === overId) return { ...f, order: tmp };
return f;
});
});
}, []);
// ======== Data source ========
const updateDs = useCallback((ds: FieldDataSource | null) => {
if (dsDrawerFieldId == null) return;
setFields((prev) => prev.map((f) => (f.id === dsDrawerFieldId ? { ...f, ds } : f)));
}, [dsDrawerFieldId]);
// ======== Group ========
const addGroup = useCallback(() => {
const name = window.prompt("分组名称:");
if (!name) return;
const id = `g${nextGroupId.current++}`;
setGroups((prev) => [...prev, { id, name }]);
}, []);
const renameGroup = useCallback((id: string) => {
const g = groups.find((g) => g.id === id);
if (!g) return;
const name = window.prompt("新名称:", g.name);
if (name) {
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name } : g)));
}
}, [groups]);
const activateField = useCallback(
(id: number) => {
setActiveId((prev) => (prev === id ? null : id));
},
[]
);
return (
<div className="min-h-screen flex flex-col">
<div className="flex-1 flex flex-col max-w-[1280px] mx-auto w-full p-5 gap-4">
{/* Module Settings */}
<ModuleSettings editMode={editMode} onEditModeChange={setEditMode} />
{/* Grouped field table (single column) */}
<div className="card flex flex-col min-h-0 flex-1">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-100 shrink-0">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
字段配置 <span className="text-slate-300 font-normal ml-1">{fields.length}</span>
</span>
<div className="flex items-center gap-1.5">
<button className="btn btn-primary text-[10px] py-1 px-2.5" onClick={addField}>
+ 新增
</button>
<button className="btn btn-ghost text-[10px] py-1 px-2" onClick={addGroup}>
+ 分组
</button>
</div>
</div>
<FieldConfigList
fields={fields}
groups={groups}
activeId={activeId}
onActivate={activateField}
onToggle={toggleField}
onUpdate={updateField}
onTypeChange={onTypeChange}
onReorder={reorderFields}
onGroupRename={renameGroup}
onDelete={deleteField}
onOpenDs={setDsDrawerFieldId}
/>
</div>
</div>
{/* Data Source Drawer */}
<DataSourceDrawer
field={dsDrawerField}
open={dsDrawerFieldId != null}
onClose={() => setDsDrawerFieldId(null)}
onUpdate={updateDs}
/>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

+1
View File
@@ -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,122 @@
import type { ModuleField, FieldDataSource } from "../types";
interface Props {
field: ModuleField | null;
open: boolean;
onClose: () => void;
onUpdate: (ds: FieldDataSource | null) => void;
}
export default function DataSourceDrawer({ field, open, onClose, onUpdate }: Props) {
if (!field) return null;
const ds = field.ds;
function switchType(type: "view" | "static" | "none") {
if (type === "none") {
onUpdate(null);
} else if (type === "view") {
onUpdate({ type: "view", view: "", vf: "b_id", lf: "b_name", ob: "b_xh ASC" });
} else {
onUpdate({ type: "static", items: [{ v: "", l: "" }] });
}
}
return (
<>
<div className={`drawer-mask ${open ? "open" : ""}`} onClick={onClose} />
<div className={`drawer ${open ? "open" : ""}`}>
<div className="drawer-head">
<h3 className="text-sm font-semibold text-slate-700">下拉数据源</h3>
<button className="drawer-close" onClick={onClose}>×</button>
</div>
<div className="drawer-body">
<div className="space-y-4">
{/* Type selector */}
<div className="seg">
<button onClick={() => switchType("view")} className={`seg-btn ${ds?.type === "view" ? "active" : ""}`}>
视图数据
</button>
<button onClick={() => switchType("static")} className={`seg-btn ${ds?.type === "static" ? "active" : ""}`}>
静态选项
</button>
<button onClick={() => switchType("none")} className={`seg-btn ${!ds ? "active" : ""}`}>
无
</button>
</div>
{!ds && <p className="text-xs text-slate-300 text-center mt-8">该字段当前无数据源</p>}
{ds?.type === "view" && (
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3 space-y-3">
<div>
<label className="text-[11px] text-slate-500 block mb-1">数据视图</label>
<select className="select-sm bg-white" defaultValue={ds.view ?? "v_carrier"}>
<option>v_carrier</option>
<option>v_port</option>
<option>v_partner</option>
<option>v_container</option>
</select>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-slate-500 block mb-1">值字段</label>
<select className="select-sm bg-white" defaultValue={ds.vf ?? "b_id"}>
<option>b_id</option>
<option>b_code</option>
<option>b_name</option>
</select>
</div>
<div>
<label className="text-[11px] text-slate-500 block mb-1">标签字段</label>
<select className="select-sm bg-white" defaultValue={ds.lf ?? "b_name"}>
<option>b_name</option>
<option>b_code</option>
<option>b_id</option>
</select>
</div>
</div>
<div>
<label className="text-[11px] text-slate-500 block mb-1">排序</label>
<input className="input-sm bg-white" defaultValue={ds.ob ?? ""} placeholder="b_xh ASC" />
</div>
<div className="bg-white rounded-lg border border-slate-100 overflow-hidden">
<div className="text-[11px] text-slate-400 px-2.5 py-1.5 bg-slate-50 border-b border-slate-100">预览</div>
<div className="text-[11px] text-slate-500 px-2.5 py-1 border-b border-slate-50">1 → MAERSK</div>
<div className="text-[11px] text-slate-500 px-2.5 py-1 border-b border-slate-50">2 → MSC</div>
<div className="text-[11px] text-slate-500 px-2.5 py-1">3 → CMA CGM</div>
</div>
</div>
)}
{ds?.type === "static" && (
<div className="bg-purple-50 border border-purple-100 rounded-lg p-3 space-y-2">
{ds.items?.map((it, i) => (
<div key={i} className="flex items-center gap-1.5">
<input className="input-sm flex-1 bg-white" defaultValue={it.v} placeholder="值" />
<span className="text-slate-300 text-[10px]">→</span>
<input className="input-sm flex-1 bg-white" defaultValue={it.l} placeholder="标签" />
<button className="text-slate-300 hover:text-red-400 shrink-0 p-0.5">✕</button>
</div>
))}
<button className="text-[11px] text-blue-600 hover:text-blue-700 flex items-center gap-1 mt-1 transition-colors">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
添加选项
</button>
</div>
)}
</div>
</div>
<div className="drawer-foot">
<button onClick={onClose} className="btn btn-primary w-full justify-center">
完成
</button>
</div>
</div>
</>
);
}
@@ -0,0 +1,388 @@
import { useState, Fragment } from "react";
import type { MouseEvent, ReactNode } from "react";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import type { DragEndEvent, Modifier } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ModuleField, FieldGroup } from "../types";
import { TYPE_DEFS } from "../types";
export interface FieldListProps {
fields: ModuleField[];
groups: FieldGroup[];
activeId: number | null;
onActivate: (id: number) => void;
onToggle: (id: number, key: string) => void;
onUpdate: (id: number, key: string, value: any) => void;
onTypeChange: (id: number, type: string) => void;
onReorder: (activeId: number, overId: number) => void;
onGroupRename: (id: string) => void;
onDelete: (id: number) => void;
onOpenDs: (id: number) => void;
}
const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0 });
type EditKey = "label" | "code";
/* ---- 字段行:单行表格 + 内联编辑/开关,可拖拽 ---- */
function SortableFieldRow({
field,
active,
onActivate,
onToggle,
onUpdate,
onTypeChange,
onDelete,
groups,
editing,
onEdit,
onStopEdit,
}: {
field: ModuleField;
active: boolean;
onActivate: () => void;
onToggle: (id: number, key: string) => void;
onUpdate: (id: number, key: string, value: any) => void;
onTypeChange: (id: number, type: string) => void;
onDelete: (id: number) => void;
editing: EditKey | null;
onEdit: (key: EditKey) => void;
onStopEdit: () => void;
groups: FieldGroup[];
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
const style: any = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.9 : 1,
background: isDragging ? "#fff" : undefined,
boxShadow: isDragging ? "0 4px 12px rgba(0,0,0,0.1)" : undefined,
};
const typeDef = TYPE_DEFS.find((t) => t.v === field.type);
const stop = (e: MouseEvent) => e.stopPropagation();
const toggleCell = (key: string, color: string, title: string) => (
<td className="text-center" onClick={stop} title={title}>
<div className="flex items-center justify-center gap-1 cursor-pointer" onClick={() => onToggle(field.id, key)}>
<div className={`toggle ${field[key as keyof ModuleField] ? "on" : ""}`} />
<span className={`text-[9px] select-none ${field[key as keyof ModuleField] ? color : "text-slate-300"}`}>{title[0]}</span>
</div>
</td>
);
return (
<tr
ref={setNodeRef}
style={style}
className={`field-row border-b border-slate-100 hover:bg-slate-50/70 ${active ? "active" : ""}`}
onClick={onActivate}
>
<td className="w-8 text-center" onClick={stop}>
<span className="drag-handle inline-flex" {...attributes} {...listeners}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" />
</svg>
</span>
</td>
<td className="min-w-[150px]" onClick={editing === "label" ? stop : undefined} onDoubleClick={() => onEdit("label")}>
{editing === "label" ? (
<input
autoFocus
className="input-sm !py-1"
value={field.label}
onChange={(e) => onUpdate(field.id, "label", e.target.value)}
onBlur={onStopEdit}
onKeyDown={(e) => e.key === "Enter" && onStopEdit()}
/>
) : (
<span className="flex items-center gap-2">
<span className="w-4 h-4 rounded flex items-center justify-center text-[9px] text-white flex-shrink-0" style={{ background: typeDef?.c ?? "#94a3b8" }}>
{typeDef?.icon ?? "T"}
</span>
<span className="text-xs font-medium text-slate-700 truncate">{field.label}</span>
</span>
)}
</td>
<td className="min-w-[120px] text-slate-400" onClick={editing === "code" ? stop : undefined} onDoubleClick={() => onEdit("code")}>
{editing === "code" ? (
<input
autoFocus
className="input-sm !py-1 font-mono"
value={field.code}
onChange={(e) => onUpdate(field.id, "code", e.target.value)}
onBlur={onStopEdit}
onKeyDown={(e) => e.key === "Enter" && onStopEdit()}
/>
) : (
<code className="text-[10px]">{field.code}</code>
)}
</td>
<td className="min-w-[100px]" onClick={stop}>
<select className="select-sm !py-1" value={field.type} onChange={(e) => onTypeChange(field.id, e.target.value)}>
{TYPE_DEFS.map((t) => (
<option key={t.v} value={t.v}>
{t.lbl}
</option>
))}
</select>
</td>
<td className="min-w-[120px]" onClick={stop}>
<select className="select-sm !py-1" value={field.groupId ?? ""} onChange={(e) => onUpdate(field.id, "groupId", e.target.value || null)}>
<option value="">未分组</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</td>
{toggleCell("listOn", "text-green-600", "列表")}
{toggleCell("editOn", "text-blue-600", "编辑")}
{toggleCell("queryOn", "text-amber-600", "查询")}
{toggleCell("editReq", "text-red-500", "必填")}
{toggleCell("editRO", "text-slate-400", "只读")}
<td className="w-12 text-center" onClick={stop}>
<button className="text-slate-300 hover:text-red-500 transition-colors text-sm leading-none" onClick={() => onDelete(field.id)} title="删除">
×
</button>
</td>
</tr>
);
}
/* ---- 选中字段时就地展开的高级属性行 ---- */
function DetailRow({
field,
onUpdate,
onOpenDs,
}: {
field: ModuleField;
onUpdate: (id: number, key: string, value: any) => void;
onOpenDs: (id: number) => void;
}) {
return (
<tr className="bg-blue-50/40">
<td colSpan={11} className="px-4 py-3 border-b border-slate-100">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">列宽</span>
<input
type="range"
min={40}
max={400}
value={field.listW}
className="w-32 accent-blue-600 h-1"
onChange={(e) => onUpdate(field.id, "listW", parseInt(e.target.value))}
/>
<span className="text-[11px] text-slate-400 w-10">{field.listW}px</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">查询默认值</span>
<input className="input-sm !w-36" value={field.queryDef} onChange={(e) => onUpdate(field.id, "queryDef", e.target.value)} />
</div>
{field.queryOn && (
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">查询方式</span>
<select className="select-sm !w-20" value={field.queryOp} onChange={(e) => onUpdate(field.id, "queryOp", e.target.value)}>
<option value="=">=</option>
<option value="like">包含</option>
<option value="in">区间</option>
<option value=">">&gt;</option>
<option value="<">&lt;</option>
</select>
</div>
)}
{field.type === "select" && (
<button className="btn btn-outline text-[11px]" onClick={() => onOpenDs(field.id)}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
数据源
</button>
)}
</div>
</td>
</tr>
);
}
export default function FieldConfigList(props: FieldListProps) {
const { fields, groups, activeId, onActivate, onToggle, onUpdate, onTypeChange, onReorder, onGroupRename, onDelete, onOpenDs } = props;
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [editing, setEditing] = useState<{ id: number; key: EditKey } | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const sorted = [...fields].sort((a, b) => a.order - b.order);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorder(Number(active.id), Number(over.id));
}
}
const ungrouped = sorted.filter((f) => !groups.some((g) => g.id === f.groupId));
const allIds = [...groups.flatMap((g) => sorted.filter((f) => f.groupId === g.id).map((f) => f.id)), ...ungrouped.map((f) => f.id)];
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
<SortableContext items={allIds} strategy={verticalListSortingStrategy}>
<div className="flex-1 overflow-auto thin-scroll">
<table className="w-full min-w-[760px] border-collapse text-left">
<thead className="sticky top-0 z-10 bg-white/95 backdrop-blur">
<tr className="text-[10px] font-semibold uppercase tracking-wide text-slate-400 border-b border-slate-200">
<th className="w-8 px-1 py-2 text-center"></th>
<th className="px-2 py-2">名称</th>
<th className="px-2 py-2">编码</th>
<th className="px-2 py-2">类型</th>
<th className="px-2 py-2">分组</th>
<th className="px-1 py-2 text-center">列表</th>
<th className="px-1 py-2 text-center">编辑</th>
<th className="px-1 py-2 text-center">查询</th>
<th className="px-1 py-2 text-center">必填</th>
<th className="px-1 py-2 text-center">只读</th>
<th className="w-12 px-1 py-2 text-center">操作</th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
const gfs = sorted.filter((f) => f.groupId === g.id);
const isCollapsed = collapsed[g.id];
const hasActive = gfs.some((f) => f.id === activeId);
return (
<GroupSection
key={g.id}
name={g.name}
count={gfs.length}
collapsed={isCollapsed}
active={hasActive}
onToggle={() => setCollapsed((c) => ({ ...c, [g.id]: !c[g.id] }))}
onRename={() => onGroupRename(g.id)}
>
{!isCollapsed &&
( gfs.length ? (
gfs.map((f) => (
<Fragment key={f.id}>
<SortableFieldRow
field={f}
active={f.id === activeId}
onActivate={() => onActivate(f.id)}
onToggle={onToggle}
onUpdate={onUpdate}
onTypeChange={onTypeChange}
onDelete={onDelete}
groups={groups}
editing={editing?.id === f.id ? editing.key : null}
onEdit={(key) => setEditing({ id: f.id, key })}
onStopEdit={() => setEditing(null)}
/>
{f.id === activeId && <DetailRow field={f} onUpdate={onUpdate} onOpenDs={onOpenDs} />}
</Fragment>
))
) : (
<tr>
<td colSpan={11} className="px-3 py-2 text-[10px] text-slate-300">
该分组暂无字段
</td>
</tr>
))}
</GroupSection>
);
})}
{ungrouped.length > 0 && (
<GroupSection name="未分组" count={ungrouped.length} collapsed={false} active={false} onToggle={() => {}} onRename={() => {}}>
{ungrouped.map((f) => (
<Fragment key={f.id}>
<SortableFieldRow
field={f}
active={f.id === activeId}
onActivate={() => onActivate(f.id)}
onToggle={onToggle}
onUpdate={onUpdate}
onTypeChange={onTypeChange}
onDelete={onDelete}
groups={groups}
editing={editing?.id === f.id ? editing.key : null}
onEdit={(key) => setEditing({ id: f.id, key })}
onStopEdit={() => setEditing(null)}
/>
{f.id === activeId && <DetailRow field={f} onUpdate={onUpdate} onOpenDs={onOpenDs} />}
</Fragment>
))}
</GroupSection>
)}
{sorted.length === 0 && (
<tr>
<td colSpan={11} className="text-center text-xs text-slate-300 py-16">
暂无字段
</td>
</tr>
)}
</tbody>
</table>
</div>
</SortableContext>
</DndContext>
);
}
/* ---- 分组表头行 ---- */
function GroupSection({
name,
count,
collapsed,
active,
onToggle,
onRename,
children,
}: {
name: string;
count: number;
collapsed: boolean;
active: boolean;
onToggle: () => void;
onRename: () => void;
children: ReactNode;
}) {
const isUngrouped = name === "未分组";
return (
<>
<tr
className={`group-block ${active ? "highlight-group" : ""} ${isUngrouped ? "bg-amber-50/40" : "bg-slate-50/70"} cursor-pointer select-none`}
onClick={onToggle}
>
<td colSpan={11} className="px-3 py-1.5">
<div className="flex items-center gap-2">
<svg className={`w-3.5 h-3.5 text-slate-400 transition-transform ${collapsed ? "-rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 9l6 6 6-6" />
</svg>
<span className="text-[12px] font-semibold text-slate-600">{name}</span>
<span className="text-[10px] text-slate-300">{count}字段</span>
{!isUngrouped && (
<button
className="ml-auto text-[10px] text-slate-300 hover:text-slate-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
改名
</button>
)}
</div>
</td>
</tr>
{children}
</>
);
}
@@ -0,0 +1,164 @@
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import type { DragEndEvent, Modifier } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0 });
import type { ModuleField, FieldGroup } from "../types";
import { TYPE_DEFS } from "../types";
interface Props {
fields: ModuleField[];
groups: FieldGroup[];
activeId: number | null;
onActivate: (id: number) => void;
onReorder: (activeId: number, overId: number) => void;
onGroupRename: (id: string) => void;
}
function PreviewWidget({ field, active }: { field: ModuleField; active: boolean }) {
const ring = active ? "ring-2 ring-blue-300" : "";
switch (field.type) {
case "select":
return (
<div className="flex-1 max-w-[260px]">
<div className={`input-sm bg-white text-slate-400 flex items-center justify-between ${ring}`}>
<span>请选择...</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
);
case "date":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}`}>2024-08-12</div>;
case "datetime":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}`}>2024-08-12 14:30</div>;
case "number":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] text-right ${ring}`}>0.00</div>;
case "switch":
return <div className={`toggle ${ring}`} />;
case "textarea":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] h-16 ${ring}`} />;
default:
return (
<div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}`}>
{field.editRO && "只读"}
</div>
);
}
}
function SortableField({
field,
active,
onActivate,
}: {
field: ModuleField;
active: boolean;
onActivate: (id: number) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
const typeDef = TYPE_DEFS.find((t) => t.v === field.type);
const tags: string[] = [];
if (field.editReq) tags.push("*必填");
if (field.editRO) tags.push("只读");
return (
<div
ref={setNodeRef}
style={style}
className={`preview-field flex items-center gap-3 px-2 py-1.5 cursor-pointer ${active ? "highlight" : ""}`}
onClick={() => onActivate(field.id)}
>
<span
{...attributes}
{...listeners}
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0 px-0.5"
title="拖拽排序"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" />
</svg>
</span>
<span className="w-5 h-5 rounded flex items-center justify-center text-[9px] font-bold text-white shrink-0" style={{ background: typeDef?.c ?? "#94a3b8" }}>
{typeDef?.icon ?? "?"}
</span>
<label className="text-xs text-slate-600 w-20 shrink-0 text-right select-none">{field.label}</label>
<PreviewWidget field={field} active={active} />
{tags.map((t, i) => (
<span key={i} className="text-[10px] text-slate-400 shrink-0">
{t}
</span>
))}
</div>
);
}
export default function FormPreview({ fields, groups, activeId, onActivate, onReorder, onGroupRename }: Props) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorder(Number(active.id), Number(over.id));
}
}
const sorted = [...fields].sort((a, b) => a.order - b.order);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
<SortableContext items={sorted.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{groups.map((g) => {
const gfs = sorted.filter((f) => f.groupId === g.id);
if (!gfs.length) return null;
const hasActive = gfs.some((f) => f.id === activeId);
return (
<div key={g.id} className={`group-block ${hasActive ? "highlight-group" : ""}`}>
<div className="px-3 py-1.5 bg-slate-50/80 rounded-t-md border-b border-slate-100 flex items-center gap-2">
<span className="text-[11px] font-medium text-slate-500">{g.name}</span>
<span className="text-[10px] text-slate-300">{gfs.length}字段</span>
<div className="flex-1" />
<button onClick={() => onGroupRename(g.id)} className="text-[10px] text-slate-300 hover:text-slate-500 transition-colors">
改名
</button>
</div>
<div className="p-3 space-y-2">
{gfs.map((f) => (
<SortableField key={f.id} field={f} active={f.id === activeId} onActivate={onActivate} />
))}
</div>
</div>
);
})}
{/* Ungrouped */}
{(() => {
const ug = sorted.filter((f) => !groups.some((g) => g.id === f.groupId));
if (!ug.length) return null;
return (
<div className="group-block">
<div className="px-3 py-1.5 bg-amber-50/80 rounded-t-md border-b border-slate-100">
<span className="text-[11px] font-medium text-slate-500">未分组</span>
</div>
<div className="p-3 space-y-2">
{ug.map((f) => (
<SortableField key={f.id} field={f} active={f.id === activeId} onActivate={onActivate} />
))}
</div>
</div>
);
})()}
{sorted.length === 0 && (
<div className="text-center text-xs text-slate-300 mt-16">暂无字段</div>
)}
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,86 @@
import { useState } from "react";
import type { EditMode } from "../types";
interface Props {
editMode: EditMode;
onEditModeChange: (mode: EditMode) => void;
}
export default function ModuleSettings({ editMode, onEditModeChange }: Props) {
const [open, setOpen] = useState(true);
const modes: { value: EditMode; label: string }[] = [
{ value: "form", label: "表单弹窗" },
{ value: "inline", label: "表内行编辑" },
{ value: "none", label: "只读" },
];
return (
<div className="card">
<div className="flex items-center gap-2 px-5 py-3 cursor-pointer select-none" onClick={() => setOpen((v) => !v)}>
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h3 className="text-sm font-semibold text-slate-700">模块设置</h3>
<svg className={`w-4 h-4 text-slate-400 ml-auto transition-transform ${open ? "" : "-rotate-90"}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 9l6 6 6-6" />
</svg>
</div>
{open && (
<div className="grid grid-cols-4 gap-x-5 gap-y-3 px-5 pb-5">
<div>
<label className="text-[11px] text-slate-400 block mb-1">编码</label>
<input className="input-sm bg-slate-50 text-slate-500" value="d_sea" readOnly />
</div>
<div>
<label className="text-[11px] text-slate-400 block mb-1">名称</label>
<input className="input-sm" defaultValue="海运主单" />
</div>
<div>
<label className="text-[11px] text-slate-400 block mb-1">类型</label>
<select className="select-sm" defaultValue="data">
<option value="data">data — 数据模块</option>
<option value="directory">directory — 目录</option>
<option value="page">page — 页面</option>
</select>
</div>
<div>
<label className="text-[11px] text-slate-400 block mb-1">数据视图</label>
<select className="select-sm" defaultValue="v_sea_master">
<option>v_sea_master</option>
<option>v_sea_fee</option>
<option>v_container</option>
</select>
</div>
<div className="col-span-2">
<label className="text-[11px] text-slate-400 block mb-1">编辑模式</label>
<div className="seg">
{modes.map((m) => (
<button
key={m.value}
onClick={() => onEditModeChange(m.value)}
className={`seg-btn ${editMode === m.value ? "active" : ""}`}
>
{m.label}
</button>
))}
</div>
</div>
<div className="col-span-2 flex items-end gap-4 pb-0.5">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" defaultChecked className="w-3.5 h-3.5 accent-blue-600" />
<span className="text-xs text-slate-600">启用</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" defaultChecked className="w-3.5 h-3.5 accent-blue-600" />
<span className="text-xs text-slate-600">显示菜单</span>
</label>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,162 @@
import { useState, useCallback, useRef } from "react";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import type { DragEndEvent, Modifier } from "@dnd-kit/core";
import { SortableContext, useSortable, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
import type { ModuleField } from "../types";
interface Props {
fields: ModuleField[];
activeId: number | null;
onActivate: (id: number) => void;
onReorder: (activeId: number, overId: number) => void;
onResize: (id: number, width: number) => void;
}
function SortableColumn({
field,
active,
isLast,
onActivate,
onResizeStart,
}: {
field: ModuleField;
active: boolean;
isLast: boolean;
onActivate: (id: number) => void;
onResizeStart: (e: React.MouseEvent, id: number) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
return (
<div
ref={setNodeRef}
style={{ ...style, width: field.listW, flexShrink: 0, position: "relative" }}
className={`px-3 py-2 border-r border-slate-200 text-xs font-medium cursor-pointer select-none ${
active ? "bg-blue-50 text-blue-600" : "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => onActivate(field.id)}
>
<div className="flex items-center gap-1.5">
<span
{...attributes}
{...listeners}
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0"
title="拖拽排序"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 10h12M6 14h12" />
</svg>
</span>
<span className="truncate">{field.label}</span>
<span className="text-[10px] text-slate-300 ml-auto">{field.listW}</span>
</div>
{!isLast && (
<div
className="col-resize-handle"
onMouseDown={(e) => onResizeStart(e, field.id)}
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
);
}
export default function TablePreview({ fields, activeId, onActivate, onReorder, onResize }: Props) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const [resizing, setResizing] = useState<{ id: number; startX: number; startW: number } | null>(null);
const tableRef = useRef<HTMLDivElement>(null);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorder(Number(active.id), Number(over.id));
}
},
[onReorder]
);
const handleResizeStart = useCallback(
(e: React.MouseEvent, id: number) => {
e.preventDefault();
e.stopPropagation();
const field = fields.find((f) => f.id === id);
if (!field) return;
setResizing({ id, startX: e.clientX, startW: field.listW });
},
[fields]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!resizing) return;
const dx = e.clientX - resizing.startX;
const w = Math.max(40, Math.min(400, Math.round(resizing.startW + dx)));
onResize(resizing.id, w);
setResizing((prev) => (prev ? { ...prev } : null));
},
[resizing, onResize]
);
const handleMouseUp = useCallback(() => {
setResizing(null);
}, []);
// Attach global listeners for resize
if (resizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
const visible = fields.filter((f) => f.listOn).sort((a, b) => a.order - b.order);
if (!visible.length) {
return <div className="text-center text-xs text-slate-300 mt-16">暂无可见列 · 请开启字段的列表开关</div>;
}
const totalW = visible.reduce((s, f) => s + f.listW, 0);
return (
<div ref={tableRef}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToHorizontalAxis]}>
<SortableContext items={visible.map((f) => f.id)} strategy={horizontalListSortingStrategy}>
<div className="overflow-x-auto border border-slate-200 rounded-lg bg-white shadow-sm">
<div style={{ minWidth: Math.max(totalW, 400) }}>
<div className="flex bg-slate-50 border-b border-slate-200">
{visible.map((f, i) => (
<SortableColumn
key={f.id}
field={f}
active={f.id === activeId}
isLast={i === visible.length - 1}
onActivate={onActivate}
onResizeStart={handleResizeStart}
/>
))}
</div>
{[0, 1].map((r) => (
<div key={r} className="flex border-b border-slate-50 text-xs text-slate-300">
{visible.map((f) => (
<div
key={f.id}
className="shrink-0 px-3 py-2 border-r border-slate-50"
style={{ width: f.listW, textAlign: f.listAlign }}
>
{f.type === "select" ? "—" : f.type === "date" ? "2024-08-12" : f.type === "number" ? "100.00" : "示例"}
</div>
))}
</div>
))}
</div>
</div>
</SortableContext>
</DndContext>
<div className="mt-2 text-[11px] text-slate-400 text-center">
拖拽列头排序 · 拖拽右边缘调整宽度 · 点击列头选中字段
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { ModuleField, FieldGroup } from "../types";
export const defaultGroups: FieldGroup[] = [
{ id: "g1", name: "默认区域" },
{ id: "g2", name: "订舱信息" },
{ id: "g3", name: "费用信息" },
];
export const defaultFields: ModuleField[] = [
{ id: 1, code: "b_id", label: "ID", type: "input", groupId: "g1", order: 10, listOn: true, listW: 80, listAlign: "left", editOn: false, editReq: false, editRO: true, queryOn: false, queryOp: "like", queryDef: "", ds: null },
{ id: 2, code: "container_no", label: "箱号", type: "input", groupId: "g1", order: 20, listOn: true, listW: 140, listAlign: "left", editOn: true, editReq: true, editRO: false, queryOn: true, queryOp: "like", queryDef: "", ds: null },
{ id: 3, code: "seal_no", label: "封号", type: "input", groupId: "g1", order: 25, listOn: true, listW: 120, listAlign: "left", editOn: false, editReq: false, editRO: false, queryOn: false, queryOp: "like", queryDef: "", ds: null },
{ id: 4, code: "carrier", label: "船公司", type: "select", groupId: "g2", order: 30, listOn: true, listW: 160, listAlign: "left", editOn: true, editReq: true, editRO: false, queryOn: true, queryOp: "=", queryDef: "", ds: { type: "view", view: "v_carrier", vf: "b_id", lf: "b_name", ob: "b_xh ASC" } },
{ id: 5, code: "vessel", label: "船名", type: "input", groupId: "g2", order: 40, listOn: true, listW: 140, listAlign: "left", editOn: true, editReq: false, editRO: false, queryOn: false, queryOp: "like", queryDef: "", ds: null },
{ id: 6, code: "voyage", label: "航次", type: "input", groupId: "g2", order: 50, listOn: true, listW: 100, listAlign: "left", editOn: true, editReq: false, editRO: false, queryOn: false, queryOp: "like", queryDef: "", ds: null },
{ id: 7, code: "pol", label: "起运港", type: "select", groupId: "g2", order: 60, listOn: true, listW: 140, listAlign: "left", editOn: true, editReq: true, editRO: false, queryOn: true, queryOp: "=", queryDef: "", ds: { type: "view", view: "v_port", vf: "b_id", lf: "b_name" } },
{ id: 8, code: "pod", label: "目的港", type: "select", groupId: "g2", order: 70, listOn: true, listW: 140, listAlign: "left", editOn: true, editReq: true, editRO: false, queryOn: true, queryOp: "=", queryDef: "", ds: { type: "view", view: "v_port", vf: "b_id", lf: "b_name" } },
{ id: 9, code: "etd", label: "预计离港", type: "date", groupId: "g2", order: 80, listOn: true, listW: 130, listAlign: "left", editOn: true, editReq: false, editRO: false, queryOn: true, queryOp: "between", queryDef: "", ds: null },
{ id: 10, code: "freight", label: "运费", type: "number", groupId: "g3", order: 90, listOn: true, listW: 120, listAlign: "right", editOn: true, editReq: false, editRO: false, queryOn: false, queryOp: ">=", queryDef: "", ds: null },
{ id: 11, code: "currency", label: "币种", type: "select", groupId: "g3", order: 100, listOn: true, listW: 100, listAlign: "left", editOn: true, editReq: true, editRO: false, queryOn: true, queryOp: "=", queryDef: "USD", ds: { type: "static", items: [{ v: "USD", l: "美元" }, { v: "CNY", l: "人民币" }, { v: "EUR", l: "欧元" }] } },
{ id: 12, code: "status", label: "状态", type: "select", groupId: "g1", order: 110, listOn: true, listW: 100, listAlign: "left", editOn: false, editReq: false, editRO: false, queryOn: true, queryOp: "=", queryDef: "draft", ds: { type: "static", items: [{ v: "draft", l: "草稿" }, { v: "confirmed", l: "已确认" }, { v: "shipped", l: "已发运" }] } },
];
let _nextId = 20;
export function nextId(): number {
return _nextId++;
}
export function makeField(groupId?: string | null, type = "input", label?: string): ModuleField {
const id = nextId();
return {
id,
code: "fld_" + id,
label: label ?? "新字段",
type,
groupId: groupId ?? null,
order: id * 10,
listOn: true,
listW: 120,
listAlign: "left",
editOn: true,
editReq: false,
editRO: false,
queryOn: false,
queryOp: "like",
queryDef: "",
ds: type === "select" ? { type: "view", view: "", vf: "b_id", lf: "b_name" } : null,
};
}
+313
View File
@@ -0,0 +1,313 @@
@import "tailwindcss";
/* ===== Design Tokens ===== */
@theme {
/* 统一品牌主色(与 JSX 中的 blue-600 保持一致,消除 blue-500/blue-600 混用) */
--color-primary: #2563eb; /* blue-600 */
--color-primary-hover: #1d4ed8; /* blue-700 */
--color-accent: #2563eb;
}
/* ===== Base ===== */
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: #f8fafc;
color: #334155;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ===== Card ===== */
.card {
background: #fff;
border: 1px solid #f1f5f9;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
/* ===== Segmented control(复用组件,三处切换统一) ===== */
.seg {
display: flex;
gap: 3px;
padding: 3px;
background: #f1f5f9; /* slate-100 */
border-radius: 6px;
}
.seg-btn {
flex: 1;
padding: 6px 10px;
font-size: 11px;
font-weight: 500;
line-height: 1;
border: none;
border-radius: 4px;
background: transparent;
color: #64748b; /* slate-500 */
cursor: pointer;
transition: all 0.15s;
}
.seg-btn:hover {
color: #334155; /* slate-700 */
}
.seg-btn.active {
background: #fff;
color: var(--color-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1;
padding: 6px 12px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-outline {
border-color: #e2e8f0; /* slate-200 */
background: #fff;
color: #475569; /* slate-600 */
}
.btn-outline:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.btn-ghost {
background: transparent;
color: #64748b;
}
.btn-ghost:hover {
background: #f1f5f9;
color: #334155;
}
/* ===== Inputs ===== */
.input,
.input-sm,
.select-sm {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
color: #334155;
background: #fff;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus,
.input-sm:focus,
.select-sm:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.input-sm {
padding: 5px 8px;
font-size: 12px;
}
/* ===== Toggle switch(div 实现,旋钮用 ::after;.on 表示开启) ===== */
.toggle {
position: relative;
display: inline-block;
flex-shrink: 0;
width: 40px;
height: 22px;
margin: 0;
padding: 0;
border: none;
border-radius: 9999px;
background: #cbd5e1; /* slate-300 */
cursor: default;
pointer-events: none;
transition: background 0.2s;
}
.toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 9999px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform 0.2s;
}
.toggle.on {
background: var(--color-primary);
}
.toggle.on::after {
transform: translateX(18px);
}
/* ===== Thin scrollbar ===== */
.thin-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.thin-scroll::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 3px;
}
.thin-scroll::-webkit-scrollbar-track {
background: transparent;
}
/* ===== Drawer ===== */
.drawer-mask {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: flex-end;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s;
}
.drawer-mask.open {
opacity: 1;
visibility: visible;
}
.drawer {
width: 100%;
max-width: 520px;
margin: 0 12px;
padding: 18px;
background: #fff;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
transition: transform 0.25s;
}
.drawer-mask.open .drawer {
transform: translateY(0);
}
.drawer-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 6px;
}
.drawer-close {
border: none;
background: transparent;
font-size: 22px;
line-height: 1;
color: #94a3b8;
cursor: pointer;
}
.drawer-body {
max-height: 60vh;
padding: 12px 0;
overflow: auto;
}
.drawer-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.form-row {
margin-bottom: 14px;
}
.form-row label {
display: block;
margin-bottom: 5px;
font-size: 11px;
color: #94a3b8;
}
/* ===== Field list & preview overlays ===== */
.field-row {
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.field-row:hover {
background: #f8fafc;
}
.field-row.active {
background: #eff6ff; /* blue-50 */
border-left-color: var(--color-primary);
}
.drag-handle {
cursor: grab;
opacity: 0;
flex-shrink: 0;
transition: opacity 0.1s;
}
.field-row:hover .drag-handle,
.field-row.active .drag-handle {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.preview-field {
position: relative;
border-radius: 8px;
transition: all 0.15s;
}
.preview-field.highlight {
background: #eff6ff;
}
.preview-field.highlight::before {
content: "";
position: absolute;
left: -8px;
top: 0;
bottom: 0;
width: 3px;
border-radius: 2px;
background: var(--color-primary);
}
.group-block {
border: 1px dashed #e2e8f0;
border-radius: 8px;
margin-bottom: 12px;
transition: border-color 0.15s, background 0.15s;
}
.group-block:hover {
border-color: #cbd5e1;
}
.group-block.highlight-group {
border-color: var(--color-primary);
background: #fafbff;
}
.col-resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
z-index: 10;
}
.col-resize-handle:hover,
.col-resize-handle.active {
background: var(--color-primary);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+52
View File
@@ -0,0 +1,52 @@
export interface FieldDataSource {
type: "view" | "static" | null;
view?: string;
vf?: string;
lf?: string;
ob?: string;
items?: { v: string; l: string }[];
}
export interface ModuleField {
id: number;
code: string;
label: string;
type: string;
groupId: string | null;
order: number;
listOn: boolean;
listW: number;
listAlign: "left" | "center" | "right";
editOn: boolean;
editReq: boolean;
editRO: boolean;
queryOn: boolean;
queryOp: string;
queryDef: string;
ds: FieldDataSource | null;
}
export interface FieldGroup {
id: string;
name: string;
}
export type EditMode = "form" | "inline" | "none";
export type PreviewMode = "form" | "table";
export interface FieldTypeDef {
v: string;
lbl: string;
icon: string;
c: string;
}
export const TYPE_DEFS: FieldTypeDef[] = [
{ v: "input", lbl: "文本", icon: "Aa", c: "#3b82f6" },
{ v: "textarea", lbl: "多行", icon: "¶", c: "#6366f1" },
{ v: "number", lbl: "数字", icon: "#", c: "#10b981" },
{ v: "select", lbl: "下拉", icon: "☰", c: "#8b5cf6" },
{ v: "date", lbl: "日期", icon: "📅", c: "#f59e0b" },
{ v: "datetime", lbl: "日期时间", icon: "🕐", c: "#ef4444" },
{ v: "switch", lbl: "开关", icon: "⇄", c: "#06b6d4" },
];
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { port: 3000, host: "0.0.0.0" },
});