├── .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 |
49 |

表单数据展示

50 | 55 | 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 |
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 |
8 | 9 | 10 | 11 |
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 |
onChange(form.getFieldsValue())} 17 | > 18 | 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 |
onChange(form.getFieldsValue())} 14 | > 15 | 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 |
onChange(form.getFieldsValue())} 18 | > 19 | 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 |
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 |
229 | {!isEditing && ( 230 |
231 | 232 |
233 | )} 234 | {isEditing && ( 235 |
243 | 244 | 248 | 249 | 250 | 257 | 262 | 263 | 264 | )} 265 |
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 |
onChange(form.getFieldsValue())} 31 | > 32 | 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 |
16 | {!isEditing && ( 17 |
18 | 19 |
20 | )} 21 | {isEditing && ( 22 |
onChange(form.getFieldsValue())} 27 | > 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 44 | 45 | 46 | )} 47 |
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 |
81 | 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 | 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 | 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 |
117 | {formList.map((field) => { 118 | const Component = typeToComponent[field.type]; 119 | 120 | if (!Component) return null; 121 | 122 | return ( 123 | 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 | 116 |
117 | 118 | 127 |
128 |
129 | 130 | <RobotOutlined 131 | style={{ color: '#4a6cf7', fontSize: '28px' }} 132 | /> 133 | AI 助手 134 | 135 | 136 | 请输入您的表单需求,AI 将为您生成专属模板 137 | 138 |
139 | 140 |
141 | 151 |