Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

View File

@@ -0,0 +1,246 @@
@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } }
.page {
--ink: #20283a;
--muted: #8790a2;
--line: #e5e8ef;
--blue: #3d6fe8;
--blue-dark: #2d5dcc;
--blue-soft: #edf3ff;
min-height: 100vh;
background: #f5f7fb;
color: var(--ink);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
.page * { box-sizing: border-box; }
.page button, .page input, .page select, .page textarea { font: inherit; }
.topbar { position: sticky; top: 0; z-index: 30; height: 64px; display: flex; align-items: center; padding: 0 24px; border-bottom: 1px solid #e3e7ee; background: rgba(255,255,255,.96); backdrop-filter: blur(12px); }
.brand { display: flex; align-items: center; gap: 9px; color: #20283a; text-decoration: none; }
.brand > span { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 8px; background: linear-gradient(145deg,#4778ed,#315cc8); color: #fff; font-family: Georgia,serif; font-size: 15px; box-shadow: 0 5px 12px rgba(50,91,199,.24); }
.brand strong { font-size: 13px; letter-spacing: -.2px; }
.topbar nav { height: 100%; display: flex; align-items: stretch; gap: 4px; margin-left: 42px; }
.topbar nav a { position: relative; display: flex; align-items: center; padding: 0 14px; color: #8a92a1; text-decoration: none; font-size: 10px; }
.topbar nav a:hover, .topbar nav .activeNav { color: #31415e; }
.topbar nav .activeNav::after { content: ""; position: absolute; inset: auto 13px -1px; height: 2px; background: var(--blue); }
.saveState { display: flex; align-items: center; gap: 6px; margin-left: auto; color: #9098a7; font-size: 8px; }
.saveState > i, .formFooter p > i { width: 6px; height: 6px; border-radius: 50%; background: #e5a94d; }
.saveState .savedDot, .formFooter p > .savedDot { background: #31a670; }
.headerActions { display: flex; align-items: center; gap: 7px; margin-left: 20px; }
.headerActions button { height: 32px; padding: 0 12px; border: 1px solid #dfe3ea; border-radius: 7px; background: #fff; color: #616d80; cursor: pointer; font-size: 9px; }
.headerActions button:hover { border-color: #b8c8e9; }
.headerActions .saveButton { border-color: var(--blue); background: var(--blue); color: #fff; box-shadow: 0 5px 13px rgba(61,111,232,.2); }
.headerActions > span { width: 30px; height: 30px; display: grid; place-items: center; margin-left: 4px; border-radius: 50%; background: #dfe9ff; color: #315daa; font-size: 9px; font-weight: 700; }
.workspace { min-height: calc(100vh - 64px); display: grid; grid-template-columns: 256px minmax(0,1fr); }
.sidebar { position: sticky; top: 64px; height: calc(100vh - 64px); display: flex; flex-direction: column; border-right: 1px solid #e2e6ed; background: #fff; }
.sidebarTitle { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px 0 19px; }
.sidebarTitle p { margin: 0 0 3px; color: #7891cc; font-size: 6px; font-weight: 800; letter-spacing: 1.3px; }
.sidebarTitle strong { font-size: 13px; }
.sidebarTitle button { width: 30px; height: 30px; border: 0; border-radius: 7px; background: var(--blue); color: #fff; cursor: pointer; font-size: 16px; }
.search { height: 33px; display: flex; align-items: center; gap: 7px; margin: 0 14px 11px; padding: 0 9px; border: 1px solid #e1e5eb; border-radius: 7px; background: #f8f9fb; color: #9da5b2; }
.search:focus-within { border-color: #9db7ef; background: #fff; box-shadow: 0 0 0 3px rgba(61,111,232,.07); }
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 8px; }
.statusTabs { display: flex; gap: 3px; padding: 0 13px 11px; border-bottom: 1px solid #eef0f3; overflow-x: auto; }
.statusTabs button { padding: 5px 7px; border: 0; border-radius: 5px; background: none; color: #9aa1ad; cursor: pointer; font-size: 7px; white-space: nowrap; }
.statusTabs .statusTabActive { background: var(--blue-soft); color: #3a67cf; font-weight: 700; }
.formList { flex: 1; overflow-y: auto; padding: 9px; }
.formList > button { width: 100%; display: flex; align-items: flex-start; gap: 9px; margin-bottom: 5px; padding: 11px 9px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: #5b6576; cursor: pointer; text-align: left; }
.formList > button:hover { background: #f7f9fc; }
.formList > .formActive { border-color: #d8e3fb; background: #f1f5ff; box-shadow: inset 3px 0 #4776e5; }
.formIcon { width: 31px; height: 31px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; background: #edf0f5; color: #7a879a; font-size: 12px; }
.formActive .formIcon { background: #dfe9ff; color: #3d6bd5; }
.formList > button > div { min-width: 0; flex: 1; }
.formList strong { display: block; overflow: hidden; color: #3e4859; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.formList small { display: block; margin: 3px 0 7px; color: #a1a8b4; font-size: 6px; }
.formList p { display: flex; align-items: center; gap: 4px; margin: 0; color: #8f97a5; font-size: 6px; }
.formList p > i { width: 5px; height: 5px; border-radius: 50%; background: #9ca4b0; }
.formList p > .recordEditing { background: #e6a23c; }
.formList p > .recordSubmitted { background: #32a76f; }
.formList p > .recordDraft { background: #929aa7; }
.formList p > span { margin-left: 4px; padding-left: 6px; border-left: 1px solid #dfe2e8; }
.formList time { margin-left: auto; }
.noForms { padding: 35px 10px; color: #a1a7b2; font-size: 8px; text-align: center; }
.sidebarFooter { padding: 10px 12px 13px; border-top: 1px solid #e9ebef; }
.sidebarFooter button { width: 100%; display: block; padding: 8px; border: 0; border-radius: 6px; background: none; color: #717b8c; cursor: pointer; text-align: left; font-size: 8px; }
.sidebarFooter button:hover { background: #f3f5f8; color: var(--blue); }
.sidebarFooter p { margin: 9px 8px 0; color: #939ba8; font-size: 7px; line-height: 1.5; }
.sidebarFooter p span { color: #b0b6bf; }
.main { width: min(1120px,100%); margin: 0 auto; padding: 25px 32px 80px; }
.breadcrumb { display: flex; align-items: center; gap: 7px; margin-bottom: 19px; color: #a1a8b4; font-size: 7px; }
.breadcrumb a { color: #7b8aa3; text-decoration: none; }
.breadcrumb strong { color: #657087; font-weight: 600; }
.formHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 21px; }
.titleGroup { display: grid; grid-template-columns: auto minmax(0,1fr); align-items: center; column-gap: 9px; }
.titleGroup input { width: min(650px,65vw); padding: 0; border: 0; outline: 0; background: transparent; color: #1f293b; font-size: 24px; font-weight: 750; letter-spacing: -.6px; }
.titleGroup p { grid-column: 1/-1; margin: 8px 0 0; color: #9199a7; font-size: 8px; }
.formStatus { padding: 5px 8px; border-radius: 5px; font-size: 7px; font-weight: 700; }
.formStatusEditing { background: #fff2dd; color: #c67d24; }
.formStatusSubmitted { background: #e7f7ef; color: #27875b; }
.formStatusDraft { background: #eceef2; color: #757e8d; }
.headerMenu { display: flex; gap: 6px; }
.headerMenu button { height: 30px; padding: 0 10px; border: 1px solid #e0e3e9; border-radius: 6px; background: #fff; color: #778194; cursor: pointer; font-size: 8px; }
.summary { display: grid; grid-template-columns: repeat(4,1fr); overflow: hidden; margin-bottom: 17px; border: 1px solid var(--line); border-radius: 11px; background: #fff; box-shadow: 0 6px 22px rgba(34,47,75,.035); }
.summary > div { min-height: 78px; padding: 16px 20px; border-right: 1px solid #eef0f4; }
.summary > div:last-child { border-right: 0; }
.summary span { display: block; margin-bottom: 6px; color: #8f98a8; font-size: 8px; }
.summary strong { color: #29354a; font-family: Georgia,serif; font-size: 20px; font-weight: 500; }
.summary strong em { color: #a4aab4; font-size: 10px; font-style: normal; }
.summary small { float: right; margin-top: 8px; color: #a5abb6; font-size: 7px; }
.warning { min-height: 48px; display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px 13px; border: 1px solid #f0d59d; border-radius: 8px; background: #fff9ec; color: #9b701f; }
.warning > span { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #e9ad3e; color: #fff; font-weight: 800; }
.warning p { flex: 1; margin: 0; font-size: 8px; line-height: 1.6; }
.warning p strong { display: block; }
.warning button { border: 0; background: none; color: inherit; cursor: pointer; }
.sectionBar { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 26px 0 12px; }
.sectionBar p { margin: 0 0 4px; color: var(--blue); font-size: 6px; font-weight: 800; letter-spacing: 1.5px; }
.sectionBar h2 { display: inline; margin: 0; font-size: 14px; }
.sectionBar div > span { margin-left: 9px; color: #969eab; font-size: 8px; }
.sectionBar > button { height: 32px; padding: 0 13px; border: 1px solid #cad8f5; border-radius: 7px; background: #fff; color: #3d6bd4; cursor: pointer; font-size: 8px; font-weight: 700; }
.itemList { display: flex; flex-direction: column; gap: 12px; }
.itemCard { scroll-margin-top: 78px; overflow: hidden; border: 1px solid #e1e5ec; border-radius: 11px; background: #fff; box-shadow: 0 6px 22px rgba(38,50,75,.035); }
.itemHeader { min-height: 59px; display: flex; align-items: center; gap: 10px; padding: 9px 13px; border-bottom: 1px solid #edf0f4; background: #fcfcfd; }
.dragHandle { color: #c2c7cf; cursor: grab; font-size: 11px; letter-spacing: -3px; }
.itemNumber { width: 30px; height: 30px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; background: #e9effe; color: #456dcb; font-family: Georgia,serif; font-size: 10px; }
.itemHeader > div:nth-of-type(1) { min-width: 0; flex: 1; }
.itemHeader strong { display: block; overflow: hidden; color: #334056; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.itemHeader small { display: block; margin-top: 4px; color: #9ba3b0; font-size: 7px; }
.itemStatus { padding: 4px 7px; border-radius: 10px; font-size: 7px; }
.itemStatusEmpty { background: #eff1f4; color: #7d8593; }
.itemStatusDoing { background: #eaf1ff; color: #3d6cd5; }
.itemStatusReview { background: #fff1db; color: #c57d22; }
.itemStatusDone { background: #e5f7ee; color: #2c9463; }
.itemActions { display: flex; align-items: center; gap: 2px; margin-left: 4px; }
.itemActions button { height: 25px; padding: 0 7px; border: 0; border-radius: 5px; background: transparent; color: #8992a1; cursor: pointer; font-size: 7px; }
.itemActions button:hover:not(:disabled) { background: #eef2f8; color: #4369c2; }
.itemActions button:disabled { opacity: .3; cursor: not-allowed; }
.itemActions .deleteAction:hover { background: #fff0f0; color: #d65353; }
.itemBody { padding: 20px; }
.fieldsGrid { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 13px; }
.fieldsGrid label { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.fieldsGrid label > span { color: #5f697a; font-size: 8px; font-weight: 650; }
.fieldsGrid label > span b { color: #df5656; }
.fieldsGrid input, .fieldsGrid select, .fieldsGrid textarea { width: 100%; border: 1px solid #dfe3ea; border-radius: 7px; outline: 0; background: #fff; color: #465267; font-size: 9px; }
.fieldsGrid input, .fieldsGrid select { height: 34px; padding: 0 10px; }
.fieldsGrid textarea { height: 67px; resize: vertical; padding: 9px 10px; line-height: 1.6; }
.fieldsGrid input:focus, .fieldsGrid select:focus, .fieldsGrid textarea:focus { border-color: #93b0ed; box-shadow: 0 0 0 3px rgba(61,111,232,.07); }
.wideField { grid-column: span 2; }
.descriptionField { grid-column: span 4; }
.attachmentSection { margin-top: 20px; padding-top: 17px; border-top: 1px solid #eef0f3; }
.attachmentHeading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 12px; }
.attachmentHeading > div { display: flex; align-items: center; gap: 9px; }
.imageBadge, .fileBadge { width: 29px; height: 29px; display: grid; place-items: center; border-radius: 7px; background: #e9f0ff; color: #4270d6; font-size: 11px; }
.fileBadge { background: #efeaff; color: #7156bf; }
.attachmentHeading p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
.attachmentHeading strong { color: #4d586a; font-size: 8px; }
.attachmentHeading small { color: #a1a8b3; font-size: 7px; }
.uploadButton { height: 29px; display: flex; align-items: center; padding: 0 10px; border: 1px solid #dbe1eb; border-radius: 6px; background: #fff; color: #526482; cursor: pointer; font-size: 7px; }
.uploadButton:hover { border-color: #9eb4e3; color: var(--blue); }
.uploadButton input, .emptyImage input, .emptyFile input { display: none; }
.imageGrid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 9px; }
.imageGrid figure { min-width: 0; overflow: hidden; margin: 0; border: 1px solid #e1e5eb; border-radius: 8px; background: #fafbfc; }
.imagePreview { position: relative; width: 100%; height: 112px; display: block; overflow: hidden; padding: 0; border: 0; background: #e9edf3; cursor: zoom-in; }
.imagePreview img { width: 100%; height: 100%; display: block; object-fit: cover; transition: transform .2s; }
.imagePreview > span { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(22,31,48,.52); color: #fff; font-size: 8px; opacity: 0; transition: opacity .2s; }
.imagePreview:hover img { transform: scale(1.035); }
.imagePreview:hover > span { opacity: 1; }
.imageGrid figcaption { min-height: 42px; display: flex; align-items: center; gap: 6px; padding: 7px 8px; }
.imageGrid figcaption p { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 2px; margin: 0; }
.imageGrid figcaption strong { overflow: hidden; color: #596477; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.imageGrid figcaption small { color: #a0a7b2; font-size: 6px; }
.imageGrid figcaption button { width: 21px; height: 21px; border: 0; border-radius: 4px; background: none; color: #a6acb6; cursor: pointer; }
.imageGrid figcaption button:hover { background: #fff0f0; color: #d75c5c; }
.emptyImage { min-height: 155px; display: flex; align-items: center; justify-content: center; flex-direction: column; border: 1px dashed #cfd6e1; border-radius: 8px; background: #fafbfc; color: #9aa3b1; cursor: pointer; }
.emptyImage:hover { border-color: #8eabe8; background: #f7f9ff; }
.emptyImage > span { width: 31px; height: 31px; display: grid; place-items: center; margin-bottom: 7px; border-radius: 50%; background: #eaf0fd; color: #557bd3; font-size: 14px; }
.emptyImage strong { color: #687589; font-size: 8px; }
.emptyImage small { margin-top: 4px; font-size: 6px; }
.fileList { display: flex; flex-direction: column; gap: 6px; }
.fileList > div { min-height: 48px; display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid #e4e7ed; border-radius: 7px; background: #fbfbfc; }
.fileType { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 6px; background: #eeeafd; color: #6d57b7; font-size: 6px; font-weight: 800; }
.fileList p { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 3px; margin: 0; }
.fileList strong { overflow: hidden; color: #536074; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.fileList small { color: #a0a7b2; font-size: 6px; }
.fileList a, .fileList > div > button { padding: 4px 7px; border: 0; border-radius: 4px; background: transparent; color: #5272bd; cursor: pointer; font-size: 7px; text-decoration: none; }
.fileList > div > button:last-child { width: 23px; color: #a2a9b4; font-size: 11px; }
.fileList > div > button:last-child:hover { background: #fff0f0; color: #d75c5c; }
.emptyFile { min-height: 46px; display: grid; place-items: center; border: 1px dashed #d2d7e0; border-radius: 7px; color: #8e99a9; cursor: pointer; font-size: 8px; }
.emptyFile:hover { border-color: #9bafe0; background: #f7f9ff; color: #4f70bd; }
.emptyItems { min-height: 260px; display: flex; align-items: center; justify-content: center; flex-direction: column; border: 1px dashed #cfd6e2; border-radius: 11px; background: #fff; color: #98a1af; }
.emptyItems > span { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 50%; background: #edf2fb; color: #5d7dc4; font-size: 18px; }
.emptyItems h3 { margin: 12px 0 3px; color: #556174; font-size: 11px; }
.emptyItems p { margin: 0 0 13px; font-size: 8px; }
.emptyItems button { height: 31px; padding: 0 12px; border: 0; border-radius: 6px; background: var(--blue); color: #fff; cursor: pointer; font-size: 8px; }
.addItemBottom { width: 100%; min-height: 65px; display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; border: 1px dashed #bdcbe5; border-radius: 10px; background: rgba(255,255,255,.55); color: #62718a; cursor: pointer; }
.addItemBottom:hover { border-color: #7595db; background: #f8faff; }
.addItemBottom > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: #e5edfe; color: #4971ca; }
.addItemBottom strong { font-size: 8px; }
.addItemBottom small { color: #9fa7b3; font-size: 7px; font-weight: 400; }
.formFooter { min-height: 70px; display: flex; align-items: center; justify-content: space-between; margin-top: 22px; padding-top: 18px; border-top: 1px solid #dfe3ea; }
.formFooter p { display: flex; align-items: center; gap: 6px; margin: 0; color: #939baa; font-size: 7px; }
.formFooter > div { display: flex; gap: 8px; }
.formFooter button { height: 34px; padding: 0 14px; border: 1px solid #dce1e9; border-radius: 7px; background: #fff; color: #687487; cursor: pointer; font-size: 8px; }
.formFooter .submitButton { border-color: var(--blue); background: var(--blue); color: #fff; }
.lightbox { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 35px; background: rgba(12,19,31,.72); backdrop-filter: blur(7px); }
.lightbox > div { width: min(980px,95vw); max-height: 88vh; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 30px 90px rgba(3,8,18,.45); }
.lightbox header { height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 0 15px; }
.lightbox header p { display: flex; flex-direction: column; gap: 3px; margin: 0; }
.lightbox header strong { font-size: 9px; }
.lightbox header small { color: #9aa2ae; font-size: 7px; }
.lightbox header button { width: 30px; height: 30px; border: 0; border-radius: 50%; background: #f1f2f5; color: #717a89; cursor: pointer; }
.lightbox img { width: 100%; max-height: calc(88vh - 52px); display: block; object-fit: contain; background: #101522; }
.toast { position: fixed; left: 50%; bottom: 25px; z-index: 70; padding: 10px 17px; border-radius: 22px; background: rgba(29,38,55,.96); color: #fff; box-shadow: 0 12px 30px rgba(22,30,44,.25); font-size: 8px; animation: toastIn .22s ease both; }
@media (max-width: 980px) {
.topbar nav { display: none; }
.workspace { grid-template-columns: 220px minmax(0,1fr); }
.sidebar { width: 220px; }
.main { padding: 23px 20px 65px; }
.fieldsGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
.wideField { grid-column: span 2; }
.descriptionField { grid-column: span 2; }
.imageGrid { grid-template-columns: repeat(3,minmax(0,1fr)); }
.itemActions button:nth-child(-n+2) { display: none; }
}
@media (max-width: 720px) {
.topbar { padding: 0 13px; }
.saveState, .headerActions > button:first-child, .headerActions > span { display: none; }
.headerActions { margin-left: auto; }
.workspace { display: block; }
.sidebar { position: relative; top: auto; width: 100%; height: auto; border-right: 0; border-bottom: 1px solid #e2e6ed; }
.sidebarTitle { height: 58px; }
.formList { max-height: 205px; }
.sidebarFooter { display: none; }
.main { width: 100%; padding: 20px 14px 55px; }
.breadcrumb { display: none; }
.titleGroup { grid-template-columns: 1fr; }
.titleGroup input { width: 100%; margin-top: 8px; font-size: 20px; }
.titleGroup p { grid-column: 1; line-height: 1.6; }
.headerMenu { display: none; }
.summary { grid-template-columns: repeat(2,1fr); }
.summary > div:nth-child(2) { border-right: 0; }
.summary > div:nth-child(-n+2) { border-bottom: 1px solid #eef0f4; }
.sectionBar div > span { display: none; }
.itemHeader { flex-wrap: wrap; }
.itemHeader > div:nth-of-type(1) { min-width: 50%; }
.itemStatus { margin-left: auto; }
.itemActions { width: 100%; justify-content: flex-end; padding-top: 6px; border-top: 1px solid #eef0f3; }
.itemBody { padding: 15px; }
.fieldsGrid { grid-template-columns: 1fr; }
.wideField, .descriptionField { grid-column: 1; }
.imageGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }
.attachmentHeading small { display: none; }
.addItemBottom small { display: none; }
.formFooter { align-items: flex-start; gap: 15px; flex-direction: column; }
.formFooter > div { width: 100%; }
.formFooter button { flex: 1; }
.lightbox { padding: 12px; }
}
@media (max-width: 430px) {
.imageGrid { grid-template-columns: 1fr; }
.imagePreview { height: 180px; }
}

View File

@@ -0,0 +1,319 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import seedData from "../data/repeatable-forms.json";
import styles from "./RepeatableForm.module.css";
type ImageAttachment = { id: string; name: string; size: string; url: string };
type FileAttachment = { id: string; name: string; size: string; type: string; url: string };
type FormItem = {
id: string;
title: string;
category: string;
owner: string;
status: string;
quantity: number;
unit: string;
dueDate: string;
description: string;
images: ImageAttachment[];
files: FileAttachment[];
};
type FormRecord = {
id: string;
title: string;
code: string;
status: string;
updatedAt: string;
createdBy: string;
tags: string[];
items: FormItem[];
};
const initialForms = seedData.forms as FormRecord[];
const recordStatusStyles: Record<string, string> = { "编辑中": "recordEditing", "已提交": "recordSubmitted", "草稿": "recordDraft" };
const formStatusStyles: Record<string, string> = { "编辑中": "formStatusEditing", "已提交": "formStatusSubmitted", "草稿": "formStatusDraft" };
const itemStatusStyles: Record<string, string> = { "待填写": "itemStatusEmpty", "进行中": "itemStatusDoing", "待确认": "itemStatusReview", "已完成": "itemStatusDone" };
export default function RepeatableFormPage() {
const [forms, setForms] = useState<FormRecord[]>(initialForms);
const [activeId, setActiveId] = useState(initialForms[0].id);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("全部");
const [collapsed, setCollapsed] = useState<string[]>([]);
const [lightbox, setLightbox] = useState<ImageAttachment | null>(null);
const [toast, setToast] = useState("");
const [saved, setSaved] = useState(true);
const [hydrated, setHydrated] = useState(false);
const [storageWarning, setStorageWarning] = useState(false);
const saveTimer = useRef<number | null>(null);
useEffect(() => {
try {
const cached = window.localStorage.getItem("orbit-repeatable-forms");
const cachedActive = window.localStorage.getItem("orbit-repeatable-active");
if (cached) setForms(JSON.parse(cached));
if (cachedActive) setActiveId(cachedActive);
} catch {
window.localStorage.removeItem("orbit-repeatable-forms");
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
setSaved(false);
if (saveTimer.current) window.clearTimeout(saveTimer.current);
saveTimer.current = window.setTimeout(() => {
try {
window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms));
window.localStorage.setItem("orbit-repeatable-active", activeId);
setSaved(true);
setStorageWarning(false);
} catch {
setStorageWarning(true);
}
}, 550);
return () => { if (saveTimer.current) window.clearTimeout(saveTimer.current); };
}, [forms, activeId, hydrated]);
const activeForm = forms.find((form) => form.id === activeId) ?? forms[0];
const visibleForms = useMemo(() => forms.filter((form) => {
const matchesQuery = `${form.title}${form.code}${form.tags.join("")}`.toLowerCase().includes(query.toLowerCase());
return matchesQuery && (statusFilter === "全部" || form.status === statusFilter);
}), [forms, query, statusFilter]);
const totals = useMemo(() => {
const items = activeForm?.items ?? [];
return {
completed: items.filter((item) => item.status === "已完成").length,
images: items.reduce((sum, item) => sum + item.images.length, 0),
files: items.reduce((sum, item) => sum + item.files.length, 0),
};
}, [activeForm]);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2200);
};
const updateForm = (patch: Partial<FormRecord>) => setForms((current) => current.map((form) => form.id === activeId ? { ...form, ...patch, updatedAt: "刚刚" } : form));
const updateItem = (itemId: string, patch: Partial<FormItem>) => setForms((current) => current.map((form) => form.id === activeId ? {
...form,
updatedAt: "刚刚",
items: form.items.map((item) => item.id === itemId ? { ...item, ...patch } : item),
} : form));
const createForm = () => {
const id = `FORM-${Date.now()}`;
const record: FormRecord = { id, title: "未命名资料表单", code: `DRAFT-${String(Date.now()).slice(-6)}`, status: "草稿", updatedAt: "刚刚", createdBy: "林知夏", tags: ["新表单"], items: [emptyItem()] };
setForms((current) => [record, ...current]);
setActiveId(id);
notify("已创建新表单");
};
const duplicateForm = () => {
if (!activeForm) return;
const now = Date.now();
const duplicated: FormRecord = {
...activeForm,
id: `FORM-${now}`,
title: `${activeForm.title} - 副本`,
status: "草稿",
updatedAt: "刚刚",
items: activeForm.items.map((item, index) => ({ ...item, id: `ITEM-${now}-${index}` })),
};
setForms((current) => [duplicated, ...current]);
setActiveId(duplicated.id);
notify("表单副本已创建");
};
const removeForm = () => {
if (!activeForm || forms.length === 1) return notify("至少需要保留一份表单");
const next = forms.filter((form) => form.id !== activeForm.id);
setForms(next);
setActiveId(next[0].id);
notify("表单已删除");
};
const addItem = () => {
const item = emptyItem();
updateForm({ items: [...activeForm.items, item] });
window.setTimeout(() => document.getElementById(item.id)?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
notify("已添加一条表单项");
};
const duplicateItem = (item: FormItem) => {
const clone = { ...item, id: `ITEM-${Date.now()}`, title: `${item.title} - 副本`, images: [...item.images], files: [...item.files] };
const index = activeForm.items.findIndex((current) => current.id === item.id);
const next = [...activeForm.items];
next.splice(index + 1, 0, clone);
updateForm({ items: next });
notify("表单项已复制");
};
const removeItem = (itemId: string) => {
updateForm({ items: activeForm.items.filter((item) => item.id !== itemId) });
notify("表单项已删除");
};
const moveItem = (itemId: string, direction: -1 | 1) => {
const index = activeForm.items.findIndex((item) => item.id === itemId);
const target = index + direction;
if (index < 0 || target < 0 || target >= activeForm.items.length) return;
const next = [...activeForm.items];
[next[index], next[target]] = [next[target], next[index]];
updateForm({ items: next });
};
const handleAttachments = async (itemId: string, files: FileList | null, kind: "images" | "files") => {
if (!files?.length) return;
const item = activeForm.items.find((current) => current.id === itemId);
if (!item) return;
const limit = kind === "images" ? 5 : 6;
const currentCount = item[kind].length;
const incoming = Array.from(files).slice(0, Math.max(0, limit - currentCount));
if (incoming.length === 0) return notify(`每条最多添加 ${limit}${kind === "images" ? "图片" : "文件"}`);
const maxBytes = kind === "images" ? 1_500_000 : 1_000_000;
const allowed = incoming.filter((file) => file.size <= maxBytes && (kind === "files" || file.type.startsWith("image/")));
if (allowed.length !== incoming.length) notify(`已跳过超过限制的${kind === "images" ? "图片1.5MB" : "文件1MB"}`);
const encoded = await Promise.all(allowed.map(async (file, index) => {
const url = await readAsDataUrl(file);
if (kind === "images") return { id: `IMG-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), url } as ImageAttachment;
const extension = file.name.split(".").pop()?.toUpperCase() || "FILE";
return { id: `FILE-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), type: extension, url } as FileAttachment;
}));
updateItem(itemId, kind === "images" ? { images: [...item.images, ...(encoded as ImageAttachment[])] } : { files: [...item.files, ...(encoded as FileAttachment[])] });
notify(`已添加 ${encoded.length}${kind === "images" ? "图片" : "文件"}`);
};
const saveNow = () => {
try {
window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms));
setSaved(true);
setStorageWarning(false);
notify("整份表单已保存到当前浏览器");
} catch {
setStorageWarning(true);
notify("存储空间不足,请删除较大的附件后重试");
}
};
if (!activeForm) return null;
return (
<div className={styles.page}>
<header className={styles.topbar}>
<Link href="/" className={styles.brand}><span>F</span><strong>FormStack</strong></Link>
<nav><Link href="/simple-list"></Link><Link className={styles.activeNav} href="/repeatable-form"></Link><Link href="/markdown"></Link></nav>
<div className={styles.saveState}><i className={saved ? styles.savedDot : ""} />{saved ? "所有更改已保存" : "正在自动保存…"}</div>
<div className={styles.headerActions}><button onClick={() => notify("预览模式为模板交互占位")}></button><button className={styles.saveButton} onClick={saveNow}></button><span></span></div>
</header>
<div className={styles.workspace}>
<aside className={styles.sidebar}>
<div className={styles.sidebarTitle}><div><p>FORM RECORDS</p><strong></strong></div><button onClick={createForm}></button></div>
<label className={styles.search}><span></span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称或编号" /></label>
<div className={styles.statusTabs}>{["全部", "编辑中", "已提交", "草稿"].map((value) => <button key={value} className={statusFilter === value ? styles.statusTabActive : ""} onClick={() => setStatusFilter(value)}>{value}</button>)}</div>
<div className={styles.formList}>
{visibleForms.map((form) => <button key={form.id} className={form.id === activeId ? styles.formActive : ""} onClick={() => setActiveId(form.id)}>
<span className={styles.formIcon}></span><div><strong>{form.title}</strong><small>{form.code}</small><p><i className={styles[recordStatusStyles[form.status]]} />{form.status}<span>{form.items.length} </span><time>{form.updatedAt}</time></p></div>
</button>)}
{visibleForms.length === 0 && <div className={styles.noForms}></div>}
</div>
<div className={styles.sidebarFooter}><button onClick={duplicateForm}> </button><button onClick={removeForm}> </button><p><br /><span></span></p></div>
</aside>
<main className={styles.main}>
<div className={styles.breadcrumb}><Link href="/"></Link><span>/</span><span></span><span>/</span><strong>{activeForm.code}</strong></div>
<section className={styles.formHeader}>
<div className={styles.titleGroup}><span className={`${styles.formStatus} ${styles[formStatusStyles[activeForm.status]]}`}>{activeForm.status}</span><input value={activeForm.title} onChange={(event) => updateForm({ title: event.target.value })} aria-label="表单名称" /><p> {activeForm.code} ·  {activeForm.createdBy} ·  {activeForm.updatedAt}</p></div>
<div className={styles.headerMenu}><button onClick={duplicateForm}></button><button onClick={() => notify("更多操作:导出 JSON、打印、归档")}></button></div>
</section>
<section className={styles.summary}>
<div><span></span><strong>{activeForm.items.length}</strong><small></small></div>
<div><span></span><strong>{totals.completed}<em> / {activeForm.items.length}</em></strong><small>{activeForm.items.length ? Math.round(totals.completed / activeForm.items.length * 100) : 0}% </small></div>
<div><span></span><strong>{totals.images}</strong><small></small></div>
<div><span></span><strong>{totals.files}</strong><small></small></div>
</section>
{storageWarning && <div className={styles.warning}><span>!</span><p><strong></strong></p><button onClick={() => setStorageWarning(false)}>×</button></div>}
<div className={styles.sectionBar}><div><p>REPEATABLE ITEMS</p><h2></h2><span></span></div><button onClick={addItem}> </button></div>
<div className={styles.itemList}>
{activeForm.items.map((item, index) => {
const isCollapsed = collapsed.includes(item.id);
return <article className={styles.itemCard} id={item.id} key={item.id}>
<header className={styles.itemHeader}>
<span className={styles.dragHandle}></span><span className={styles.itemNumber}>{String(index + 1).padStart(2, "0")}</span>
<div><strong>{item.title || "未命名表单项"}</strong><small>{item.category} · {item.images.length} · {item.files.length} </small></div>
<span className={`${styles.itemStatus} ${styles[itemStatusStyles[item.status]]}`}>{item.status}</span>
<div className={styles.itemActions}><button disabled={index === 0} onClick={() => moveItem(item.id, -1)}></button><button disabled={index === activeForm.items.length - 1} onClick={() => moveItem(item.id, 1)}></button><button onClick={() => duplicateItem(item)}></button><button onClick={() => setCollapsed((current) => isCollapsed ? current.filter((id) => id !== item.id) : [...current, item.id])}>{isCollapsed ? "展开" : "收起"}</button><button className={styles.deleteAction} onClick={() => removeItem(item.id)}></button></div>
</header>
{!isCollapsed && <div className={styles.itemBody}>
<div className={styles.fieldsGrid}>
<label className={styles.wideField}><span> <b>*</b></span><input value={item.title} onChange={(event) => updateItem(item.id, { title: event.target.value })} placeholder="请输入明细名称" /></label>
<label><span></span><select value={item.category} onChange={(event) => updateItem(item.id, { category: event.target.value })}><option></option><option></option><option></option><option></option><option></option><option></option></select></label>
<label><span></span><input value={item.owner} onChange={(event) => updateItem(item.id, { owner: event.target.value })} /></label>
<label><span></span><select value={item.status} onChange={(event) => updateItem(item.id, { status: event.target.value })}><option></option><option></option><option></option><option></option></select></label>
<label><span></span><input type="number" min="0" value={item.quantity} onChange={(event) => updateItem(item.id, { quantity: Number(event.target.value) })} /></label>
<label><span></span><input value={item.unit} onChange={(event) => updateItem(item.id, { unit: event.target.value })} /></label>
<label><span></span><input type="date" value={item.dueDate} onChange={(event) => updateItem(item.id, { dueDate: event.target.value })} /></label>
<label className={styles.descriptionField}><span></span><textarea value={item.description} onChange={(event) => updateItem(item.id, { description: event.target.value })} placeholder="补充本条明细的内容、规格或验收要求" /></label>
</div>
<div className={styles.attachmentSection}>
<div className={styles.attachmentHeading}><div><span className={styles.imageBadge}></span><p><strong></strong><small>JPGPNGWEBP · 1.5MB · 5 </small></p></div><label className={styles.uploadButton}> <input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label></div>
<div className={styles.imageGrid}>
{item.images.map((image) => <figure key={image.id}><button className={styles.imagePreview} onClick={() => setLightbox(image)}><img src={image.url} alt={image.name} /><span></span></button><figcaption><p><strong>{image.name}</strong><small>{image.size}</small></p><button aria-label={`删除 ${image.name}`} onClick={() => updateItem(item.id, { images: item.images.filter((current) => current.id !== image.id) })}>×</button></figcaption></figure>)}
{item.images.length === 0 && <label className={styles.emptyImage}><span></span><strong></strong><small></small><input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label>}
</div>
</div>
<div className={styles.attachmentSection}>
<div className={styles.attachmentHeading}><div><span className={styles.fileBadge}></span><p><strong></strong><small> · 1MB · 6 </small></p></div><label className={styles.uploadButton}> <input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label></div>
<div className={styles.fileList}>
{item.files.map((file) => <div key={file.id}><span className={styles.fileType}>{file.type}</span><p><strong>{file.name}</strong><small>{file.size} · </small></p>{file.url ? <a href={file.url} download={file.name}></a> : <button onClick={() => notify("这是 JSON 示例文件,仅展示文件信息")}></button>}<button aria-label={`删除 ${file.name}`} onClick={() => updateItem(item.id, { files: item.files.filter((current) => current.id !== file.id) })}>×</button></div>)}
{item.files.length === 0 && <label className={styles.emptyFile}> <input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label>}
</div>
</div>
</div>}
</article>;
})}
{activeForm.items.length === 0 && <div className={styles.emptyItems}><span></span><h3></h3><p></p><button onClick={addItem}> </button></div>}
</div>
<button className={styles.addItemBottom} onClick={addItem}><span></span><strong></strong><small></small></button>
<footer className={styles.formFooter}><p><i className={saved ? styles.savedDot : ""} />{saved ? `上次保存:${activeForm.updatedAt}` : "存在未保存更改"}</p><div><button onClick={() => updateForm({ status: "草稿" })}>稿</button><button className={styles.submitButton} onClick={() => { updateForm({ status: "已提交" }); saveNow(); notify("表单已提交(演示)"); }}></button></div></footer>
</main>
</div>
{lightbox && <div className={styles.lightbox} onMouseDown={() => setLightbox(null)}><div onMouseDown={(event) => event.stopPropagation()}><header><p><strong>{lightbox.name}</strong><small>{lightbox.size}</small></p><button onClick={() => setLightbox(null)}>×</button></header><img src={lightbox.url} alt={lightbox.name} /></div></div>}
{toast && <div className={styles.toast}> {toast}</div>}
</div>
);
}
function emptyItem(): FormItem {
return { id: `ITEM-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, title: "", category: "页面交付", owner: "林知夏", status: "待填写", quantity: 1, unit: "项", dueDate: new Date().toISOString().slice(0, 10), description: "", images: [], files: [] };
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}