├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── app
├── action
│ └── auth.ts
├── api
│ ├── ai-chat
│ │ └── route.ts
│ ├── auth
│ │ └── [...nextauth]
│ │ │ └── route.ts
│ ├── form
│ │ └── route.ts
│ └── submission
│ │ └── route.ts
├── components
│ ├── header
│ │ ├── header.module.css
│ │ └── header.tsx
│ └── login-out
│ │ └── login-out.tsx
├── entry
│ └── [id]
│ │ ├── page.module.css
│ │ └── page.tsx
├── favicon.ico
├── form
│ ├── [id]
│ │ ├── components
│ │ │ ├── Item
│ │ │ │ ├── Item.module.css
│ │ │ │ ├── Item.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Handle
│ │ │ │ │ │ ├── Handle.module.css
│ │ │ │ │ │ ├── Handle.tsx
│ │ │ │ │ │ └── index.ts
│ │ │ │ └── index.ts
│ │ │ ├── dnd-context-wrapper
│ │ │ │ └── dnd-context-wrapper.tsx
│ │ │ ├── droppable-container
│ │ │ │ └── droppable-container.tsx
│ │ │ ├── form-item
│ │ │ │ ├── checkbox-group.tsx
│ │ │ │ ├── field-types.ts
│ │ │ │ ├── form-date-picker.tsx
│ │ │ │ ├── form-rate.tsx
│ │ │ │ ├── form-time-picker.tsx
│ │ │ │ ├── radio-group.module.css
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── text-area.tsx
│ │ │ │ ├── text-input.tsx
│ │ │ │ ├── upload.tsx
│ │ │ │ └── withUpdateState.tsx
│ │ │ ├── form-title
│ │ │ │ ├── form-title.module.css
│ │ │ │ ├── form-title.tsx
│ │ │ │ └── index.ts
│ │ │ ├── main-form-panel.tsx
│ │ │ ├── main-form.tsx
│ │ │ ├── main-header.tsx
│ │ │ ├── preview-form
│ │ │ │ ├── index.ts
│ │ │ │ ├── preview-form.module.css
│ │ │ │ └── preview-form.tsx
│ │ │ ├── side-form-item-panel.tsx
│ │ │ ├── side-item
│ │ │ │ ├── index.ts
│ │ │ │ ├── side-item.module.css
│ │ │ │ └── side-item.tsx
│ │ │ ├── sortable-item
│ │ │ │ ├── sortable-item.ts
│ │ │ │ └── sortable-item.tsx
│ │ │ └── styles
│ │ │ │ ├── main-form.module.css
│ │ │ │ └── side-form-item-panel.module.css
│ │ ├── layout.tsx
│ │ ├── loading.tsx
│ │ ├── not-found.module.css
│ │ ├── not-found.tsx
│ │ ├── page.module.css
│ │ └── page.tsx
│ └── constants.ts
├── globals.css
├── home
│ ├── components
│ │ └── sidebar
│ │ │ ├── sidebar.module.css
│ │ │ └── sidebar.tsx
│ ├── layout.tsx
│ ├── my-forms
│ │ ├── components
│ │ │ └── form-card
│ │ │ │ ├── form-card.module.css
│ │ │ │ └── form-card.tsx
│ │ ├── my-forms.module.css
│ │ └── page.tsx
│ ├── page.tsx
│ └── template
│ │ ├── components
│ │ ├── generate-template
│ │ │ ├── generate-template.module.css
│ │ │ └── generate-template.tsx
│ │ ├── template-card
│ │ │ ├── index.ts
│ │ │ ├── template-card.module.css
│ │ │ └── template-card.tsx
│ │ └── template-list
│ │ │ ├── index.ts
│ │ │ ├── template-list-skeleton.tsx
│ │ │ ├── template-list.module.css
│ │ │ └── template-list.tsx
│ │ ├── page.module.css
│ │ └── page.tsx
├── layout.tsx
├── page.module.css
├── page.tsx
└── share
│ └── [id]
│ ├── page.module.css
│ └── page.tsx
├── auth.ts
├── eslint.config.mjs
├── lib
├── field.ts
├── form.ts
├── mongodb.ts
├── submission.ts
└── template.ts
├── middleware.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── store
├── field.ts
├── form.ts
└── index.ts
├── tsconfig.json
└── utils
├── common.ts
├── history-manager.ts
└── user.ts
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | .
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | contributing
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 lbsmx
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Smart Form
2 |
3 | ## 在线预览
4 | https://smart-form-kappa.vercel.app/
5 |
6 | ## 特点
7 |
8 | 1. 支持模板一键生成表单,更多模板正在开放中
9 |
10 | 2. 支持点击/拖拽生成表单项
11 |
12 | 3. 支持表单项拖拽排序
13 |
14 | 4. 支持表单预览
15 |
16 | 5. 接入 Deep Seek,支持 AI 生成表单
17 |
18 | 6. 支持 undo/redo
19 |
20 | 7. 支持实时保存
21 |
22 | ## 技术栈
23 |
24 | 1. Next.js
25 |
26 | 2. TypeScript
27 |
28 | 3. Ant Design
29 |
30 | 4. Deep Seek
31 |
32 | 5. Dnd Kit
33 |
34 | 6. Vercel
35 |
36 | 7. MongoDB
37 |
38 | ## 蓝图
39 |
40 | 1. 支持用户登录
41 |
42 | 2. 支持数据表格
43 |
44 | 3. 支持数据分析
45 |
46 | ## Getting Started
47 |
48 | First, run the development server:
49 |
50 | ```bash
51 | npm run dev
52 | # or
53 | yarn dev
54 | # or
55 | pnpm dev
56 | # or
57 | bun dev
58 | ```
59 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Use this section to tell people about which versions of your project are
6 | currently being supported with security updates.
7 |
8 | | Version | Supported |
9 | | ------- | ------------------ |
10 | | 5.1.x | :white_check_mark: |
11 | | 5.0.x | :x: |
12 | | 4.0.x | :white_check_mark: |
13 | | < 4.0 | :x: |
14 |
15 | ## Reporting a Vulnerability
16 |
17 | Use this section to tell people how to report a vulnerability.
18 |
19 | Tell them where to go, how often they can expect to get an update on a
20 | reported vulnerability, what to expect if the vulnerability is accepted or
21 | declined, etc.
22 |
--------------------------------------------------------------------------------
/app/action/auth.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { signIn, signOut } from '@/auth';
4 |
5 | export const login = async () => {
6 | await signIn('github');
7 | };
8 |
9 | export const logout = async () => {
10 | await signOut();
11 | };
12 |
--------------------------------------------------------------------------------
/app/api/ai-chat/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from 'next/server';
2 | import OpenAI from 'openai';
3 |
4 | interface ChatMessage {
5 | role: 'system' | 'user' | 'assistant';
6 | content: string;
7 | }
8 |
9 | export async function POST(request: NextRequest) {
10 | const { requirements } = await request.json();
11 |
12 | const openai = new OpenAI({
13 | baseURL: process.env.DEEPSEEK_BASE_URL,
14 | apiKey: process.env.DEEPSEEK_API_KEY,
15 | });
16 |
17 | const systemPrompt = `你是一个专业的表单设计助手。请根据用户要求生成一个结构化的 JSON 格式表单模板
18 |
19 | 1. **表单类型和描述**:
20 | - textInput: 单行文本输入框,适用于短文本(如姓名、电话号码)。
21 | - options 支持字段:
22 | - maxLength: number,最大输入长度,默认 30。
23 | - placeholder: string,输入提示文字。
24 | - textArea: 多行文本输入框,适用于长文本(如个人简介、备注)。
25 | - options 支持字段:
26 | - maxLength: number,最大输入字符数。
27 | - placeholder: string,输入提示文字。
28 | - radioGroup: 单选框组,用户只能选择一项。
29 | - options 支持字段:
30 | - options: Array<{ value: string; label: string }>,选项列表。
31 | - checkboxGroup: 多选框组,用户可以选择多个选项。
32 | - options 支持字段:
33 | - options: Array<{ value: string; label: string }>,选项列表。
34 | - uploader: 文件上传组件。
35 | - options 支持字段:
36 | - accept: string,允许上传的文件类型,仅支持四种格式:"image/*,.pdf,.docx,.xlsx"。默认全选。
37 | - multiple: boolean,是否允许用户选择多个文件。默认 false。
38 |
39 | 2. **JSON Schema 示例**:
40 | {
41 | "formTitle": "表单标题",
42 | "formList": [
43 | {
44 | "type": "textInput",
45 | "label": "用户名",
46 | "required": true,
47 | "options": {
48 | "maxLength": 20,
49 | "placeholder": "请输入用户名"
50 | }
51 | },
52 | {
53 | "type": "radioGroup",
54 | "label": "性别",
55 | "required": true,
56 | "options": {
57 | "options": [
58 | {"value": "male", "label": "男"},
59 | {"value": "female", "label": "女"}
60 | ]
61 | }
62 | },
63 | {
64 | "type": "uploader",
65 | "label": "附件",
66 | "required": true,
67 | "options": {
68 | "accept": "image/*,.pdf,.docx,.xlsx",
69 | "multiple": false
70 | }
71 | }
72 | ]
73 | }
74 |
75 | 3. **输出要求**:
76 | - 表单标题应该与用户需求相关联,如果没有预期内容,则返回"表单标题"。
77 | - 如果用户没有明确说明字段规则,请根据常识推断最合适的字段类型和验证规则。
78 | - 返回的 JSON 必须严格符合上述格式,不要包含任何解释性文字。
79 | - 如果需求无意义(如仅包含数字或乱码),返回空数组。
80 | `;
81 |
82 | const userPrompt = `请帮我设计一个表单,需要包含以下信息:${requirements}`;
83 |
84 | const messages: ChatMessage[] = [
85 | // 系统角色,提示AI应该怎样收敛输出内容
86 | {
87 | role: 'system',
88 | content: systemPrompt,
89 | },
90 | // 用户角色,模拟用户的输入
91 | {
92 | role: 'user',
93 | content: userPrompt,
94 | },
95 | ];
96 |
97 | try {
98 | const completion = await openai.chat.completions.create({
99 | messages: messages,
100 | model: 'deepseek-chat',
101 | // 指定返回格式为json,且返回格式通过prompt进行严格限制
102 | response_format: {
103 | type: 'json_object',
104 | },
105 | });
106 | const responseJson = completion.choices[0].message.content;
107 | return new Response(responseJson, {
108 | status: 200,
109 | });
110 | } catch (error: any) {
111 | const { status } = error;
112 | let message;
113 | switch (status) {
114 | case 400:
115 | message = '请求格式错误错误';
116 | break;
117 | case 401:
118 | message = 'API key错误';
119 | break;
120 | case 402:
121 | message = '作者没钱了:(';
122 | break;
123 | case 500:
124 | message = 'deepseek又挂掉了:(';
125 | break;
126 | default:
127 | break;
128 | }
129 | return new Response(JSON.stringify({ error: message }), {
130 | status,
131 | headers: { 'Content-Type': 'application/json' },
132 | });
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { handlers } from '@/auth'; // Referring to the auth.ts we just created
2 | export const { GET, POST } = handlers;
3 |
--------------------------------------------------------------------------------
/app/api/form/route.ts:
--------------------------------------------------------------------------------
1 | import dbConnect from '@/lib/mongodb';
2 | import Forms from '@/lib/form';
3 | import { NextRequest } from 'next/server';
4 | import { FormUpdateType } from '@/store/form';
5 |
6 | export async function POST(request: NextRequest) {
7 | try {
8 | await dbConnect();
9 | const cookie = request.cookies.get('userid');
10 | const userId = cookie?.value;
11 | const { title, fields: formList } = await request.json();
12 | const newForm = await Forms.create({ userId, title, formList });
13 |
14 | const response = {
15 | id: newForm._id,
16 | };
17 | return new Response(JSON.stringify(response), { status: 201 });
18 | } catch (error) {
19 | return new Response(
20 | JSON.stringify({
21 | error,
22 | }),
23 | { status: 500 }
24 | );
25 | }
26 | }
27 |
28 | // Helper function to create a response
29 | const createResponse = (type: string, form: any, status: number = 200) => {
30 | return new Response(
31 | JSON.stringify({
32 | type,
33 | form,
34 | }),
35 | {
36 | status,
37 | }
38 | );
39 | };
40 |
41 | // Helper function to update the form and return the updated form
42 | const updateForm = async (
43 | formId: string,
44 | updateData: any,
45 | options: { arrayFilters?: any[] } = {}
46 | ) => {
47 | await dbConnect();
48 | const form = await Forms.findByIdAndUpdate(formId, updateData, {
49 | new: true,
50 | ...options,
51 | });
52 | return form;
53 | };
54 |
55 | export async function PUT(request: NextRequest) {
56 | const { type, formId, data } = await request.json();
57 | let form;
58 |
59 | switch (type) {
60 | case FormUpdateType.UpdateItem:
61 | const updatePayload = Object.keys(data.updated).reduce(
62 | (acc, key) => {
63 | acc[`formList.$[item].${key}`] = data.updated[key];
64 | return acc;
65 | },
66 | {}
67 | );
68 |
69 | form = await updateForm(
70 | formId,
71 | {
72 | $set: updatePayload,
73 | },
74 | {
75 | arrayFilters: [{ 'item.id': data.id }],
76 | }
77 | );
78 | break;
79 | case FormUpdateType.AddItem:
80 | form = await updateForm(formId, {
81 | $push: {
82 | formList: {
83 | $each: [data.newItem],
84 | $position: data.index,
85 | },
86 | },
87 | });
88 | break;
89 | case FormUpdateType.DeleteItem:
90 | form = await updateForm(formId, {
91 | $pull: {
92 | formList: { id: data.item.id },
93 | },
94 | });
95 | break;
96 | case FormUpdateType.SortList:
97 | const { oldIndex, newIndex } = data;
98 |
99 | const currentForm = await Forms.findById(formId);
100 | if (!currentForm) {
101 | return new Response(
102 | JSON.stringify({ error: 'Form not found' }),
103 | {
104 | status: 404,
105 | }
106 | );
107 | }
108 | const formList = [...currentForm.formList];
109 | const [movedItem] = formList.splice(oldIndex, 1);
110 | formList.splice(newIndex, 0, movedItem);
111 |
112 | form = await updateForm(formId, {
113 | $set: {
114 | formList,
115 | },
116 | });
117 |
118 | break;
119 | case FormUpdateType.UpdateTitle:
120 | form = await updateForm(formId, { title: data.formTitle });
121 | break;
122 | default:
123 | return new Response(JSON.stringify({ error: 'Invalid type' }), {
124 | status: 400,
125 | });
126 | }
127 |
128 | return createResponse(type, form);
129 | }
130 |
--------------------------------------------------------------------------------
/app/api/submission/route.ts:
--------------------------------------------------------------------------------
1 | import dbConnect from '@/lib/mongodb';
2 | import Submissions from '@/lib/submission';
3 | import { NextRequest } from 'next/server';
4 | import Forms from '@/lib/form';
5 |
6 | // 禁用 Next.js 默认的 body parser
7 | export const config = {
8 | api: {
9 | bodyParser: false,
10 | },
11 | };
12 |
13 | export async function POST(request: NextRequest) {
14 | const cookie = request.cookies.get('userid');
15 | const userId = cookie?.value;
16 | await dbConnect();
17 | const formData = await request.formData();
18 | const { formId, ...rest } = Object.fromEntries(formData);
19 | const form = await Forms.findById(formId);
20 | const { formList } = form;
21 | if (!form) {
22 | return new Response(
23 | JSON.stringify({
24 | error: '表单不存在',
25 | }),
26 | { status: 404 }
27 | );
28 | }
29 |
30 | const newFormList = Object.entries(rest).map(([key, value]) => {
31 | const formItem = formList.find((item) => item.id === key);
32 | formItem['value'] = value;
33 | return formItem;
34 | });
35 |
36 | try {
37 | await Submissions.create({
38 | formId,
39 | formData: newFormList,
40 | userId,
41 | });
42 | return new Response(JSON.stringify({ success: true }), {
43 | status: 200,
44 | headers: { 'Content-Type': 'application/json' },
45 | });
46 | } catch (error) {
47 | console.log('数据库写入失败', error);
48 | return new Response(JSON.stringify({ error: '提交失败' }), {
49 | status: 500,
50 | headers: { 'Content-Type': 'application/json' },
51 | });
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/components/header/header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: center;
5 | padding: 1rem 2rem;
6 | background-color: #ffffff;
7 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
8 | position: fixed;
9 | top: 0;
10 | left: 0;
11 | right: 0;
12 | height: 64px;
13 | z-index: 1000;
14 | }
15 |
16 | .logo {
17 | font-size: 1.5rem;
18 | font-weight: bold;
19 | color: #333;
20 | }
21 |
22 | .nav {
23 | display: flex;
24 | gap: 1rem;
25 | align-items: center;
26 | }
27 |
28 | .githubLink {
29 | text-decoration: none;
30 | color: #333;
31 | font-weight: 500;
32 | transition: color 0.2s ease;
33 | }
34 |
35 | .githubLink:hover {
36 | color: #666;
37 | }
38 |
--------------------------------------------------------------------------------
/app/components/header/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import styles from '@/app/components/header/header.module.css';
3 | import LogInOut from '../login-out/login-out';
4 | import { auth } from '@/auth';
5 |
6 | export default async function Header() {
7 | const session = await auth();
8 | return (
9 |
10 | Smart Form
11 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/components/login-out/login-out.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from 'antd';
4 | import { login, logout } from '@/app/action/auth';
5 |
6 | export default function LogInOut(props) {
7 | const { session } = props;
8 | return session?.user ? (
9 |
10 | {session?.user.name}
11 |
14 |
15 | ) : (
16 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/app/entry/[id]/page.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 20px;
3 | max-width: 1200px;
4 | margin: 0 auto;
5 | }
6 |
--------------------------------------------------------------------------------
/app/entry/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Table } from 'antd';
2 | import styles from './page.module.css';
3 | import dbConnect from '@/lib/mongodb';
4 | import Submissions from '@/lib/submission';
5 | import { headers } from 'next/headers';
6 | import { parse } from 'cookie';
7 | import Forms from '@/lib/form';
8 | import FieldType from '@/app/form/[id]/components/form-item/field-types';
9 |
10 | export default async function Entry({ params }: { params: { id: string } }) {
11 | const { id } = await params;
12 | const cookies = (await headers()).get('cookie');
13 | const cookiesObject = parse(cookies || '');
14 | const userId = cookiesObject.userid;
15 | const form = await Forms.find({ _id: id, userId });
16 | if (!form) {
17 | return 您没有当前填写数据的访问权限
;
18 | }
19 | await dbConnect();
20 | const submissions = await Submissions.find(
21 | { formId: id },
22 | { formData: 1, _id: 0 }
23 | );
24 | console.log(submissions);
25 |
26 | type ResultType = { key: number } & Record;
27 |
28 | const dataSource = submissions.map((item, index) => {
29 | const { formData } = item;
30 | const result: ResultType = {
31 | key: index,
32 | };
33 | formData.forEach((fieldItem: FieldType) => {
34 | result[fieldItem.id] = fieldItem.value;
35 | });
36 | return result;
37 | });
38 |
39 | const columns = submissions[0].formData.map((item: FieldType) => {
40 | return {
41 | title: item.label,
42 | dataIndex: item.id,
43 | key: item.id,
44 | };
45 | });
46 |
47 | return (
48 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lbsmx/smart-form/1e55d9dcc567b412990af387928d98967530068e/app/favicon.ico
--------------------------------------------------------------------------------
/app/form/[id]/components/Item/Item.module.css:
--------------------------------------------------------------------------------
1 | .item {
2 | position: relative;
3 | padding: 18px 32px;
4 | border-radius: 4px;
5 | background-color: #fff;
6 | transition: box-shadow 0.3s ease;
7 | cursor: pointer;
8 | }
9 |
10 | .item:not(:first-child) {
11 | margin-top: 8px;
12 | }
13 |
14 | .item.dragging::after {
15 | content: '';
16 | position: absolute;
17 | left: 0;
18 | right: 0;
19 | top: 0;
20 | bottom: 0;
21 | background-color: rgba(231, 238, 253);
22 | border: 1px dashed rgba(20, 85, 237);
23 | z-index: 1;
24 | }
25 |
26 | .item:hover {
27 | box-shadow: 0px 6px 18px 6px rgba(31, 35, 41, 0.03),
28 | 0px 3px 6px -6px rgba(31, 35, 41, 0.05),
29 | 0px 4px 8px 0px rgba(31, 35, 41, 0.03);
30 | z-index: 1;
31 | }
32 |
33 | .formItemContainer {
34 | position: relative;
35 | width: 100%;
36 | display: flex;
37 | align-items: center;
38 | }
39 |
40 | .formItemContainer.editable::before {
41 | content: '';
42 | position: absolute;
43 | top: 0;
44 | left: 0;
45 | right: 0;
46 | bottom: 0;
47 | background-color: rgba(255, 255, 255, 0.5);
48 | pointer-events: all;
49 | z-index: 1;
50 | }
51 |
52 | .handle {
53 | position: absolute;
54 | left: -18px;
55 | top: 50%;
56 | transform: translateY(-50%);
57 | }
58 |
59 | .labelContainer {
60 | position: relative;
61 | display: flex;
62 | align-items: center;
63 | margin-bottom: 8px;
64 | }
65 |
66 | .label {
67 | display: flex;
68 | align-items: center;
69 | font-size: 14px;
70 | }
71 |
72 | .label.required::before {
73 | display: inline-block;
74 | margin-inline-end: 4px;
75 | color: #ff4d4f;
76 | font-size: 14px;
77 | font-family: SimSun, sans-serif;
78 | line-height: 1;
79 | content: '*';
80 | }
81 |
82 | .switchContainer {
83 | display: flex;
84 | align-items: center;
85 | margin-left: auto;
86 | }
87 |
88 | .switchText {
89 | margin-left: 6px;
90 | font-size: 14px;
91 | }
92 |
--------------------------------------------------------------------------------
/app/form/[id]/components/Item/Item.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { forwardRef, memo, useEffect, useState } from 'react';
4 | import { InputProps } from 'antd/es/input';
5 | import TextInput from '../form-item/text-input';
6 | import TextArea from '../form-item/text-area';
7 | import Handle from './components/Handle/Handle';
8 | import styles from './Item.module.css';
9 | import { ItemProps } from './index';
10 | import { Button, Switch } from 'antd';
11 | import { useDispatch, useSelector } from 'react-redux';
12 | import { AppDispatch, RootState } from '@/store';
13 | import { FormUpdateType, updateForm } from '@/store/form';
14 | import RadioGroup from '../form-item/radio-group';
15 | import CheckboxGroup from '../form-item/checkbox-group';
16 | import { CloseOutlined } from '@ant-design/icons';
17 | import { UploaderProps } from '../form-item/upload';
18 | import Uploader from '../form-item/upload';
19 | import FormRate from '../form-item/form-rate';
20 | import FormDatePicker from '../form-item/form-date-picker';
21 | import FormTimePicker from '../form-item/form-time-picker';
22 |
23 | // 定义表单组件的类型映射
24 | export interface FormComponentMap {
25 | textInput: React.FC;
26 | textArea: React.FC;
27 | radioGroup: React.FC;
28 | checkboxGroup: React.FC;
29 | uploader: React.FC;
30 | rate: React.FC;
31 | datePicker: React.FC;
32 | timePicker: React.FC;
33 | }
34 |
35 | // 实现表单组件的映射
36 | export const formComponentMap: FormComponentMap = {
37 | textInput: TextInput,
38 | textArea: TextArea,
39 | radioGroup: RadioGroup,
40 | checkboxGroup: CheckboxGroup,
41 | uploader: Uploader,
42 | rate: FormRate,
43 | datePicker: FormDatePicker,
44 | timePicker: FormTimePicker,
45 | };
46 |
47 | const Item = forwardRef(
48 | (
49 | {
50 | sortable,
51 | listeners,
52 | attributes,
53 | id,
54 | type,
55 | isDragging,
56 | required,
57 | label,
58 | options,
59 | },
60 | ref
61 | ) => {
62 | const [isEditing, setIsEditing] = useState(false);
63 | const dispatch = useDispatch();
64 | const formList = useSelector((state: RootState) => state.form.formList);
65 |
66 | useEffect(() => {
67 | document.addEventListener('click', handleDocumentClick);
68 | return () => {
69 | document.removeEventListener('click', handleDocumentClick);
70 | };
71 | }, []);
72 |
73 | // 全局点击事件处理,将item变为非编辑状态
74 | const handleDocumentClick = (e: MouseEvent) => {
75 | if (!(e.target instanceof HTMLElement) || isEditing) return;
76 | const targetEl = e.target.closest(`.${styles.item}`);
77 | if (
78 | !targetEl ||
79 | targetEl?.getAttribute('data-id') !== id ||
80 | e.target.closest(`.${styles.switchContainer}`)
81 | ) {
82 | setIsEditing(false);
83 | } else {
84 | setIsEditing(true);
85 | }
86 | };
87 |
88 | const onRequiredChange = (checked: boolean) => {
89 | dispatch(
90 | updateForm({
91 | type: FormUpdateType.UpdateItem,
92 | data: {
93 | id,
94 | updated: { required: checked },
95 | },
96 | })
97 | );
98 | };
99 |
100 | const handleDelete = () => {
101 | dispatch(
102 | updateForm({
103 | type: FormUpdateType.DeleteItem,
104 | data: {
105 | index: formList.findIndex((item) => item.id === id),
106 | item: formList.find((item) => item.id === id),
107 | },
108 | })
109 | );
110 | };
111 |
112 | return (
113 |
123 |
124 | {!sortable || isEditing ? null : (
125 | <>
126 |
127 |
128 |
129 |
136 |
137 |
142 | 必填
143 |
144 |
}
148 | style={{ marginLeft: '8px' }}
149 | onClick={handleDelete}
150 | />
151 | >
152 | )}
153 |
154 |
159 | {formComponentMap[type as keyof FormComponentMap]({
160 | id,
161 | type,
162 | isEditing,
163 | sortable,
164 | required,
165 | label,
166 | options,
167 | })}
168 |
169 |
170 | );
171 | }
172 | );
173 |
174 | Item.displayName = 'Item';
175 |
176 | export default memo(Item);
177 |
--------------------------------------------------------------------------------
/app/form/[id]/components/Item/components/Handle/Handle.module.css:
--------------------------------------------------------------------------------
1 | /* Handle.module.css */
2 | .handle {
3 | display: flex;
4 | width: 12px;
5 | align-items: center;
6 | justify-content: center;
7 | flex: 0 0 auto;
8 | touch-action: none;
9 | cursor: grab;
10 | border-radius: 5px;
11 | border: none;
12 | outline: none;
13 | appearance: none;
14 | background-color: transparent;
15 | -webkit-tap-highlight-color: transparent;
16 | box-sizing: content-box;
17 | }
18 |
--------------------------------------------------------------------------------
/app/form/[id]/components/Item/components/Handle/Handle.tsx:
--------------------------------------------------------------------------------
1 | // Handle.tsx
2 | import React from "react";
3 | import styles from "./Handle.module.css";
4 |
5 | export default function Handle({ listeners }) {
6 | return (
7 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/form/[id]/components/Item/components/Handle/index.ts:
--------------------------------------------------------------------------------
1 | import Handle from "./Handle";
2 |
3 | export default Handle;
4 |
--------------------------------------------------------------------------------
/app/form/[id]/components/Item/index.ts:
--------------------------------------------------------------------------------
1 | import { DraggableSyntheticListeners } from '@dnd-kit/core';
2 | import Item from './Item';
3 | import FieldType from '../form-item/field-types';
4 |
5 | export interface ItemProps extends FieldType {
6 | sortable: boolean;
7 | disabled?: boolean;
8 | style?: React.CSSProperties;
9 | listeners?: DraggableSyntheticListeners;
10 | attributes?: any;
11 | required: boolean;
12 | isDragging: boolean;
13 | }
14 |
15 | export default Item;
16 |
--------------------------------------------------------------------------------
/app/form/[id]/components/dnd-context-wrapper/dnd-context-wrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import styles from '@/app/form/[id]/page.module.css';
5 | import SideFormItemPanel from '../../components/side-form-item-panel';
6 | import MainFormPanel from '../../components/main-form-panel';
7 | import {
8 | DndContext,
9 | KeyboardSensor,
10 | PointerSensor,
11 | useSensor,
12 | useSensors,
13 | DragOverlay,
14 | DropAnimation,
15 | defaultDropAnimationSideEffects,
16 | UniqueIdentifier,
17 | Announcements,
18 | ScreenReaderInstructions,
19 | rectIntersection,
20 | } from '@dnd-kit/core';
21 | import {
22 | sortableKeyboardCoordinates,
23 | SortableContext,
24 | verticalListSortingStrategy,
25 | } from '@dnd-kit/sortable';
26 | import { createPortal } from 'react-dom';
27 | import { useState, useEffect } from 'react';
28 | import Item from '../../components/Item/Item.tsx';
29 | import { SortableItemProps } from '../../components/sortable-item/sortable-item';
30 | import { v4 as uuidv4 } from 'uuid';
31 | import { useSelector, useDispatch } from 'react-redux';
32 | import { FormUpdateType, setForm, updateForm } from '@/store/form.ts';
33 | import { AppDispatch, RootState } from '@/store/index.ts';
34 | import SideItem from '../../components/side-item/side-item.tsx';
35 | import _ from 'lodash';
36 | import { setFormLib } from '@/store/field.ts';
37 | import { produce } from 'immer';
38 |
39 | const screenReaderInstructions: ScreenReaderInstructions = {
40 | draggable: `
41 | To pick up a sortable item, press the space bar.
42 | While sorting, use the arrow keys to move the item.
43 | Press space again to drop the item in its new position, or press escape to cancel.
44 | `,
45 | };
46 | export default function DndContextWrapper(props: DndContextWrapper) {
47 | const { formData } = props;
48 | const {
49 | id,
50 | formList: initFormList,
51 | title,
52 | formLib: initFormLib,
53 | } = formData;
54 |
55 | const [activeItem, setActiveItem] = useState(
56 | null
57 | );
58 | const [portalRoot, setPortalRoot] = useState(null);
59 |
60 | const sensors = useSensors(
61 | useSensor(PointerSensor, {
62 | // 触发拖拽的阈值,默认为15px 防止绑定其上的click无法触发
63 | activationConstraint: {
64 | distance: 15,
65 | },
66 | }),
67 | useSensor(KeyboardSensor, {
68 | coordinateGetter: sortableKeyboardCoordinates,
69 | })
70 | );
71 |
72 | const dispatch = useDispatch();
73 | const formList = useSelector((state: RootState) => state.form.formList);
74 | const formLib = useSelector((state: RootState) => state.field.formLib);
75 |
76 | // 因为在服务端无法获取document 因此组件挂载时创建 portalRoot
77 | useEffect(() => {
78 | // 客户端组件也会在服务端进行预渲染,然后在客户端进行水合,最后根据交互在客户端进行渲染
79 | // 因此在服务端预渲染时拿不到document,需要使用副作用函数保证在客户端获取到document
80 | if (typeof document !== 'undefined') setPortalRoot(document.body);
81 | dispatch(
82 | setForm({
83 | formId: id,
84 | formTitle: title,
85 | formList: initFormList,
86 | })
87 | );
88 | dispatch(setFormLib(initFormLib));
89 | }, []);
90 |
91 | const dropAnimationConfig: DropAnimation = {
92 | sideEffects: defaultDropAnimationSideEffects({
93 | styles: {
94 | active: {
95 | opacity: '0.5',
96 | },
97 | },
98 | }),
99 | };
100 |
101 | const findContainer = (id: UniqueIdentifier) => {
102 | if (formList.find((item) => item.id === id)) {
103 | return 'formList';
104 | }
105 | if (id === 'form-container') {
106 | return 'formContainer';
107 | }
108 | return 'formLib';
109 | };
110 |
111 | const announcements: Announcements = {
112 | onDragStart({ active }) {
113 | // 防止(点击)拖拽到form-container时,触发onDragStart
114 | if (active.id === 'form-container') return;
115 | setActiveItem(active.data.current as SortableItemProps);
116 | return undefined;
117 | },
118 | onDragOver({ active, over }) {
119 | if (!active || !over || !activeItem) return;
120 |
121 | const activeContainer = findContainer(activeItem!.id);
122 | const overContainer = findContainer(over.id);
123 | if (activeContainer === overContainer) return;
124 | const overItem = over.data.current;
125 | // 从lib中拖拽到form中
126 | if (activeContainer === 'formLib' && !activeItem?.sortable) {
127 | const overIndex = formList.findIndex(
128 | (item) => item.id === overItem?.id
129 | );
130 | const isBelowOverItem =
131 | active.rect.current.translated &&
132 | active.rect.current.translated.top >
133 | over.rect.top + over.rect.height;
134 |
135 | const modifier = isBelowOverItem ? 1 : 0;
136 | const newIndex: number =
137 | overIndex >= 0 ? overIndex + modifier : formList.length + 1;
138 |
139 | // 修改formLib
140 | const newFormLib = produce(formLib, (draft) => {
141 | const activeGroupKey = Object.keys(draft).find((key) =>
142 | draft[key]?.some((item) => item.id === activeItem.id)
143 | );
144 |
145 | if (!activeGroupKey) return;
146 |
147 | const activeGroup = draft[activeGroupKey];
148 | const activeIndex = activeGroup?.findIndex(
149 | (item) => item.id === activeItem.id
150 | );
151 |
152 | if (activeIndex === -1 || !activeGroup) return;
153 |
154 | activeGroup[activeIndex].id = uuidv4();
155 | });
156 |
157 | dispatch(setFormLib(newFormLib));
158 | dispatch(
159 | updateForm({
160 | type: FormUpdateType.AddItem,
161 | data: {
162 | index: newIndex,
163 | newItem: activeItem,
164 | },
165 | })
166 | );
167 | }
168 | return undefined;
169 | },
170 | onDragEnd({ active, over }) {
171 | // formList排序
172 | if (active && over && active.id !== over.id) {
173 | const oldIndex = formList.findIndex(
174 | (item) => item.id === active.id
175 | );
176 | const newIndex = formList.findIndex(
177 | (item) => item.id === over.id
178 | );
179 | dispatch(
180 | updateForm({
181 | type: FormUpdateType.SortList,
182 | data: {
183 | oldIndex,
184 | newIndex,
185 | },
186 | })
187 | );
188 | return;
189 | }
190 |
191 | // 批量更新机制,同一个事件处理函数中,多个setState会被合并为一次重新渲染
192 | setActiveItem(null);
193 | return undefined;
194 | },
195 | onDragCancel() {
196 | return undefined;
197 | },
198 | };
199 |
200 | // 注: 因为侧边栏和主区域共享SortableContext,因此碰撞算法不能使用closestCenter,
201 | // 否则会在拖拽开始就检测到侧边栏item碰撞到了form item
202 |
203 | return (
204 |
213 |
214 |
215 |
219 |
220 |
221 |
222 | {portalRoot &&
223 | createPortal(
224 |
228 | {activeItem != null ? (
229 | activeItem.sortable ? (
230 |
234 | ) : (
235 |
236 | )
237 | ) : null}
238 | ,
239 | document.body
240 | )}
241 |
242 | );
243 | }
244 |
--------------------------------------------------------------------------------
/app/form/[id]/components/droppable-container/droppable-container.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | SortableContext,
5 | useSortable,
6 | verticalListSortingStrategy,
7 | } from '@dnd-kit/sortable';
8 | import { CSS } from '@dnd-kit/utilities';
9 |
10 | export default function DroppableContainer(props) {
11 | const { attributes, setNodeRef, transform, transition } = useSortable({
12 | id: 'form-container',
13 | });
14 |
15 | return (
16 |
25 | item.id)]}
27 | strategy={verticalListSortingStrategy}
28 | >
29 | {props.children}
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/checkbox-group.tsx:
--------------------------------------------------------------------------------
1 | import RadioGroup from './radio-group';
2 |
3 | export default RadioGroup;
4 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/field-types.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from '@dnd-kit/core';
2 |
3 | export default interface FieldType {
4 | id: UniqueIdentifier;
5 | label: string;
6 | type: string;
7 | required: boolean;
8 | isEditing: boolean;
9 | options: Record;
10 | value: any;
11 | onChange: (value: any) => void;
12 | }
13 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/form-date-picker.tsx:
--------------------------------------------------------------------------------
1 | import { DatePicker, Form, Input, Radio } from 'antd';
2 | import WithUpdateState from './withUpdateState';
3 | import FieldType from './field-types';
4 | import { useForm } from 'antd/es/form/Form';
5 |
6 | function FormDatePicker(props: FieldType) {
7 | const { isEditing, label, options, onChange } = props;
8 | const [form] = useForm();
9 | return (
10 |
11 | {isEditing ? (
12 |
19 |
20 |
21 |
22 |
23 | 天
24 | 周
25 | 月
26 | 季度
27 | 年
28 |
29 |
30 |
34 |
35 | 否
36 | 是
37 |
38 |
39 |
40 | ) : (
41 |
42 | )}
43 |
44 | );
45 | }
46 |
47 | export default WithUpdateState(FormDatePicker);
48 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/form-rate.tsx:
--------------------------------------------------------------------------------
1 | import { Rate, Checkbox, Input, Radio, Form } from 'antd';
2 | import FieldType from './field-types';
3 | import WithUpdateState from './withUpdateState';
4 |
5 | function FormRate(props: FieldType) {
6 | const { isEditing, label, options, onChange } = props;
7 | const [form] = Form.useForm();
8 |
9 | return isEditing ? (
10 |
16 |
17 |
18 |
23 |
24 |
25 |
26 |
27 | 5
28 | 10
29 | 100
30 |
31 |
32 |
33 | ) : (
34 |
35 | );
36 | }
37 |
38 | export default WithUpdateState(FormRate);
39 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/form-time-picker.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Input, Radio, TimePicker } from 'antd';
2 | import FieldType from './field-types';
3 | import { useForm } from 'antd/es/form/Form';
4 | import WithUpdateState from './withUpdateState';
5 |
6 | function FormTimePicker(props: FieldType) {
7 | const { isEditing, label, options, onChange } = props;
8 | const [form] = useForm();
9 |
10 | return (
11 |
12 | {isEditing ? (
13 |
20 |
21 |
22 |
26 |
27 | 否
28 | 是
29 |
30 |
31 |
32 | ) : (
33 |
34 | )}
35 |
36 | );
37 | }
38 |
39 | export default WithUpdateState(FormTimePicker);
40 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/radio-group.module.css:
--------------------------------------------------------------------------------
1 | /* radio-group.module.css */
2 | .draggableRadio {
3 | margin-top: 10px;
4 | display: flex;
5 | align-items: center;
6 | gap: 10px;
7 | transition: transform 0.3s;
8 | }
9 |
10 | .input {
11 | flex: 1;
12 | }
13 |
14 | .closeButton {
15 | color: inherit;
16 | border: none;
17 | padding: 0;
18 | cursor: pointer;
19 | transition: color 0.3s;
20 | }
21 |
22 | .closeButton:hover {
23 | color: red;
24 | }
25 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/radio-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { Input, Button, Form } from 'antd';
5 | import Handle from '../Item/components/Handle';
6 | import {
7 | Announcements,
8 | closestCenter,
9 | DndContext,
10 | PointerSensor,
11 | useSensor,
12 | useSensors,
13 | } from '@dnd-kit/core';
14 | import { CSS } from '@dnd-kit/utilities';
15 | import { CloseOutlined } from '@ant-design/icons';
16 | import styles from './radio-group.module.css';
17 | import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
18 | import { v4 as uuid } from 'uuid';
19 | import FieldType from './field-types';
20 | import {
21 | arrayMove,
22 | SortableContext,
23 | useSortable,
24 | verticalListSortingStrategy,
25 | } from '@dnd-kit/sortable';
26 | import { useDispatch } from 'react-redux';
27 | import { AppDispatch } from '@/store';
28 | import { FormUpdateType, updateForm } from '@/store/form';
29 |
30 | const SortableOption = ({ option, index, onChange, onDelete }) => {
31 | const { value } = option;
32 |
33 | const {
34 | attributes,
35 | listeners,
36 | setNodeRef,
37 | transform,
38 | transition,
39 | isDragging,
40 | } = useSortable({ id: value });
41 |
42 | const style = {
43 | transform: CSS.Transform.toString(transform),
44 | transition,
45 | opacity: isDragging ? 0.5 : 1,
46 | };
47 |
48 | const handleChange = (event: React.ChangeEvent) => {
49 | onChange(value, event.target.value);
50 | };
51 |
52 | return (
53 |
59 |
60 |
61 |
62 |
63 | }
66 | className={styles.closeButton}
67 | onClick={() => onDelete(value)}
68 | />
69 |
70 | );
71 | };
72 |
73 | const RenderOptions = ({ label, options, id }) => {
74 | const dispatch = useDispatch();
75 |
76 | const sensors = useSensors(
77 | useSensor(PointerSensor, {
78 | activationConstraint: { distance: 15 },
79 | })
80 | );
81 |
82 | const announcements: Announcements = {
83 | onDragStart() {
84 | return undefined;
85 | },
86 | onDragOver() {
87 | return undefined;
88 | },
89 | onDragCancel() {
90 | return undefined;
91 | },
92 | onDragEnd: ({ active, over }) => {
93 | if (active && over && active.id !== over.id) {
94 | const activeIndex = options.findIndex(
95 | (item) => item.value === active.id
96 | );
97 | const overIndex = options.findIndex(
98 | (item) => item.value === over.id
99 | );
100 | const updatedOptions = arrayMove(
101 | options,
102 | activeIndex,
103 | overIndex
104 | );
105 | console.log(updatedOptions);
106 | }
107 | return undefined;
108 | },
109 | };
110 |
111 | const handleChange = (optionId: string, newLabel: string) => {
112 | const updatedOptions = options.map((option) =>
113 | option.value === optionId
114 | ? {
115 | ...option,
116 | label: newLabel,
117 | }
118 | : option
119 | );
120 | dispatch(
121 | updateForm({
122 | type: FormUpdateType.UpdateItem,
123 | data: {
124 | id,
125 | old: { label, options: { options } },
126 | updated: {
127 | label,
128 | options: {
129 | options: updatedOptions,
130 | },
131 | },
132 | },
133 | })
134 | );
135 | };
136 |
137 | const handleDelete = (optionId: string) => {
138 | const updatedOptions = options.filter(
139 | (option) => option.value !== optionId
140 | );
141 | dispatch(
142 | updateForm({
143 | type: FormUpdateType.UpdateItem,
144 | data: {
145 | id,
146 | old: { label, options: { options } },
147 | updated: {
148 | label,
149 | options: {
150 | options: updatedOptions,
151 | },
152 | },
153 | },
154 | })
155 | );
156 | };
157 |
158 | return (
159 |
165 | item.value)}
167 | strategy={verticalListSortingStrategy}
168 | >
169 | {options.map((option, index) => (
170 |
177 | ))}
178 |
179 |
180 | );
181 | };
182 |
183 | function RadioGroup(props: FieldType) {
184 | const { isEditing, options, label, id } = props;
185 | const [form] = Form.useForm();
186 | const dispatch = useDispatch();
187 |
188 | const handleAddOption = () => {
189 | const newOption = {
190 | value: uuid(),
191 | label: '新选项',
192 | };
193 | dispatch(
194 | updateForm({
195 | type: FormUpdateType.UpdateItem,
196 | data: {
197 | id,
198 | old: { label, options },
199 | updated: {
200 | label,
201 | options: {
202 | options: [...options.options, newOption],
203 | },
204 | },
205 | },
206 | })
207 | );
208 | };
209 |
210 | const onLabelChange = (event: React.ChangeEvent) => {
211 | const newLabel = event.target.value;
212 | dispatch(
213 | updateForm({
214 | type: FormUpdateType.UpdateItem,
215 | data: {
216 | id,
217 | old: { label, options },
218 | updated: {
219 | label: newLabel,
220 | options,
221 | },
222 | },
223 | })
224 | );
225 | };
226 |
227 | return (
228 |
266 | );
267 | }
268 |
269 | export default RadioGroup;
270 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/text-area.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Input, InputNumber, Form } from 'antd';
4 | import React from 'react';
5 | import FieldType from './field-types';
6 | import WithUpdateState from './withUpdateState';
7 |
8 | function TextArea(props: FieldType) {
9 | const { isEditing, options, label, onChange } = props;
10 | const { placeholder, maxLength } = options;
11 | const [form] = Form.useForm();
12 |
13 | return (
14 |
15 | {!isEditing && (
16 |
17 |
23 |
24 | )}
25 | {isEditing && (
26 |
33 |
34 |
35 |
39 |
40 |
41 |
42 |
48 |
49 |
50 | )}
51 |
52 | );
53 | }
54 |
55 | export default WithUpdateState(TextArea);
56 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/text-input.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Input, InputNumber, Form } from 'antd';
4 | import React from 'react';
5 | import FieldType from './field-types';
6 | import WithUpdateState from './withUpdateState';
7 |
8 | function TextInput(props: FieldType) {
9 | const { isEditing, options, label, onChange } = props;
10 | const { placeholder } = options;
11 |
12 | const [form] = Form.useForm();
13 |
14 | return (
15 |
48 | );
49 | }
50 |
51 | export default WithUpdateState(TextInput);
52 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/upload.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Upload, Form, Input, Checkbox } from 'antd';
4 | import React from 'react';
5 | import { InboxOutlined } from '@ant-design/icons';
6 | import { useDispatch } from 'react-redux';
7 | import { updateForm } from '@/store/form';
8 | import { AppDispatch } from '@/store/index';
9 | import FieldType from './field-types';
10 |
11 | const { Dragger } = Upload;
12 |
13 | export interface UploaderProps extends FieldType {
14 | accept?: string;
15 | multiple?: boolean;
16 | }
17 |
18 | export default function Uploader(props: UploaderProps) {
19 | const { isEditing, id, label, options } = props;
20 | const { accept = '', multiple = false } = options;
21 | const dispatch = useDispatch();
22 |
23 | const [form] = Form.useForm();
24 |
25 | const onValuesChange = (changedValues: any) => {
26 | // TODO
27 | dispatch(
28 | updateForm({
29 | type: 'formItem',
30 | data: {
31 | id,
32 | updatedItem:
33 | 'accept' in changedValues
34 | ? {
35 | options: {
36 | ...options,
37 | accept: changedValues.accept.join(','),
38 | },
39 | }
40 | : { options: { ...options, ...changedValues } },
41 | },
42 | })
43 | );
44 | };
45 |
46 | const fileOptions = [
47 | { value: 'image/*', label: '图片' },
48 | { value: '.pdf', label: 'PDF' },
49 | { value: '.docx', label: 'Word' },
50 | { value: '.xlsx', label: 'Excel' },
51 | ];
52 |
53 | // 保证至少有一个选项被选中
54 | const isCheckboxDisabled = (value: string) => {
55 | return (
56 | accept.split(',').length <= 1 && accept.split(',').includes(value)
57 | );
58 | };
59 |
60 | return (
61 |
62 | {!isEditing && (
63 |
64 |
65 |
66 |
67 |
68 | )}
69 |
70 | {isEditing && (
71 |
82 |
83 |
84 |
89 | 上传多个文件
90 |
91 |
92 |
93 | {fileOptions.map((option) => (
94 |
99 | {option.label}
100 |
101 | ))}
102 |
103 |
104 |
105 | )}
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-item/withUpdateState.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import FieldType from './field-types';
3 | import { useDispatch } from 'react-redux';
4 | import { AppDispatch } from '@/store';
5 | import { FormUpdateType, updateForm } from '@/store/form';
6 |
7 | export default function WithUpdateState(Component: React.FC) {
8 | const UpdateStateComponent = (props: FieldType) => {
9 | const { isEditing, label, options, id } = props;
10 | const [changed, setChanged] = useState(false);
11 | const [formData, setFormData] = useState({});
12 | const dispatch = useDispatch();
13 |
14 | useEffect(() => {
15 | if (!isEditing && changed) {
16 | // 执行保存逻辑
17 | handleUpdate();
18 | setChanged(false);
19 | }
20 | }, [isEditing, changed]);
21 |
22 | const handleUpdate = () => {
23 | dispatch(
24 | updateForm({
25 | type: FormUpdateType.UpdateItem,
26 | data: {
27 | id,
28 | old: {
29 | label,
30 | options,
31 | },
32 | updated: formData,
33 | },
34 | })
35 | );
36 | };
37 | const onChange = (formData: any) => {
38 | setChanged(true);
39 | setFormData(formData);
40 | };
41 |
42 | return ;
43 | };
44 |
45 | return UpdateStateComponent;
46 | }
47 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-title/form-title.module.css:
--------------------------------------------------------------------------------
1 | .formTitleContainer {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | width: 100%;
6 | padding: 10px;
7 | box-sizing: border-box;
8 | }
9 |
10 | .formTitleText {
11 | max-width: 100%;
12 | font-size: 24px;
13 | font-weight: bold;
14 | color: #333;
15 | cursor: pointer;
16 | user-select: none;
17 | transition: color 0.3s ease, background-color 0.3s ease,
18 | border-color 0.3s ease;
19 | outline: none;
20 | padding: 10px;
21 | border-radius: 5px;
22 | }
23 |
24 | .formTitleText.editable:hover,
25 | .formTitleText[contenteditable='true'] {
26 | color: #555;
27 | background-color: #dee2e6;
28 | border-color: #007bff;
29 | }
30 |
31 | .formTitleText::selection {
32 | background-color: #007bff;
33 | color: #fff;
34 | border-radius: 3px; /* 圆角 */
35 | }
36 |
37 | .formTitleInput {
38 | font-size: 24px;
39 | font-weight: bold;
40 | color: #333;
41 | border: 1px solid #ccc;
42 | background-color: #f9f9f9;
43 | padding: 10px; /* 增加内边距 */
44 | box-sizing: border-box;
45 | outline: none;
46 | transition: border-color 0.3s ease, background-color 0.3s ease;
47 | margin: 10px; /* 增加边距 */
48 | border-radius: 5px; /* 圆角 */
49 | }
50 |
51 | .formTitleInput:hover,
52 | .formTitleInput:focus {
53 | border-color: #007bff;
54 | background-color: #dee2e6; /* 更深的背景颜色 */
55 | box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); /* 添加阴影效果 */
56 | }
57 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-title/form-title.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState, useRef, useEffect } from 'react';
4 | import styles from './form-title.module.css';
5 | import { useSelector, useDispatch } from 'react-redux';
6 | import { AppDispatch, RootState } from '@/store';
7 | import { FormUpdateType, updateForm } from '@/store/form';
8 |
9 | interface FormTitleProps {
10 | editable: boolean;
11 | }
12 |
13 | export default function FormTitle({ editable }: FormTitleProps) {
14 | const [isEditing, setIsEditing] = useState(false);
15 | const formTitle = useSelector((state: RootState) => state.form.formTitle);
16 | const textRef = useRef(null);
17 | const dispatch = useDispatch();
18 |
19 | useEffect(() => {
20 | if (isEditing && textRef.current) {
21 | textRef.current.focus();
22 | document.execCommand('selectAll', false, undefined);
23 | }
24 | }, [isEditing]);
25 |
26 | const handleEdit = () => {
27 | if (!editable) return;
28 | setIsEditing(true);
29 | };
30 |
31 | const handleBlur = () => {
32 | setIsEditing(false);
33 | if (textRef.current) {
34 | const trimmedTitle = textRef.current.innerText.trim();
35 | if (trimmedTitle === '') {
36 | textRef.current.innerHTML = formTitle;
37 | } else {
38 | dispatch(
39 | updateForm({
40 | type: FormUpdateType.UpdateTitle,
41 | data: {
42 | oldTitle: formTitle,
43 | formTitle: trimmedTitle,
44 | },
45 | })
46 | );
47 | }
48 | }
49 | };
50 |
51 | const handleKeyDown = (event: React.KeyboardEvent) => {
52 | if (event.key === 'Enter') {
53 | event.preventDefault(); // 阻止默认的换行行为
54 | event.target.blur();
55 | } else if (
56 | textRef.current &&
57 | textRef.current.innerText.length >= 30 &&
58 | ![
59 | 'Backspace',
60 | 'Delete',
61 | 'ArrowLeft',
62 | 'ArrowRight',
63 | 'ArrowUp',
64 | 'ArrowDown',
65 | ].includes(event.key)
66 | ) {
67 | event.preventDefault(); // 阻止输入超过30个字符
68 | }
69 | };
70 |
71 | return (
72 |
73 |
83 | {formTitle}
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/app/form/[id]/components/form-title/index.ts:
--------------------------------------------------------------------------------
1 | import FormTitle from "./form-title";
2 |
3 | export default FormTitle;
4 |
--------------------------------------------------------------------------------
/app/form/[id]/components/main-form-panel.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import MainHeader from './main-header';
4 | import MainForm from './main-form';
5 | import { CSSProperties, memo } from 'react';
6 | import { SortableItemProps } from './sortable-item/sortable-item';
7 |
8 | function MainFormPanel({ formList }: { formList: SortableItemProps[] }) {
9 | const style: CSSProperties = {
10 | display: 'flex',
11 | flexDirection: 'column',
12 | height: '100%',
13 | overflow: 'hidden',
14 | };
15 |
16 | return (
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export default memo(MainFormPanel);
25 |
--------------------------------------------------------------------------------
/app/form/[id]/components/main-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Empty, Radio } from 'antd';
4 | import styles from '@/app/form/[id]/components/styles/main-form.module.css';
5 | import SortableItem from '@/app/form/[id]/components/sortable-item/sortable-item.tsx';
6 | import DroppableContainer from './droppable-container/droppable-container';
7 | import FormTitle from './form-title/form-title';
8 | import { SortableItemProps } from '@/app/form/[id]/components/sortable-item/sortable-item';
9 | import PreviewForm from './preview-form';
10 | import { useDispatch, useSelector } from 'react-redux';
11 | import { AppDispatch, RootState } from '@/store';
12 | import { setEditable } from '@/store/form';
13 | export default function MainForm({
14 | formList,
15 | }: {
16 | formList: SortableItemProps[];
17 | }) {
18 | const dispatch = useDispatch();
19 | const editable = useSelector((state: RootState) => state.form.editable);
20 |
21 | const { Group, Button } = Radio;
22 |
23 | return (
24 |
25 |
26 | dispatch(setEditable(e.target.value))}
29 | >
30 |
31 |
32 |
33 |
34 |
35 |
36 | {editable ? (
37 |
38 | {formList.length > 0 ? (
39 | formList.map((item) => (
40 |
46 | ))
47 | ) : (
48 |
52 | )}
53 |
54 | ) : (
55 |
56 | )}
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/app/form/[id]/components/main-header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button, Space, Popover } from 'antd';
4 | import {
5 | UndoOutlined,
6 | RedoOutlined,
7 | SaveOutlined,
8 | EyeOutlined,
9 | ShareAltOutlined,
10 | CopyOutlined,
11 | } from '@ant-design/icons';
12 | import React, { useEffect } from 'react';
13 | import { useDispatch, useSelector } from 'react-redux';
14 | import { AppDispatch, RootState } from '@/store';
15 | import { FormUpdateType, historyManager, updateForm } from '@/store/form';
16 |
17 | export default function MainHeader() {
18 | const [copied, setCopied] = React.useState(false);
19 | const [shareLink, setShareLink] = React.useState('');
20 | const formId = useSelector((state: RootState) => state.form.formId);
21 | const dispatch = useDispatch();
22 |
23 | useEffect(() => {
24 | setShareLink(`${window.location.host}/share/${formId}`);
25 | }, [formId]);
26 |
27 | const handleCopy = () => {
28 | navigator.clipboard
29 | .writeText(shareLink)
30 | .then(() => {
31 | setCopied(true);
32 | setTimeout(() => setCopied(false), 2000); // 2秒后恢复
33 | })
34 | .catch((err) => {
35 | console.error('复制失败:', err);
36 | });
37 | };
38 |
39 | const content = (
40 |
41 |
将以下链接复制并分享给他人:
42 |
51 | {shareLink}
52 |
53 |
}
55 | onClick={handleCopy}
56 | block
57 | type={copied ? 'primary' : 'default'}
58 | >
59 | {copied ? '已复制' : '复制链接'}
60 |
61 |
62 | );
63 |
64 | const handleUndo = () => {
65 | const state = historyManager.undo();
66 | if (!state) return;
67 | switch (state.action) {
68 | case FormUpdateType.UpdateTitle:
69 | const { oldTitle, formTitle } = state.data;
70 | dispatch(
71 | updateForm({
72 | type: FormUpdateType.UpdateTitle,
73 | data: {
74 | oldTitle: formTitle,
75 | formTitle: oldTitle,
76 | },
77 | history: true,
78 | })
79 | );
80 | break;
81 | case FormUpdateType.AddItem:
82 | const { index, newItem } = state.data;
83 | dispatch(
84 | updateForm({
85 | type: FormUpdateType.DeleteItem,
86 | data: {
87 | index,
88 | item: newItem,
89 | },
90 | history: true,
91 | })
92 | );
93 | break;
94 | case FormUpdateType.DeleteItem:
95 | dispatch(
96 | updateForm({
97 | type: FormUpdateType.AddItem,
98 | data: {
99 | index: state.data.index,
100 | newItem: state.data.item,
101 | },
102 | history: true,
103 | })
104 | );
105 | break;
106 | case FormUpdateType.UpdateItem:
107 | dispatch(
108 | updateForm({
109 | type: FormUpdateType.UpdateItem,
110 | data: {
111 | id: state.data.id,
112 | old: state.data.updated,
113 | updated: state.data.old,
114 | },
115 | history: true,
116 | })
117 | );
118 | break;
119 | case FormUpdateType.SortList:
120 | dispatch(
121 | updateForm({
122 | type: FormUpdateType.SortList,
123 | data: {
124 | oldIndex: state.data.newIndex,
125 | newIndex: state.data.oldIndex,
126 | },
127 | history: true,
128 | })
129 | );
130 | break;
131 | }
132 | };
133 |
134 | const handleRedo = () => {
135 | const state = historyManager.redo();
136 | if (!state) return;
137 | switch (state.action) {
138 | case FormUpdateType.UpdateTitle:
139 | const { oldTitle, formTitle } = state.data;
140 | dispatch(
141 | updateForm({
142 | type: FormUpdateType.UpdateTitle,
143 | data: {
144 | oldTitle,
145 | formTitle,
146 | },
147 | history: true,
148 | })
149 | );
150 | break;
151 | case FormUpdateType.AddItem:
152 | const { index, newItem } = state.data;
153 | dispatch(
154 | updateForm({
155 | type: FormUpdateType.AddItem,
156 | data: {
157 | index,
158 | newItem,
159 | },
160 | history: true,
161 | })
162 | );
163 | break;
164 | case FormUpdateType.DeleteItem:
165 | dispatch(
166 | updateForm({
167 | type: FormUpdateType.DeleteItem,
168 | data: {
169 | index: state.data.index,
170 | item: state.data.item,
171 | },
172 | history: true,
173 | })
174 | );
175 | break;
176 | case FormUpdateType.UpdateItem:
177 | dispatch(
178 | updateForm({
179 | type: FormUpdateType.UpdateItem,
180 | data: {
181 | id: state.data.id,
182 | old: state.data.old,
183 | updated: state.data.updated,
184 | },
185 | history: true,
186 | })
187 | );
188 | break;
189 | case FormUpdateType.SortList:
190 | dispatch(
191 | updateForm({
192 | type: FormUpdateType.SortList,
193 | data: {
194 | oldIndex: state.data.oldIndex,
195 | newIndex: state.data.newIndex,
196 | },
197 | history: true,
198 | })
199 | );
200 | break;
201 | }
202 | };
203 |
204 | return (
205 |
215 |
216 | } onClick={handleUndo} />
217 | } onClick={handleRedo} />
218 | } type='primary' />
219 | } />
220 |
221 |
222 |
223 | } type='primary'>
224 | 分享
225 |
226 |
227 |
228 | );
229 | }
230 |
--------------------------------------------------------------------------------
/app/form/[id]/components/preview-form/index.ts:
--------------------------------------------------------------------------------
1 | import PreviewForm from './preview-form';
2 |
3 | export default PreviewForm;
4 |
--------------------------------------------------------------------------------
/app/form/[id]/components/preview-form/preview-form.module.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lbsmx/smart-form/1e55d9dcc567b412990af387928d98967530068e/app/form/[id]/components/preview-form/preview-form.module.css
--------------------------------------------------------------------------------
/app/form/[id]/components/preview-form/preview-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import {
5 | Form,
6 | Input,
7 | Checkbox,
8 | Radio,
9 | Button,
10 | Upload,
11 | message,
12 | Rate,
13 | DatePicker,
14 | } from 'antd';
15 | import { InboxOutlined } from '@ant-design/icons';
16 | import FieldType from '../form-item/field-types';
17 |
18 | // Define types for form field options
19 | interface FieldOptions {
20 | [key: string]: any;
21 | }
22 |
23 | function previewUploader(props) {
24 | const { accept, multiple } = props;
25 | return (
26 |
27 |
28 |
29 |
30 |
31 | 点击/拖拽 上传
32 |
33 | 支持 {accept} 格式的文件
34 |
35 | );
36 | }
37 |
38 | interface PreviewFormProps {
39 | formList: FieldType[];
40 | share?: boolean;
41 | formId?: string;
42 | }
43 |
44 | export default function PreviewForm(props: PreviewFormProps) {
45 | const { formList, share = false, formId } = props;
46 | const [form] = Form.useForm();
47 |
48 | const [messageApi, contextHolder] = message.useMessage();
49 |
50 | const typeToComponent: Record> = {
51 | textInput: Input,
52 | textArea: Input.TextArea,
53 | radioGroup: Radio.Group,
54 | checkboxGroup: Checkbox.Group,
55 | uploader: previewUploader,
56 | rate: Rate,
57 | datePicker: DatePicker,
58 | };
59 |
60 | const onFinish = async (values: any) => {
61 | const submitted = sessionStorage.getItem('submitted');
62 | if (!share) return;
63 | if (submitted === '1') {
64 | messageApi.info('请勿重复提交');
65 | return;
66 | }
67 |
68 | const formData = new FormData();
69 |
70 | formData.append('formId', formId!);
71 |
72 | // 遍历 values,区分普通字段和文件字段
73 | for (const key in values) {
74 | if (Object.prototype.hasOwnProperty.call(values, key)) {
75 | const value = values[key];
76 |
77 | if (value instanceof FileList || value instanceof File) {
78 | // 如果是文件,直接 append
79 | formData.append(key, values);
80 | } else if (
81 | Array.isArray(value) &&
82 | value.every((v) => v instanceof File)
83 | ) {
84 | // 如果是多个文件组成的数组
85 | value.forEach((file) => formData.append(key, file));
86 | } else {
87 | // 普通文本字段
88 | formData.append(key, value);
89 | }
90 | }
91 | }
92 |
93 | try {
94 | const res = await fetch('/api/submission', {
95 | method: 'POST',
96 | body: formData,
97 | });
98 |
99 | if (!res.ok) throw new Error('提交失败');
100 |
101 | messageApi.success('提交成功');
102 | sessionStorage.setItem('submitted', '1');
103 | } catch (error) {
104 | console.error('提交出错:', error);
105 | }
106 | };
107 |
108 | return (
109 | <>
110 | {contextHolder}
111 |
134 |
135 |
136 | );
137 | })}
138 |
139 |
140 |
143 |
144 |
145 | >
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/app/form/[id]/components/side-form-item-panel.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react';
2 | import styles from '@/app/form/[id]/components/styles/side-form-item-panel.module.css';
3 | import SortableItem from '@/app/form/[id]/components/sortable-item/sortable-item.tsx';
4 | import { SortableItemProps } from './sortable-item/sortable-item';
5 | import { useSelector } from 'react-redux';
6 | import { RootState } from '@/store';
7 |
8 | interface SideFormItemPanelProps {
9 | active: SortableItemProps | null;
10 | formLib: Record | null;
11 | }
12 |
13 | function SideFormItemPanel({ active, formLib }: SideFormItemPanelProps) {
14 | const editable = useSelector((state: RootState) => state.form.editable);
15 |
16 | return (
17 |
18 |
19 | {formLib
20 | ? Object.entries(formLib).map(
21 | ([label, sets], groupIndex) => (
22 |
26 |
27 | {label}
28 |
29 |
30 | {sets.map((field) => (
31 |
37 | ))}
38 |
39 |
40 | )
41 | )
42 | : null}
43 |
44 |
45 | );
46 | }
47 |
48 | export default memo(SideFormItemPanel);
49 |
--------------------------------------------------------------------------------
/app/form/[id]/components/side-item/index.ts:
--------------------------------------------------------------------------------
1 | import SideItem from "./side-item";
2 |
3 | export default SideItem;
4 |
--------------------------------------------------------------------------------
/app/form/[id]/components/side-item/side-item.module.css:
--------------------------------------------------------------------------------
1 | .sideItem {
2 | display: flex;
3 | align-items: center;
4 | padding: 8px 12px;
5 | margin: 4px 0;
6 | background-color: #f2f3f5;
7 | border-radius: 8px;
8 | cursor: pointer;
9 | transition: background-color 0.3s;
10 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
11 | }
12 |
13 | .sideItem:hover {
14 | background-color: rgba(31, 35, 41, 0.12);
15 | }
16 |
17 | .icon {
18 | margin-right: 8px;
19 | font-size: 16px;
20 | }
21 |
22 | .label {
23 | flex-grow: 1;
24 | font-size: 14px;
25 | color: #333;
26 | }
27 |
28 | .itemContainer {
29 | display: flex;
30 | flex-wrap: wrap;
31 | gap: 8px;
32 | }
33 |
--------------------------------------------------------------------------------
/app/form/[id]/components/side-item/side-item.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react';
2 | import styles from '@/app/form/[id]/components/side-item/side-item.module.css';
3 | import { SortableItemProps } from '@/app/form/[id]/components/sortable-item/sortable-item';
4 | import { FormUpdateType, updateForm } from '@/store/form';
5 | import { useDispatch, useSelector } from 'react-redux';
6 | import { AppDispatch, RootState } from '@/store';
7 | import { v4 as uuid } from 'uuid';
8 |
9 | interface SideItemProps extends SortableItemProps {}
10 |
11 | const SideItem = forwardRef(
12 | ({ listeners, attributes, type, label, required, options }, ref) => {
13 | const dispatch = useDispatch();
14 | const formList = useSelector((state: RootState) => state.form.formList);
15 |
16 | // 添加当前item到form中
17 | const handleClick = () => {
18 | dispatch(
19 | updateForm({
20 | type: FormUpdateType.AddItem,
21 | data: {
22 | index: formList.length,
23 | newItem: {
24 | id: uuid(),
25 | sortable: true,
26 | type,
27 | label,
28 | disabled: false,
29 | required,
30 | options,
31 | },
32 | },
33 | })
34 | );
35 | };
36 |
37 | // 根据 type 获取对应的图标
38 | const getIcon = (type: string) => {
39 | switch (type) {
40 | case 'textInput':
41 | return 🔤; // 文本输入
42 | case 'textArea':
43 | return 📝; // 多行文本
44 | case 'rate':
45 | return ⭐; // 评分组件
46 | case 'radioGroup':
47 | return 🔘; // 单选框
48 | case 'checkboxGroup':
49 | return ✅; // 复选框
50 | case 'switch':
51 | return 🔄; // 开关
52 | default:
53 | return 📎; // 默认图标
54 | }
55 | };
56 |
57 | return (
58 |
65 | {getIcon(type)}
66 | {label}
67 |
68 | );
69 | }
70 | );
71 |
72 | SideItem.displayName = 'SideItem';
73 |
74 | export default SideItem;
75 |
--------------------------------------------------------------------------------
/app/form/[id]/components/sortable-item/sortable-item.ts:
--------------------------------------------------------------------------------
1 | import { ItemProps } from '../Item/index';
2 |
3 | export interface SortableItemProps extends ItemProps {}
4 |
--------------------------------------------------------------------------------
/app/form/[id]/components/sortable-item/sortable-item.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { memo } from 'react';
4 | import { useSortable } from '@dnd-kit/sortable';
5 | import Item from '../Item/Item.tsx';
6 | import { SortableItemProps } from './sortable-item';
7 | import SideItem from '../side-item';
8 |
9 | function SortableItem(props: SortableItemProps) {
10 | // useSortable会导致组件更新一次
11 | const {
12 | attributes,
13 | listeners,
14 | setNodeRef,
15 | transform,
16 | transition,
17 | isDragging,
18 | } = useSortable({ id: props.id, data: props });
19 |
20 | const style: React.CSSProperties = {
21 | transition: [transition].filter(Boolean).join(','),
22 | transform: transform
23 | ? `translate(${Math.round(transform.x)}px, ${Math.round(
24 | transform.y
25 | )}px)`
26 | : undefined,
27 | cursor: props.sortable ? 'poiner' : 'grab',
28 | };
29 |
30 | return props.sortable ? (
31 | // 将style绑定到父元素上并在Item内部添加memo防止组件在拖拽时频繁更新导致卡顿
32 |
33 |
40 |
41 | ) : (
42 |
43 |
49 |
50 | );
51 | }
52 |
53 | export default memo(SortableItem);
54 |
--------------------------------------------------------------------------------
/app/form/[id]/components/styles/main-form.module.css:
--------------------------------------------------------------------------------
1 | .formContainer {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 | max-width: 800px;
6 | flex: 1;
7 | margin: 0 auto;
8 | padding: 24px;
9 | background-color: #fff;
10 | border-radius: 8px;
11 | overflow: hidden;
12 | }
13 |
14 | .formTypeContainer {
15 | display: flex;
16 | justify-content: center;
17 | margin-bottom: 16px;
18 | }
19 |
20 | .form {
21 | flex: 1;
22 | padding: 16px;
23 | border-radius: 8px;
24 | overflow-y: auto;
25 | box-shadow: 0 6px 24px rgba(31, 35, 41, 0.08);
26 | }
27 |
--------------------------------------------------------------------------------
/app/form/[id]/components/styles/side-form-item-panel.module.css:
--------------------------------------------------------------------------------
1 | .panel {
2 | /* padding: 24px; */
3 | background: linear-gradient(135deg, #f8f9fa, #e9ecef);
4 | width: 0;
5 | min-width: 0;
6 | height: 100vh;
7 | overflow: hidden;
8 | border-right: 1px solid #dee2e6;
9 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
10 | transition: all 0.3s ease;
11 | }
12 |
13 | .panel.editable {
14 | width: 100%;
15 | }
16 |
17 | .categoryTitle {
18 | font-size: 16px;
19 | font-weight: bold;
20 | margin-bottom: 16px;
21 | color: #343a40;
22 | }
23 |
24 | .componentTitle {
25 | font-size: 14px;
26 | font-weight: bold;
27 | margin-bottom: 8px;
28 | color: #495057;
29 | }
30 |
31 | .componentContainer {
32 | margin-bottom: 24px;
33 | padding: 12px;
34 | background-color: #fff;
35 | border: 1px solid #e9ecef;
36 | border-radius: 8px;
37 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
38 | }
39 |
40 | .disabled {
41 | position: relative;
42 | opacity: 0.7; /* Slightly reduce opacity for disabled state */
43 | }
44 |
45 | .disabled::before {
46 | content: '';
47 | position: absolute;
48 | top: 0;
49 | left: 0;
50 | width: 100%;
51 | height: 100%;
52 | background-color: rgba(255, 255, 255, 0.5);
53 | z-index: 99;
54 | }
55 |
56 | .componentContainer:hover {
57 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
58 | }
59 |
60 | .itemContainer {
61 | display: grid;
62 | grid-template-columns: repeat(2, 1fr);
63 | gap: 16px;
64 | }
65 |
--------------------------------------------------------------------------------
/app/form/[id]/layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Provider } from 'react-redux';
4 | import store from '@/store';
5 |
6 | export default function Layout({ children }: { children: React.ReactNode }) {
7 | return (
8 |
9 | {/* */}
10 | {children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/form/[id]/loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { Skeleton } from 'antd';
5 |
6 | export default function DndSkeletonLoader() {
7 | return (
8 |
9 | {/* 左侧 - 侧边栏 (SideFormItemPanel) */}
10 |
25 | {/* 分组列表 */}
26 | {[...Array(2)].map((_, groupIndex) => (
27 |
39 | {/* 分组标题 */}
40 |
48 |
53 |
54 |
55 | {/* 表单项网格布局 */}
56 |
63 | {[...Array(6)].map((_, itemIndex) => (
64 |
71 |
81 |
82 | ))}
83 |
84 |
85 | ))}
86 |
87 |
88 | {/* 右侧 - 主区域 (MainFormPanel) */}
89 |
90 | {/* MainHeader - 精准还原样式 */}
91 |
101 | {/* 左侧按钮组 */}
102 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | {/* 右侧分享按钮 */}
116 |
117 |
118 |
119 | {/* 表单内容区域 - 居中版本 */}
120 |
132 | {/* 编辑/预览切换按钮组 */}
133 |
143 |
148 |
153 |
154 |
155 | {/* 表单标题 + 表单内容区块合并 */}
156 |
170 | {/* 表单标题 */}
171 |
175 |
176 | {/* 表单项骨架区块 */}
177 |
185 | {[...Array(5)].map((_, index) => (
186 |
197 | ))}
198 |
199 |
200 |
201 |
202 |
203 | );
204 | }
205 |
--------------------------------------------------------------------------------
/app/form/[id]/not-found.module.css:
--------------------------------------------------------------------------------
1 | /* app/form/[id]/not-found.module.css */
2 | .notFoundContainer {
3 | display: flex;
4 | flex-direction: column;
5 | align-items: center;
6 | justify-content: center;
7 | height: 100vh;
8 | text-align: center;
9 | background-color: #f9f9f9;
10 | }
11 |
12 | .notFoundTitle {
13 | font-size: 2em;
14 | color: #333;
15 | }
16 |
17 | .notFoundDescription {
18 | font-size: 1.2em;
19 | color: #666;
20 | }
21 |
22 | .actions {
23 | margin-top: 20px;
24 | }
25 |
26 | .actionButton {
27 | margin: 0 10px;
28 | padding: 10px 20px;
29 | font-size: 1em;
30 | cursor: pointer;
31 | border: none;
32 | border-radius: 5px;
33 | background-color: #007bff;
34 | color: white;
35 | transition: background-color 0.3s;
36 | }
37 |
38 | .actionButton:hover {
39 | background-color: #0056b3;
40 | }
41 |
--------------------------------------------------------------------------------
/app/form/[id]/not-found.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import styles from './not-found.module.css';
3 |
4 | const NotFound = () => {
5 | return (
6 |
7 |
404 - Form Not Found
8 |
9 | The form you are looking for does not exist.
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default NotFound;
26 |
--------------------------------------------------------------------------------
/app/form/[id]/page.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | display: grid;
3 | grid-template-columns: auto 1fr;
4 | width: 100%;
5 | height: 100%;
6 | overflow: hidden;
7 | }
8 |
--------------------------------------------------------------------------------
/app/form/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import Forms from '@/lib/form';
2 | import { notFound } from 'next/navigation';
3 | import Fields from '@/lib/field';
4 | import { UniqueIdentifier } from '@dnd-kit/core';
5 | import { v4 as uuid } from 'uuid';
6 | import DndContextWrapper from './components/dnd-context-wrapper/dnd-context-wrapper';
7 | // import { Button } from 'antd';
8 |
9 | interface SideField {
10 | id: UniqueIdentifier;
11 | label: string;
12 | type: string;
13 | required: boolean;
14 | belong: string;
15 | children?: SideField[];
16 | options: object;
17 | }
18 | const convert2Tree = (arr: SideField[]) => {
19 | const result: Record = {};
20 | arr.forEach((field) => {
21 | const { label, type, required, belong, options } = field;
22 | switch (type) {
23 | case 'radioGroup':
24 | case 'checkboxGroup':
25 | const { options: list } = options as {
26 | options: { value: string; label: string }[];
27 | };
28 | list.forEach((item) => {
29 | item.value = uuid();
30 | });
31 | break;
32 | default:
33 | break;
34 | }
35 |
36 | if (!result[belong]) {
37 | result[belong] = [];
38 | }
39 | result[belong].push({
40 | id: uuid(),
41 | label,
42 | type,
43 | required,
44 | options,
45 | belong,
46 | });
47 | });
48 | return result;
49 | };
50 |
51 | export default async function Form({
52 | params,
53 | }: {
54 | params: Promise<{ id: string }>;
55 | }) {
56 | const { id }: { id: string } = await params;
57 | try {
58 | const formPromsie = Forms.findById(id).exec();
59 | const fieldsPromise = Fields.find({}, { _id: 0 }).exec(); // 排除_id字段
60 | // 两个数据直接没有依赖 并发请求 降低瀑布流
61 | const [form, fields] = await Promise.all([formPromsie, fieldsPromise]);
62 | const formLib = convert2Tree(fields);
63 | const { formList, title } = form;
64 | const formData = {
65 | id,
66 | formList,
67 | title,
68 | formLib,
69 | };
70 |
71 | return (
72 | <>
73 | {/* */}
74 |
75 | >
76 | );
77 | } catch (error) {
78 | console.log(error);
79 | notFound();
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app/form/constants.ts:
--------------------------------------------------------------------------------
1 | const Constants = {
2 | labelMap: {
3 | textInput: '文本输入框',
4 | numberInput: '数字输入框',
5 | textarea: '文本域',
6 | selectInput: '下拉选择框',
7 | dateInput: '日期选择框',
8 | timeInput: '时间选择框',
9 | dateTimeInput: '日期时间选择框',
10 | radioInput: '单选框',
11 | checkboxInput: '多选框',
12 | uploadInput: '上传文件',
13 | uploadImgInput: '上传图片',
14 | uploadVideoInput:'上传视频',
15 | }
16 | }
17 |
18 | export default Constants
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background: #ffffff;
3 | --foreground: #171717;
4 | }
5 |
6 | @media (prefers-color-scheme: dark) {
7 | :root {
8 | --background: #0a0a0a;
9 | --foreground: #ededed;
10 | }
11 | }
12 |
13 | html,
14 | body {
15 | max-width: 100vw;
16 | height: 100%;
17 | overflow-x: hidden;
18 | }
19 |
20 | body {
21 | color: var(--foreground);
22 | background: var(--background);
23 | font-family: Arial, Helvetica, sans-serif;
24 | -webkit-font-smoothing: antialiased;
25 | -moz-osx-font-smoothing: grayscale;
26 | }
27 |
28 | * {
29 | box-sizing: border-box;
30 | padding: 0;
31 | margin: 0;
32 | }
33 |
34 | a {
35 | color: inherit;
36 | text-decoration: none;
37 | }
38 |
39 | @media (prefers-color-scheme: dark) {
40 | html {
41 | color-scheme: dark;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/home/components/sidebar/sidebar.module.css:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | width: 256px;
3 | background-color: #fff;
4 | height: 100%;
5 | border-right: 1px solid #f0f0f0;
6 | box-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);
7 | transition: all 0.3s ease;
8 | overflow: hidden;
9 | display: flex;
10 | flex-direction: column;
11 | }
12 |
13 | .header {
14 | padding: 16px 24px;
15 | border-bottom: 1px solid #f0f0f0;
16 | margin-bottom: 8px;
17 | flex-shrink: 0;
18 | }
19 |
20 | .title {
21 | margin: 0;
22 | font-size: 16px;
23 | font-weight: 600;
24 | color: rgba(0, 0, 0, 0.85);
25 | white-space: nowrap;
26 | overflow: hidden;
27 | text-overflow: ellipsis;
28 | }
29 |
30 | .menu {
31 | flex: 1;
32 | border-right: none;
33 | padding: 8px 0;
34 | overflow-y: auto;
35 | overflow-x: hidden;
36 | }
37 |
38 | .sidebar :global(.ant-menu-item a) {
39 | color: inherit;
40 | display: flex;
41 | align-items: center;
42 | gap: 8px;
43 | font-size: 14px;
44 | width: 100%;
45 | overflow: hidden;
46 | }
47 |
48 | .sidebar :global(.ant-menu-item .anticon) {
49 | font-size: 16px;
50 | flex-shrink: 0;
51 | }
52 |
53 | .sidebar :global(.ant-menu-item:hover) {
54 | background-color: rgba(0, 0, 0, 0.04) !important;
55 | }
56 |
57 | .sidebar :global(.ant-menu-item-selected) {
58 | background-color: #e6f4ff !important;
59 | color: #1677ff !important;
60 | }
61 |
62 | .sidebar :global(.ant-menu-item-selected::after) {
63 | display: none;
64 | }
65 |
66 | .sidebar :global(.ant-menu-item-selected:hover) {
67 | background-color: #e6f4ff !important;
68 | }
69 |
70 | .sidebar :global(.ant-menu-item-selected a) {
71 | color: #1677ff !important;
72 | }
73 |
74 | .sidebar :global(.ant-menu-item-selected .anticon) {
75 | color: #1677ff !important;
76 | }
77 |
78 | /* 自定义滚动条样式 */
79 | .sidebar :global(.ant-menu::-webkit-scrollbar) {
80 | width: 6px;
81 | }
82 |
83 | .sidebar :global(.ant-menu::-webkit-scrollbar-thumb) {
84 | background-color: rgba(0, 0, 0, 0.2);
85 | border-radius: 3px;
86 | }
87 |
88 | .sidebar :global(.ant-menu::-webkit-scrollbar-track) {
89 | background-color: transparent;
90 | }
91 |
--------------------------------------------------------------------------------
/app/home/components/sidebar/sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Menu } from 'antd';
4 | import { FormOutlined, AppstoreOutlined } from '@ant-design/icons';
5 | import Link from 'next/link';
6 | import { usePathname } from 'next/navigation';
7 | import styles from './sidebar.module.css';
8 |
9 | export default function Sidebar() {
10 | const pathname = usePathname();
11 |
12 | const items = [
13 | {
14 | key: '/home/template',
15 | icon: ,
16 | label: 模板,
17 | },
18 | {
19 | key: '/home/my-forms',
20 | icon: ,
21 | label: 我的表单,
22 | },
23 | ];
24 |
25 | return (
26 |
27 |
28 |
表单管理
29 |
30 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/home/layout.tsx:
--------------------------------------------------------------------------------
1 | import Sidebar from './components/sidebar/sidebar';
2 |
3 | export default function HomeLayout({
4 | children,
5 | }: {
6 | children: React.ReactNode;
7 | }) {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/home/my-forms/components/form-card/form-card.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | position: relative;
3 | padding: 16px;
4 | background: white;
5 | border-radius: 8px;
6 | padding: 1.5rem;
7 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
8 | transition: transform 0.25s ease, box-shadow 0.25s ease;
9 | }
10 |
11 | .card:hover {
12 | transform: translateY(-4px); /* Lift up slightly */
13 | box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); /* Deeper shadow */
14 | }
15 |
16 | .tooltipWrapper {
17 | position: absolute;
18 | top: 12px;
19 | right: 12px;
20 | cursor: pointer;
21 | display: none;
22 | }
23 |
24 | .card:hover .tooltipWrapper {
25 | display: flex;
26 | align-items: center;
27 | }
28 |
29 | .dataIcon {
30 | font-size: 18px;
31 | color: #666;
32 | transition: color 0.2s ease;
33 | }
34 |
35 | .dataIcon:hover {
36 | color: #0070f3;
37 | }
38 |
39 | .tooltipText {
40 | visibility: hidden;
41 | width: max-content;
42 | background: #333;
43 | color: #fff;
44 | text-align: center;
45 | border-radius: 4px;
46 | padding: 4px 8px;
47 | position: absolute;
48 | z-index: 1;
49 | bottom: 125%; /* above icon */
50 | left: 50%;
51 | margin-left: -60px;
52 | opacity: 0;
53 | transition: opacity 0.3s;
54 | white-space: nowrap;
55 | }
56 |
57 | .tooltipWrapper:hover .tooltipText {
58 | visibility: visible;
59 | opacity: 1;
60 | }
61 |
62 | .formTitle {
63 | font-size: 1.25rem;
64 | font-weight: 600;
65 | margin-bottom: 0.5rem;
66 | color: #1a1a1a;
67 | }
68 |
69 | .meta {
70 | display: flex;
71 | justify-content: space-between;
72 | margin-top: 8px;
73 | font-size: 0.875rem;
74 | color: #888;
75 | }
76 |
77 | .fields {
78 | height: 200px; /* adjust based on your layout */
79 | overflow-y: auto;
80 | padding-right: 8px; /* optional: for better visual spacing */
81 | }
82 |
83 | /* Optional: Style scrollbar for better appearance (Chrome only) */
84 |
85 | .fields::-webkit-scrollbar {
86 | width: 6px;
87 | }
88 |
89 | .fields::-webkit-scrollbar-track {
90 | background: #f1f1f1;
91 | }
92 |
93 | .fields::-webkit-scrollbar-thumb {
94 | background: #888;
95 | border-radius: 4px;
96 | }
97 |
98 | .fields::-webkit-scrollbar-thumb:hover {
99 | background: #555;
100 | }
101 |
102 | .field {
103 | padding: 0.75rem 0;
104 | border-bottom: 1px solid #f5f5f5;
105 | }
106 |
107 | .field:last-child {
108 | border-bottom: none;
109 | }
110 |
111 | .fieldHeader {
112 | display: flex;
113 | justify-content: space-between;
114 | align-items: center;
115 | }
116 |
117 | .fieldLabel {
118 | color: #333;
119 | font-size: 0.9rem;
120 | font-weight: 500;
121 | }
122 |
123 | .required {
124 | color: #ff4d4f;
125 | margin-left: 0.25rem;
126 | }
127 |
128 | .fieldType {
129 | color: #666;
130 | font-size: 0.8rem;
131 | text-transform: capitalize;
132 | background: #f5f5f5;
133 | padding: 0.25rem 0.5rem;
134 | border-radius: 4px;
135 | }
136 |
137 | .options {
138 | color: #666;
139 | font-size: 0.8rem;
140 | background: #fafafa;
141 | padding: 0.5rem;
142 | border-radius: 4px;
143 | margin-top: 0.5rem;
144 | }
145 |
--------------------------------------------------------------------------------
/app/home/my-forms/components/form-card/form-card.tsx:
--------------------------------------------------------------------------------
1 | import styles from './form-card.module.css';
2 | import FieldType from '@/app/form/[id]/components/form-item/field-types';
3 | import { BarChartOutlined, EyeOutlined } from '@ant-design/icons';
4 | import { Tooltip } from 'antd';
5 | import Link from 'next/link';
6 |
7 | type Form = {
8 | id: string;
9 | title: string;
10 | formList: FieldType[];
11 | updatedAt: Date;
12 | };
13 |
14 | const fieldTypeMap = {
15 | textInput: '文本框',
16 | textArea: '文本域',
17 | checkboxGroup: '多选框',
18 | radioGroup: '单选框',
19 | uploader: '附件',
20 | };
21 |
22 | export default function FormCard({ form }: { form: Form }) {
23 | return (
24 |
25 |
26 |
27 |
28 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
{form.title}
41 |
42 | {form.formList.map((field) => (
43 |
44 |
45 |
46 | {field.label}
47 | {field.required && (
48 | *
49 | )}
50 |
51 |
52 | {
53 | fieldTypeMap[
54 | field.type as keyof typeof fieldTypeMap
55 | ]
56 | }
57 |
58 |
59 |
60 | ))}
61 |
62 |
63 |
64 | 更新时间: {new Date(form.updatedAt).toLocaleString()}
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/app/home/my-forms/my-forms.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | max-width: 1200px;
3 | margin: 2rem auto;
4 | padding: 0 1rem;
5 | }
6 |
7 | .title {
8 | font-size: 2rem;
9 | font-weight: 600;
10 | margin-bottom: 2rem;
11 | color: #1a1a1a;
12 | }
13 |
14 | .grid {
15 | display: grid;
16 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
17 | gap: 1.5rem;
18 | }
19 |
20 | .empty {
21 | text-align: center;
22 | color: #666;
23 | padding: 2rem;
24 | border: 1px dashed #ddd;
25 | border-radius: 8px;
26 | }
27 |
28 | .error {
29 | color: #ff4d4f;
30 | text-align: center;
31 | padding: 2rem;
32 | }
33 |
--------------------------------------------------------------------------------
/app/home/my-forms/page.tsx:
--------------------------------------------------------------------------------
1 | import styles from './my-forms.module.css';
2 | import { cookies } from 'next/headers';
3 | import dbConnect from '@/lib/mongodb';
4 | import FormCard from './components/form-card/form-card';
5 | import Forms from '@/lib/form';
6 |
7 | export default async function MyForms() {
8 | const cookieStore = await cookies();
9 | const userId = cookieStore.get('userid')?.value;
10 |
11 | if (!userId) {
12 | return 请先登录
;
13 | }
14 |
15 | await dbConnect();
16 | const forms = await Forms.find({ userId });
17 | const pureForms = forms.map((form) => {
18 | const { _id, title, formList, updatedAt } = form;
19 | return {
20 | id: _id.toString(),
21 | title,
22 | formList,
23 | updatedAt,
24 | };
25 | });
26 |
27 | return (
28 |
29 |
我的表单
30 | {pureForms.length === 0 ? (
31 |
您还没有创建任何表单
32 | ) : (
33 |
34 | {pureForms.map((form) => (
35 |
36 | ))}
37 |
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/app/home/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function Home() {
4 | redirect('/home/template');
5 | }
6 |
--------------------------------------------------------------------------------
/app/home/template/components/generate-template/generate-template.module.css:
--------------------------------------------------------------------------------
1 | .promptContainer {
2 | display: flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | margin: 32px 24px;
6 | padding: 16px 24px;
7 | background: linear-gradient(135deg, #f0f7ff 0%, #e6f3ff 100%);
8 | border-radius: 12px;
9 | border: 1px solid rgba(74, 108, 247, 0.1);
10 | position: relative;
11 | overflow: hidden;
12 | transition: all 0.3s ease;
13 | }
14 |
15 | .promptContainer::before {
16 | content: '';
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | right: 0;
21 | bottom: 0;
22 | background: linear-gradient(
23 | 45deg,
24 | rgba(74, 108, 247, 0.05) 0%,
25 | rgba(255, 255, 255, 0.1) 100%
26 | );
27 | z-index: 0;
28 | }
29 |
30 | .promptContainer::after {
31 | content: '';
32 | position: absolute;
33 | top: 0;
34 | right: 0;
35 | width: 200px;
36 | height: 100%;
37 | background: radial-gradient(
38 | circle at right center,
39 | rgba(74, 108, 247, 0.1) 0%,
40 | transparent 100%
41 | );
42 | z-index: 0;
43 | }
44 |
45 | .promptContainer:hover {
46 | background: linear-gradient(135deg, #e6f3ff 0%, #d9edff 100%);
47 | border-color: rgba(74, 108, 247, 0.2);
48 | transform: translateY(-2px);
49 | box-shadow: 0 4px 12px rgba(74, 108, 247, 0.1);
50 | }
51 |
52 | .promptText {
53 | font-size: 18px;
54 | color: #2c3e50;
55 | font-weight: 600;
56 | position: relative;
57 | z-index: 1;
58 | display: flex;
59 | align-items: center;
60 | gap: 12px;
61 | }
62 |
63 | .promptText::before {
64 | content: '✨';
65 | font-size: 20px;
66 | }
67 |
68 | .generateButton {
69 | padding: 8px 20px;
70 | height: 40px;
71 | font-size: 16px;
72 | color: #fff;
73 | background: linear-gradient(135deg, #4a6cf7 0%, #2541b2 100%);
74 | border: none;
75 | border-radius: 8px;
76 | font-weight: 500;
77 | position: relative;
78 | z-index: 1;
79 | transition: all 0.3s ease;
80 | box-shadow: 0 2px 4px rgba(74, 108, 247, 0.2);
81 | display: flex;
82 | align-items: center;
83 | gap: 8px;
84 | }
85 |
86 | .generateButton:hover {
87 | background: linear-gradient(135deg, #2541b2 0%, #1a2b6b 100%);
88 | transform: translateY(-1px);
89 | box-shadow: 0 4px 8px rgba(74, 108, 247, 0.3);
90 | }
91 |
92 | .modal :global(.ant-modal-header) {
93 | margin: 0;
94 | padding: 0;
95 | background: transparent;
96 | border: none;
97 | }
98 |
99 | .modal :global(.ant-modal-title) {
100 | font-size: 0;
101 | }
102 |
103 | .modal :global(.ant-modal-close) {
104 | display: none;
105 | }
106 |
107 | .modal :global(.ant-modal-body) {
108 | padding: 0;
109 | }
110 |
111 | .modalContent {
112 | display: flex;
113 | flex-direction: column;
114 | background: #fff;
115 | position: relative;
116 | overflow: hidden;
117 | }
118 |
119 | .header {
120 | padding: 24px 32px;
121 | background: linear-gradient(135deg, #f6f8ff 0%, #f0f4ff 100%);
122 | position: relative;
123 | overflow: hidden;
124 | border-radius: 24px 24px 0 0;
125 | box-shadow: 0 2px 4px rgba(74, 108, 247, 0.05);
126 | margin-bottom: 16px;
127 | }
128 |
129 | .header::after {
130 | content: '';
131 | position: absolute;
132 | bottom: 0;
133 | left: 0;
134 | right: 0;
135 | height: 1px;
136 | background: linear-gradient(
137 | 90deg,
138 | transparent,
139 | rgba(74, 108, 247, 0.1),
140 | transparent
141 | );
142 | }
143 |
144 | .title {
145 | font-size: 22px;
146 | font-weight: 600;
147 | color: #1a1a1a;
148 | margin-bottom: 8px;
149 | display: flex;
150 | align-items: center;
151 | gap: 12px;
152 | position: relative;
153 | }
154 |
155 | .subtitle {
156 | font-size: 14px;
157 | color: #666;
158 | margin: 0;
159 | line-height: 1.5;
160 | }
161 |
162 | .form {
163 | flex: 1;
164 | display: flex;
165 | flex-direction: column;
166 | padding: 20px 24px;
167 | background: #fff;
168 | min-height: 0;
169 | gap: 16px;
170 | }
171 |
172 | .formItem {
173 | flex: 1;
174 | display: flex;
175 | flex-direction: column;
176 | background: #fafafa;
177 | border-radius: 12px;
178 | padding: 16px;
179 | transition: all 0.3s ease;
180 | min-height: 0;
181 | border: 1px solid rgba(74, 108, 247, 0.08);
182 | }
183 |
184 | .formItem:focus-within {
185 | background: #fff;
186 | box-shadow: 0 4px 16px rgba(74, 108, 247, 0.1);
187 | border-color: rgba(74, 108, 247, 0.2);
188 | }
189 |
190 | .textArea {
191 | flex: 1;
192 | resize: none;
193 | border: none;
194 | outline: none;
195 | font-size: 14px;
196 | line-height: 1.6;
197 | color: #1a1a1a;
198 | background: transparent;
199 | padding: 8px;
200 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
201 | 'Helvetica Neue', Arial, sans-serif;
202 | min-height: 0;
203 | }
204 |
205 | .textArea::placeholder {
206 | color: #999;
207 | font-size: 14px;
208 | font-style: italic;
209 | }
210 |
211 | .footer {
212 | padding: 16px 24px;
213 | background: #fff;
214 | display: flex;
215 | justify-content: flex-end;
216 | gap: 8px;
217 | border-top: 1px solid rgba(74, 108, 247, 0.05);
218 | border-radius: 0 0 24px 24px;
219 | }
220 |
221 | .cancelButton {
222 | height: 44px;
223 | padding: 0 24px;
224 | font-size: 15px;
225 | font-weight: 500;
226 | color: #666;
227 | background: #f5f5f5;
228 | border: none;
229 | border-radius: 12px;
230 | transition: all 0.3s ease;
231 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
232 | }
233 |
234 | .cancelButton:hover {
235 | background: #eee;
236 | color: #333;
237 | transform: translateY(-1px);
238 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
239 | }
240 |
241 | .submitButton {
242 | height: 44px;
243 | padding: 0 28px;
244 | font-size: 15px;
245 | font-weight: 500;
246 | background: linear-gradient(135deg, #4a6cf7 0%, #2541b2 100%);
247 | border: none;
248 | border-radius: 12px;
249 | color: #fff;
250 | display: flex;
251 | align-items: center;
252 | gap: 8px;
253 | transition: all 0.3s ease;
254 | box-shadow: 0 4px 12px rgba(74, 108, 247, 0.2);
255 | }
256 |
257 | .submitButton:hover {
258 | background: linear-gradient(135deg, #2541b2 0%, #1a2b6b 100%);
259 | transform: translateY(-1px);
260 | box-shadow: 0 6px 16px rgba(74, 108, 247, 0.3);
261 | }
262 |
263 | .submitButton:active {
264 | transform: translateY(0);
265 | box-shadow: 0 2px 4px rgba(74, 108, 247, 0.2);
266 | }
267 |
--------------------------------------------------------------------------------
/app/home/template/components/generate-template/generate-template.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { Button, Modal, Form, Input, Typography } from 'antd';
5 | import {
6 | RocketOutlined,
7 | BulbOutlined,
8 | SendOutlined,
9 | RobotOutlined,
10 | CloseOutlined,
11 | } from '@ant-design/icons';
12 | import styles from './generate-template.module.css';
13 | import useMessage from 'antd/es/message/useMessage';
14 | import FieldType from '@/app/form/[id]/components/form-item/field-types';
15 | import { v4 as uuid } from 'uuid';
16 | import { useRouter } from 'next/navigation';
17 |
18 | const { TextArea } = Input;
19 | const { Text, Title } = Typography;
20 |
21 | interface FormValues {
22 | requirements: string;
23 | }
24 |
25 | export default function GenerateTemplate() {
26 | const [isModalOpen, setIsModalOpen] = useState(false);
27 | const [isGenerating, setIsGenerating] = useState(false);
28 | const [form] = Form.useForm();
29 | const [messageApi, contextHolder] = useMessage();
30 |
31 | const router = useRouter();
32 |
33 | const showModal = () => {
34 | setIsModalOpen(true);
35 | };
36 |
37 | const handleCancel = () => {
38 | setIsModalOpen(false);
39 | form.resetFields();
40 | };
41 |
42 | const generateId = (fields: FieldType[]) => {
43 | return fields.map((field) => ({ ...field, id: uuid() }));
44 | };
45 |
46 | const handleSubmit = async () => {
47 | form.validateFields().then(async (values) => {
48 | setIsGenerating(true);
49 | handleCancel();
50 | try {
51 | const res = await fetch('/api/ai-chat', {
52 | method: 'POST',
53 | headers: {
54 | 'Content-Type': 'application/json',
55 | },
56 | body: JSON.stringify({
57 | requirements: values.requirements,
58 | }),
59 | });
60 | if (!res.ok) {
61 | const response = await res.json();
62 | throw new Error(response.error);
63 | }
64 | const response = await res.json();
65 | const { formTitle, formList } = response;
66 | messageApi.success('模板生成成功,正在跳转至详情页');
67 | const fullFields = generateId(formList);
68 | const formRes = await fetch('/api/form', {
69 | method: 'POST',
70 | headers: {
71 | 'Content-Type': 'application/json',
72 | },
73 | body: JSON.stringify({
74 | title: formTitle,
75 | fields: fullFields,
76 | }),
77 | });
78 | if (!formRes.ok) {
79 | throw new Error('创建表单失败');
80 | }
81 | const form = await formRes.json();
82 | if (form.id) {
83 | router.push(`/form/${form.id}`);
84 | } else {
85 | throw new Error('创建表单失败');
86 | }
87 | } catch (error: any) {
88 | if (error.message) {
89 | messageApi.error(`生成失败,${error.message}`);
90 | }
91 | } finally {
92 | setIsGenerating(false);
93 | }
94 | });
95 | };
96 |
97 | return (
98 | <>
99 | {contextHolder}
100 |
101 |
102 |
105 | 找不到合适的模板?试试我们的 AI 助手
106 |
107 | }
112 | loading={isGenerating}
113 | >
114 | {isGenerating ? '生成中...' : '立即生成专属模板'}
115 |
116 |
117 |
118 |
127 |
128 |
129 |
130 |
133 | AI 助手
134 |
135 |
136 | 请输入您的表单需求,AI 将为您生成专属模板
137 |
138 |
139 |
140 |
151 |
158 |
159 |
160 |
161 |
162 | }
166 | disabled={isGenerating}
167 | >
168 | 取消
169 |
170 | }
175 | loading={isGenerating}
176 | >
177 | {isGenerating ? '生成中...' : '生成模板'}
178 |
179 |
180 |
181 |
182 | >
183 | );
184 | }
185 |
--------------------------------------------------------------------------------
/app/home/template/components/template-card/index.ts:
--------------------------------------------------------------------------------
1 | import TemplateCard from "./template-card";
2 |
3 | export default TemplateCard;
4 |
--------------------------------------------------------------------------------
/app/home/template/components/template-card/template-card.module.css:
--------------------------------------------------------------------------------
1 | .card {
2 | background-color: #fff;
3 | border-radius: 8px;
4 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
5 | transition: all 0.3s ease;
6 | height: 100%;
7 | min-height: 160px;
8 | display: flex;
9 | flex-direction: column;
10 | }
11 |
12 | .card:hover {
13 | transform: translateY(-4px);
14 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
15 | }
16 |
17 | .cardContent {
18 | display: flex;
19 | flex-direction: column;
20 | gap: 6px;
21 | flex: 1;
22 | height: 100%;
23 | padding: 8px;
24 | }
25 |
26 | .cardHeader {
27 | display: flex;
28 | justify-content: space-between;
29 | align-items: center;
30 | gap: 8px;
31 | min-height: 28px;
32 | }
33 |
34 | .cardTitle {
35 | font-size: 16px;
36 | font-weight: 500;
37 | color: rgba(0, 0, 0, 0.85);
38 | line-height: 1.5;
39 | display: flex;
40 | align-items: center;
41 | gap: 8px;
42 | flex: 1;
43 | }
44 |
45 | .cardTitle :global(h4) {
46 | margin: 0;
47 | }
48 |
49 | .fieldList {
50 | display: flex;
51 | flex-wrap: wrap;
52 | min-height: 0;
53 | flex: 1;
54 | max-height: 80px;
55 | overflow: hidden;
56 | }
57 |
58 | .cardFooter {
59 | margin-top: auto;
60 | border-top: 1px solid #f0f0f0;
61 | display: flex;
62 | justify-content: flex-end;
63 | align-items: center;
64 | min-height: 32px;
65 | }
66 |
67 | .card :global(.ant-card) {
68 | padding: 0 !important;
69 | }
70 |
71 | .card :global(.ant-card-body) {
72 | padding: 0 !important;
73 | height: 100%;
74 | }
75 |
--------------------------------------------------------------------------------
/app/home/template/components/template-card/template-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState } from 'react';
4 | import { Card, Tag, Typography, Space, Button } from 'antd';
5 | import {
6 | FormOutlined,
7 | PlusOutlined,
8 | DownOutlined,
9 | UpOutlined,
10 | } from '@ant-design/icons';
11 | import FieldType from '@/app/form/[id]/components/form-item/field-types';
12 | import { v4 as uuid } from 'uuid';
13 | import { useRouter } from 'next/navigation';
14 | import styles from './template-card.module.css';
15 |
16 | const { Text } = Typography;
17 |
18 | interface TemplateCardProps {
19 | name: string;
20 | fields: FieldType[];
21 | }
22 |
23 | const MAX_VISIBLE_FIELDS = 6;
24 |
25 | function TemplateCard({ name, fields }: TemplateCardProps) {
26 | const router = useRouter();
27 | const [isExpanded, setIsExpanded] = useState(false);
28 |
29 | const generateId = (fields: FieldType[]) => {
30 | return fields.map((field) => ({ ...field, id: uuid() }));
31 | };
32 |
33 | const handleTemplateSelect = async (fields: FieldType[]) => {
34 | const fullFields = generateId(fields);
35 | const res = await fetch('/api/form', {
36 | method: 'POST',
37 | headers: {
38 | 'Content-Type': 'application/json',
39 | },
40 | body: JSON.stringify({ title: name, fields: fullFields }),
41 | });
42 | if (!res.ok) {
43 | throw new Error('Failed to create form');
44 | }
45 | const response = await res.json();
46 | if (response.id) {
47 | router.push(`/form/${response.id}`);
48 | } else {
49 | throw new Error('Failed to create form');
50 | }
51 | };
52 |
53 | const visibleFields = isExpanded
54 | ? fields
55 | : fields.slice(0, MAX_VISIBLE_FIELDS);
56 | const hasMoreFields = fields.length > MAX_VISIBLE_FIELDS;
57 |
58 | return (
59 | handleTemplateSelect(fields)}
63 | >
64 |
65 |
71 |
72 |
73 | {visibleFields.map((field, index) => (
74 |
75 | {field.label}
76 |
77 | ))}
78 |
79 | {hasMoreFields && (
80 |
98 | )}
99 |
100 |
101 |
{fields.length} 个字段
102 |
103 |
104 |
105 |
106 | );
107 | }
108 |
109 | export default TemplateCard;
110 |
--------------------------------------------------------------------------------
/app/home/template/components/template-list/index.ts:
--------------------------------------------------------------------------------
1 | import TemplateList from './template-list';
2 |
3 | export default TemplateList;
4 |
--------------------------------------------------------------------------------
/app/home/template/components/template-list/template-list-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import styles from './template-list.module.css';
2 |
3 | export default function TemplateListSkeleton({ count = 50 }) {
4 | return (
5 |
6 | {Array.from({ length: count }).map((_, index) => (
7 |
25 | ))}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/app/home/template/components/template-list/template-list.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | flex: 1;
3 | display: grid;
4 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
5 | gap: 24px;
6 | padding: 24px;
7 | overflow-y: auto;
8 | max-height: calc(100vh - 200px);
9 | align-content: start;
10 | }
11 |
12 | .skeleton {
13 | background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
14 | border-radius: 4px;
15 | background-size: 400% 100%;
16 | animation: loading 1.5s infinite;
17 | }
18 |
19 | @keyframes loading {
20 | 0% {
21 | background-position: 100% 0;
22 | }
23 | 100% {
24 | background-position: -100% 0;
25 | }
26 | }
27 |
28 | .card {
29 | padding: 16px;
30 | border: 1px solid #ddd;
31 | border-radius: 8px;
32 | transition: all 0.3s ease;
33 | }
34 |
35 | .cardHeader .title {
36 | margin-bottom: 10px;
37 | width: 60%;
38 | height: 24px;
39 | }
40 |
41 | .cardBody .line {
42 | width: 100%;
43 | height: 12px;
44 | margin-bottom: 8px;
45 | }
46 |
47 | .cardBody .line.wide {
48 | width: 80%;
49 | }
50 |
--------------------------------------------------------------------------------
/app/home/template/components/template-list/template-list.tsx:
--------------------------------------------------------------------------------
1 | import { use } from 'react';
2 | import TemplateCard from '../template-card';
3 | import styles from './template-list.module.css';
4 |
5 | export default function TemplateList({
6 | templates,
7 | }: {
8 | templates: Promise;
9 | }) {
10 | const allTemplates = use(templates);
11 | return (
12 |
13 | {allTemplates.map((item) => (
14 |
19 | ))}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/home/template/page.module.css:
--------------------------------------------------------------------------------
1 | .layout {
2 | display: flex;
3 | height: 100%;
4 | background-color: #f5f5f5;
5 | }
6 |
7 | .main {
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | background-color: #fff;
12 | border-radius: 12px;
13 | margin: 24px;
14 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
15 | overflow: hidden;
16 | }
17 |
18 | .header {
19 | padding: 24px;
20 | background-color: #fff;
21 | border-bottom: 1px solid #f0f0f0;
22 | }
23 |
24 | .title {
25 | font-size: 28px;
26 | font-weight: 600;
27 | margin-bottom: 8px;
28 | color: rgba(0, 0, 0, 0.85);
29 | }
30 |
31 | .description {
32 | color: rgba(0, 0, 0, 0.45);
33 | font-size: 16px;
34 | line-height: 1.5;
35 | }
36 |
37 | /* 响应式布局 */
38 | @media (max-width: 1200px) {
39 | .main {
40 | margin: 16px;
41 | }
42 |
43 | .container {
44 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
45 | gap: 16px;
46 | padding: 16px;
47 | }
48 | }
49 |
50 | @media (max-width: 768px) {
51 | .content {
52 | margin-left: 0;
53 | }
54 |
55 | .main {
56 | margin: 0;
57 | border-radius: 0;
58 | }
59 |
60 | .header {
61 | padding: 16px;
62 | }
63 |
64 | .title {
65 | font-size: 24px;
66 | }
67 |
68 | .description {
69 | font-size: 14px;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/home/template/page.tsx:
--------------------------------------------------------------------------------
1 | import dbConnect from '@/lib/mongodb';
2 | import Templates from '@/lib/template';
3 | import GenerateTemplate from './components/generate-template/generate-template';
4 | import styles from './page.module.css';
5 | import TemplateList from './components/template-list';
6 | import { Suspense } from 'react';
7 | import TemplateListSkeleton from './components/template-list/template-list-skeleton';
8 |
9 | export default async function Template() {
10 | await dbConnect();
11 | // 不再使用await而是直接将promise作为参数传入子组件
12 | // 子组件使用use消费promise,从而使得子组件可以等待异步任务完成后再进行渲染
13 | // 为嵌套在外层的suspense生效
14 | const templates = Templates.find().exec();
15 |
16 | return (
17 |
18 |
19 |
25 |
26 |
}>
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css';
2 | import Header from '@/app/components/header/header';
3 |
4 | import { Geist, Geist_Mono } from 'next/font/google';
5 | import { AntdRegistry } from '@ant-design/nextjs-registry';
6 |
7 | const geistSans = Geist({
8 | variable: '--font-geist-sans',
9 | subsets: ['latin'],
10 | });
11 |
12 | const geistMono = Geist_Mono({
13 | variable: '--font-geist-mono',
14 | subsets: ['latin'],
15 | });
16 |
17 | export default function RootLayout({
18 | children,
19 | }: {
20 | children: React.ReactNode;
21 | }) {
22 | return (
23 |
27 |
28 |
29 |
30 |
36 | {children}
37 |
38 | {/* 避开固定 header 的高度 */}
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/app/page.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | --gray-rgb: 0, 0, 0;
3 | --gray-alpha-200: rgba(var(--gray-rgb), 0.08);
4 | --gray-alpha-100: rgba(var(--gray-rgb), 0.05);
5 |
6 | --button-primary-hover: #383838;
7 | --button-secondary-hover: #f2f2f2;
8 |
9 | display: grid;
10 | grid-template-rows: 20px 1fr 20px;
11 | align-items: center;
12 | justify-items: center;
13 | min-height: 100svh;
14 | padding: 80px;
15 | gap: 64px;
16 | font-family: var(--font-geist-sans);
17 | }
18 |
19 | @media (prefers-color-scheme: dark) {
20 | .page {
21 | --gray-rgb: 255, 255, 255;
22 | --gray-alpha-200: rgba(var(--gray-rgb), 0.145);
23 | --gray-alpha-100: rgba(var(--gray-rgb), 0.06);
24 |
25 | --button-primary-hover: #ccc;
26 | --button-secondary-hover: #1a1a1a;
27 | }
28 | }
29 |
30 | .main {
31 | display: flex;
32 | flex-direction: column;
33 | gap: 32px;
34 | grid-row-start: 2;
35 | }
36 |
37 | .main ol {
38 | font-family: var(--font-geist-mono);
39 | padding-left: 0;
40 | margin: 0;
41 | font-size: 14px;
42 | line-height: 24px;
43 | letter-spacing: -0.01em;
44 | list-style-position: inside;
45 | }
46 |
47 | .main li:not(:last-of-type) {
48 | margin-bottom: 8px;
49 | }
50 |
51 | .main code {
52 | font-family: inherit;
53 | background: var(--gray-alpha-100);
54 | padding: 2px 4px;
55 | border-radius: 4px;
56 | font-weight: 600;
57 | }
58 |
59 | .ctas {
60 | display: flex;
61 | gap: 16px;
62 | }
63 |
64 | .ctas a {
65 | appearance: none;
66 | border-radius: 128px;
67 | height: 48px;
68 | padding: 0 20px;
69 | border: none;
70 | border: 1px solid transparent;
71 | transition:
72 | background 0.2s,
73 | color 0.2s,
74 | border-color 0.2s;
75 | cursor: pointer;
76 | display: flex;
77 | align-items: center;
78 | justify-content: center;
79 | font-size: 16px;
80 | line-height: 20px;
81 | font-weight: 500;
82 | }
83 |
84 | a.primary {
85 | background: var(--foreground);
86 | color: var(--background);
87 | gap: 8px;
88 | }
89 |
90 | a.secondary {
91 | border-color: var(--gray-alpha-200);
92 | min-width: 158px;
93 | }
94 |
95 | .footer {
96 | grid-row-start: 3;
97 | display: flex;
98 | gap: 24px;
99 | }
100 |
101 | .footer a {
102 | display: flex;
103 | align-items: center;
104 | gap: 8px;
105 | }
106 |
107 | .footer img {
108 | flex-shrink: 0;
109 | }
110 |
111 | /* Enable hover only on non-touch devices */
112 | @media (hover: hover) and (pointer: fine) {
113 | a.primary:hover {
114 | background: var(--button-primary-hover);
115 | border-color: transparent;
116 | }
117 |
118 | a.secondary:hover {
119 | background: var(--button-secondary-hover);
120 | border-color: transparent;
121 | }
122 |
123 | .footer a:hover {
124 | text-decoration: underline;
125 | text-underline-offset: 4px;
126 | }
127 | }
128 |
129 | @media (max-width: 600px) {
130 | .page {
131 | padding: 32px;
132 | padding-bottom: 80px;
133 | }
134 |
135 | .main {
136 | align-items: center;
137 | }
138 |
139 | .main ol {
140 | text-align: center;
141 | }
142 |
143 | .ctas {
144 | flex-direction: column;
145 | }
146 |
147 | .ctas a {
148 | font-size: 14px;
149 | height: 40px;
150 | padding: 0 16px;
151 | }
152 |
153 | a.secondary {
154 | min-width: auto;
155 | }
156 |
157 | .footer {
158 | flex-wrap: wrap;
159 | align-items: center;
160 | justify-content: center;
161 | }
162 | }
163 |
164 | @media (prefers-color-scheme: dark) {
165 | .logo {
166 | filter: invert();
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import '@ant-design/v5-patch-for-react-19';
2 | import { redirect } from 'next/navigation';
3 |
4 | export default function Home() {
5 | redirect('/home');
6 | }
7 |
--------------------------------------------------------------------------------
/app/share/[id]/page.module.css:
--------------------------------------------------------------------------------
1 | .page {
2 | width: 100%;
3 | height: 100%;
4 | overflow: hidden;
5 | }
6 |
7 | .formContainer {
8 | padding: 16px;
9 | border-radius: 8px;
10 | overflow-y: auto;
11 | box-shadow: 0 6px 24px #1f232914;
12 | width: 100%;
13 | max-width: 800px;
14 | height: calc(100% - 48px * 2);
15 | margin: 48px auto;
16 | padding: 24px;
17 | background-color: #fff;
18 | }
19 |
--------------------------------------------------------------------------------
/app/share/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import PreviewForm from '@/app/form/[id]/components/preview-form/preview-form';
2 | import Forms from '@/lib/form';
3 | import styles from '@/app/share/[id]/page.module.css';
4 | import dbConnect from '@/lib/mongodb';
5 |
6 | export default async function Share({ params }: { params: { id: string } }) {
7 | const { id } = await params;
8 |
9 | try {
10 | await dbConnect();
11 | const formData = await Forms.findById(id);
12 | const { formList, title } = formData;
13 |
14 | return (
15 |
21 | );
22 | } catch (error) {
23 | console.error('Error fetching form data:', error);
24 | return 表单加载失败,请稍后再试
;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/auth.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 | import GitHub from 'next-auth/providers/github';
3 |
4 | export const { handlers, signIn, signOut, auth } = NextAuth({
5 | providers: [GitHub],
6 | });
7 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from 'path';
2 | import { fileURLToPath } from 'url';
3 | import { FlatCompat } from '@eslint/eslintrc';
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends('next/core-web-vitals', 'next/typescript'),
14 | {
15 | rules: {
16 | '@typescript-eslint/no-explicit-any': 'off',
17 | '@typescript-eslint/no-empty-object-type': 'off',
18 | '@typescript-eslint/no-unused-vars': 'off',
19 | },
20 | },
21 | ];
22 |
23 | export default eslintConfig;
24 |
--------------------------------------------------------------------------------
/lib/field.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema, Document } from 'mongoose';
2 |
3 | interface FieldDocument extends Document {
4 | belong: string;
5 | label: string;
6 | type: string;
7 | required: boolean;
8 | options: object;
9 | }
10 |
11 | const fieldSchema: Schema = new mongoose.Schema({
12 | belong: {
13 | type: String,
14 | required: true,
15 | },
16 | label: {
17 | type: String,
18 | required: true,
19 | },
20 | type: {
21 | type: String,
22 | required: true,
23 | },
24 | required: {
25 | type: Boolean,
26 | default: true,
27 | },
28 | options: {
29 | type: Object,
30 | default: {},
31 | },
32 | });
33 |
34 | const Fields = mongoose.models.Fields || mongoose.model('Fields', fieldSchema);
35 |
36 | export default Fields;
37 |
--------------------------------------------------------------------------------
/lib/form.ts:
--------------------------------------------------------------------------------
1 | import FieldType from '@/app/form/[id]/components/form-item/field-types';
2 | import mongoose, { Document, Schema } from 'mongoose';
3 |
4 | // Define the structure of the form document
5 | interface FormDocument extends Document {
6 | userId: string;
7 | title: string;
8 | formList: FieldType[];
9 | }
10 |
11 | // Create a Mongoose schema for the form collection
12 | const formSchema: Schema = new Schema(
13 | {
14 | userId: {
15 | type: String,
16 | required: true,
17 | },
18 | title: {
19 | type: String,
20 | required: true,
21 | },
22 | formList: {
23 | type: [Object],
24 | required: true,
25 | },
26 | },
27 | {
28 | timestamps: true, // Automatically adds createdAt and updatedAt fields
29 | }
30 | );
31 |
32 | // Create a Mongoose model using the schema
33 | const Forms =
34 | mongoose.models.Forms || mongoose.model('Forms', formSchema);
35 |
36 | export default Forms;
37 |
--------------------------------------------------------------------------------
/lib/mongodb.ts:
--------------------------------------------------------------------------------
1 | // lib/mongodb.ts
2 | import mongoose from 'mongoose';
3 |
4 | const MONGODB_URI = process.env.MONGODB_URL as string;
5 |
6 | if (!MONGODB_URI) {
7 | throw new Error(
8 | 'Please define the MONGODB_URI environment variable inside .env.local'
9 | );
10 | }
11 |
12 | /**
13 | * Global is used here to maintain a cached connection across hot reloads
14 | * in development. This prevents connections growing exponentially
15 | * during API Route usage.
16 | */
17 | let cached = global.mongoose;
18 |
19 | if (!cached) {
20 | cached = global.mongoose = { conn: null, promise: null };
21 | }
22 |
23 | async function dbConnect() {
24 | if (cached.conn) {
25 | return cached.conn;
26 | }
27 |
28 | if (!cached.promise) {
29 | const opts = {
30 | bufferCommands: false,
31 | };
32 |
33 | cached.promise = mongoose
34 | .connect(MONGODB_URI, opts)
35 | .then((mongoose) => {
36 | return mongoose;
37 | });
38 | }
39 |
40 | try {
41 | cached.conn = await cached.promise;
42 | } catch (e) {
43 | cached.promise = null;
44 | throw e;
45 | }
46 |
47 | return cached.conn;
48 | }
49 |
50 | export default dbConnect;
51 |
--------------------------------------------------------------------------------
/lib/submission.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from 'mongoose';
2 |
3 | const submissionSchema = new Schema({
4 | formId: {
5 | type: String,
6 | required: true,
7 | },
8 | userId: {
9 | type: String || undefined,
10 | required: false,
11 | },
12 | formData: {
13 | type: Array,
14 | required: true,
15 | },
16 | });
17 |
18 | const Submissions =
19 | mongoose.models.Submissions ||
20 | mongoose.model('Submissions', submissionSchema);
21 |
22 | export default Submissions;
23 |
--------------------------------------------------------------------------------
/lib/template.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const templateSchema = new mongoose.Schema({
4 | name: {
5 | type: String,
6 | required: true,
7 | trim: true,
8 | },
9 | structure: {
10 | title: {
11 | type: String,
12 | required: true,
13 | trim: true,
14 | },
15 | formList: {
16 | type: Array,
17 | required: true,
18 | trim: true,
19 | default: [],
20 | },
21 | },
22 | });
23 |
24 | const Templates =
25 | mongoose.models.Templates || mongoose.model("Templates", templateSchema);
26 |
27 | export default Templates;
28 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 | import { v4 as uuid } from 'uuid';
3 |
4 | // 全局登录校验
5 | export function middleware(request: NextRequest) {
6 | const userid = request.cookies.get('userid')?.value;
7 |
8 | if (!userid) {
9 | const newUserId = uuid();
10 |
11 | const response = NextResponse.next();
12 | response.cookies.set('userid', newUserId, {
13 | maxAge: 60 * 60 * 24 * 365, // 1 year
14 | httpOnly: true,
15 | path: '/',
16 | sameSite: 'strict',
17 | });
18 |
19 | return response;
20 | }
21 |
22 | return NextResponse.next();
23 | }
24 |
25 | export const config = {
26 | matcher: '/:path*',
27 | };
28 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | reactStrictMode: false, // 关闭严格模式防止影响debug
6 | typescript: {
7 | ignoreBuildErrors: true,
8 | },
9 | };
10 |
11 | export default nextConfig;
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "smart-form",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ant-design/icons": "^5.6.1",
13 | "@ant-design/nextjs-registry": "^1.0.2",
14 | "@ant-design/v5-patch-for-react-19": "^1.0.3",
15 | "@dnd-kit/core": "^6.3.1",
16 | "@dnd-kit/modifiers": "^9.0.0",
17 | "@dnd-kit/sortable": "^10.0.0",
18 | "@reduxjs/toolkit": "^2.7.0",
19 | "antd": "^5.24.6",
20 | "cookie": "^1.0.2",
21 | "immer": "^10.1.1",
22 | "lodash": "^4.17.21",
23 | "mongoose": "^8.13.2",
24 | "next": "15.2.4",
25 | "next-auth": "^5.0.0-beta.28",
26 | "openai": "^4.97.0",
27 | "react": "^19.0.0",
28 | "react-dom": "^19.0.0",
29 | "react-redux": "^9.2.0",
30 | "uuid": "^11.1.0"
31 | },
32 | "devDependencies": {
33 | "@eslint/eslintrc": "^3",
34 | "@types/formidable": "^3.4.5",
35 | "@types/lodash": "^4.17.16",
36 | "@types/node": "^20",
37 | "@types/react": "^19",
38 | "@types/react-dom": "^19",
39 | "eslint": "^9",
40 | "eslint-config-next": "15.2.4",
41 | "typescript": "^5"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/store/field.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { SortableItemProps } from '@/app/form/[id]/components/sortable-item/sortable-item';
3 |
4 | // 定义 state 类型
5 | export interface FieldState {
6 | formLib: Record;
7 | }
8 |
9 | // 初始化状态
10 | const initialState: FieldState = {
11 | formLib: {},
12 | };
13 |
14 | // 创建 slice
15 | const fieldSlice = createSlice({
16 | name: 'field',
17 | initialState,
18 | reducers: {
19 | // setFormLib 支持直接设置值或传入一个更新函数
20 | setFormLib(
21 | state,
22 | action: PayloadAction<
23 | | Record
24 | | ((
25 | prev: Record
26 | ) => Record)
27 | >
28 | ) {
29 | if (typeof action.payload === 'function') {
30 | return {
31 | ...state,
32 | formLib: action.payload(state.formLib),
33 | };
34 | } else {
35 | return {
36 | ...state,
37 | formLib: action.payload,
38 | };
39 | }
40 | },
41 | },
42 | });
43 |
44 | // 导出 action 和 reducer
45 | export const { setFormLib } = fieldSlice.actions;
46 | export default fieldSlice.reducer;
47 |
--------------------------------------------------------------------------------
/store/form.ts:
--------------------------------------------------------------------------------
1 | import { SortableItemProps } from '@/app/form/[id]/components/sortable-item/sortable-item';
2 | import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
3 | import { RootState } from '.';
4 | import { UniqueIdentifier } from '@dnd-kit/core';
5 | import FieldType from '@/app/form/[id]/components/form-item/field-types';
6 | import HistoryManager from '@/utils/history-manager';
7 |
8 | interface FormState {
9 | formId: string | undefined;
10 | formTitle: string;
11 | formList: SortableItemProps[];
12 | editable: boolean;
13 | }
14 |
15 | interface updatedFormListParams {
16 | formList: SortableItemProps[];
17 | id: UniqueIdentifier;
18 | updated: Partial;
19 | }
20 |
21 | export const updateFormList = ({
22 | formList,
23 | id,
24 | updated,
25 | }: updatedFormListParams): SortableItemProps[] => {
26 | const itemIndex = formList.findIndex((item) => item.id === id);
27 | if (itemIndex !== -1) {
28 | const updatedFormList = formList.map((item, index) =>
29 | index === itemIndex ? { ...item, ...updated } : item
30 | );
31 | return updatedFormList;
32 | } else {
33 | return formList;
34 | }
35 | };
36 |
37 | export enum FormUpdateType {
38 | UpdateItem = 'formItem/update',
39 | DeleteItem = 'formItem/delete',
40 | AddItem = 'formItem/add',
41 | SortList = 'formList/sort',
42 | UpdateTitle = 'formTitle/update',
43 | }
44 |
45 | export const updateForm = createAsyncThunk(
46 | 'form/updateForm',
47 | async (
48 | {
49 | type,
50 | data,
51 | history = false, // 确认是否是由于undo/redo而导致表单更新,默认为false
52 | }: {
53 | type: FormUpdateType;
54 | data: any;
55 | history?: boolean;
56 | },
57 | thunkAPI
58 | ) => {
59 | const state = thunkAPI.getState() as RootState;
60 | const formState = state.form;
61 | try {
62 | const res = await fetch('/api/form', {
63 | method: 'PUT',
64 | headers: {
65 | 'Content-Type': 'application/json',
66 | },
67 | body: JSON.stringify({
68 | formId: formState.formId,
69 | type,
70 | data,
71 | }),
72 | });
73 | if (!res.ok) {
74 | throw new Error('Failed to update form');
75 | }
76 | return res.json();
77 | } catch (error) {
78 | console.log(error);
79 | }
80 | }
81 | );
82 |
83 | // 声明undo/redo实例
84 | export const historyManager = new HistoryManager();
85 |
86 | const formSlice = createSlice({
87 | name: 'form',
88 | initialState: {
89 | editable: true,
90 | formId: undefined,
91 | formTitle: '表单标题',
92 | formList: [],
93 | } as FormState,
94 | reducers: {
95 | setForm(
96 | state,
97 | action: PayloadAction<{
98 | formId: string;
99 | formTitle: string;
100 | formList: SortableItemProps[];
101 | }>
102 | ) {
103 | const { formId, formTitle, formList } = action.payload;
104 | state.formId = formId;
105 | state.formTitle = formTitle;
106 | state.formList = formList;
107 | },
108 | setEditable(state, action: PayloadAction) {
109 | state.editable = action.payload;
110 | },
111 | },
112 | extraReducers: (builder) => {
113 | // pending状态下乐观更新数据,防止拖拽等行为发生意料之外的效果
114 | builder.addCase(updateForm.pending, (state, action) => {
115 | const { type, data, history } = action.meta.arg;
116 | // 如果不是由于undo/redo而导致表单更新,才添加到historyManager中
117 | // 以防止操作被重复记录
118 | if (!history) {
119 | historyManager.add({ action: type, data });
120 | }
121 | switch (type) {
122 | case FormUpdateType.UpdateItem:
123 | const updatedFormList = updateFormList({
124 | formList: state.formList,
125 | id: data.id,
126 | updated: data.updated,
127 | });
128 | state.formList = updatedFormList;
129 | break;
130 | case FormUpdateType.AddItem:
131 | const { index, newItem } = data;
132 | state.formList = [
133 | ...state.formList.slice(0, index),
134 | newItem,
135 | ...state.formList.slice(index),
136 | ];
137 | break;
138 | case FormUpdateType.DeleteItem:
139 | state.formList = state.formList.filter(
140 | (item) => item.id !== data.item.id
141 | );
142 | break;
143 | case FormUpdateType.SortList:
144 | const copyFormList = [...state.formList];
145 | const [moveItem] = copyFormList.splice(data.oldIndex, 1);
146 | copyFormList.splice(data.newIndex, 0, moveItem);
147 | state.formList = copyFormList;
148 | break;
149 | case FormUpdateType.UpdateTitle:
150 | state.formTitle = data.formTitle;
151 | break;
152 | }
153 | });
154 | // TODO
155 | builder.addCase(updateForm.fulfilled, (state, action) => {
156 | // TODO 在header更新状态
157 | // const { type, form } = action.payload;
158 | // switch (type) {
159 | // case 'formItem':
160 | // case 'formList':
161 | // state.formList = form.formList;
162 | // break;
163 | // case 'formTitle':
164 | // state.formTitle = form.title;
165 | // break;
166 | // }
167 | });
168 | },
169 | });
170 |
171 | export const { setForm, setEditable } = formSlice.actions;
172 |
173 | export default formSlice.reducer;
174 |
--------------------------------------------------------------------------------
/store/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import formSliceReducer from './form';
3 | import fieldSliceReducer from './field';
4 |
5 | const store = configureStore({
6 | reducer: {
7 | form: formSliceReducer,
8 | field: fieldSliceReducer,
9 | },
10 | });
11 | export type RootState = ReturnType;
12 |
13 | export default store;
14 |
15 | export type AppDispatch = typeof store.dispatch;
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "allowImportingTsExtensions": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/utils/common.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lbsmx/smart-form/1e55d9dcc567b412990af387928d98967530068e/utils/common.ts
--------------------------------------------------------------------------------
/utils/history-manager.ts:
--------------------------------------------------------------------------------
1 | import { FormUpdateType } from '@/store/form';
2 |
3 | type HistoryState = {
4 | action: FormUpdateType;
5 | data: any;
6 | };
7 |
8 | class HistoryManager {
9 | private undoStack: any[];
10 | private redoStack: any[];
11 | constructor() {
12 | this.undoStack = [];
13 | this.redoStack = [];
14 | }
15 |
16 | add(state: HistoryState) {
17 | this.undoStack.push(state);
18 | }
19 |
20 | undo() {
21 | if (this.undoStack.length > 0) {
22 | const state = this.undoStack.pop();
23 | this.redoStack.push(state);
24 | return state;
25 | }
26 | return null;
27 | }
28 |
29 | redo() {
30 | if (this.redoStack.length > 0) {
31 | const state = this.redoStack.pop();
32 | this.undoStack.push(state);
33 | return state;
34 | }
35 | return null;
36 | }
37 | }
38 |
39 | export default HistoryManager;
40 |
--------------------------------------------------------------------------------
/utils/user.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lbsmx/smart-form/1e55d9dcc567b412990af387928d98967530068e/utils/user.ts
--------------------------------------------------------------------------------