浏览代码

Improve field mapping UX and label template 1408367

master
Jan Svabenik 1 个月前
父节点
当前提交
f7721598b4
共有 14 个文件被更改,包括 1047 次插入32 次删除
  1. +2
    -0
      README.md
  2. 二进制
      data/qctextbuilder.db
  3. 二进制
      dist/qctextbuilder.exe
  4. +1
    -0
      internal/domain/models.go
  5. +130
    -0
      internal/domain/website_sections.go
  6. +2
    -0
      internal/httpserver/handlers/handlers.go
  7. +628
    -11
      internal/httpserver/handlers/ui.go
  8. +117
    -0
      internal/httpserver/handlers/ui_grouping_test.go
  9. +15
    -4
      internal/onboarding/service.go
  10. +8
    -0
      internal/onboarding/service_test.go
  11. +71
    -0
      internal/store/sqlite/migrations/002_add_website_section_to_fields.sql
  12. +9
    -8
      internal/store/sqlite/store.go
  13. +56
    -9
      web/templates/build_new.gohtml
  14. +8
    -0
      web/templates/template_detail.gohtml

+ 2
- 0
README.md 查看文件

@@ -59,3 +59,5 @@ Documented `globalData` scope supported by UI/API mapping:

UI note:
- `/builds/new` now supports loading an existing draft, reviewing/editing values, saving draft, and only then starting the build.
- Template fields in `/builds/new` are grouped block-first by extracted internal block IDs (for example `m1710`, `c7886`, `r4830`) with heuristic fallback for fields without block IDs.
- Template field settings in `/templates/{id}` include a persistent `websiteSection` mapping (`hero`, `intro`, `services`, `service_item`, `about`, `team`, `testimonials`, `cta`, `contact`, `footer`, `gallery`, `other`) used by `/builds/new` grouping with fallback when not set.

二进制
data/qctextbuilder.db 查看文件


二进制
dist/qctextbuilder.exe 查看文件


+ 1
- 0
internal/domain/models.go 查看文件

@@ -40,6 +40,7 @@ type TemplateField struct {
TemplateID int64 `json:"templateId"`
ManifestID string `json:"manifestId"`
Section string `json:"section"`
WebsiteSection string `json:"websiteSection"`
KeyName string `json:"keyName"`
Path string `json:"path"`
FieldKind string `json:"fieldKind"`


+ 130
- 0
internal/domain/website_sections.go 查看文件

@@ -0,0 +1,130 @@
package domain

import (
"regexp"
"strings"
)

const (
WebsiteSectionHero = "hero"
WebsiteSectionIntro = "intro"
WebsiteSectionServices = "services"
WebsiteSectionServiceItem = "service_item"
WebsiteSectionAbout = "about"
WebsiteSectionTeam = "team"
WebsiteSectionTestimonials = "testimonials"
WebsiteSectionCTA = "cta"
WebsiteSectionContact = "contact"
WebsiteSectionFooter = "footer"
WebsiteSectionGallery = "gallery"
WebsiteSectionOther = "other"
)

var websiteSectionOrder = []string{
WebsiteSectionHero,
WebsiteSectionIntro,
WebsiteSectionServices,
WebsiteSectionServiceItem,
WebsiteSectionAbout,
WebsiteSectionTeam,
WebsiteSectionTestimonials,
WebsiteSectionCTA,
WebsiteSectionContact,
WebsiteSectionFooter,
WebsiteSectionGallery,
WebsiteSectionOther,
}

var websiteSectionLabels = map[string]string{
WebsiteSectionHero: "Hero",
WebsiteSectionIntro: "Intro",
WebsiteSectionServices: "Services",
WebsiteSectionServiceItem: "Service Item",
WebsiteSectionAbout: "About",
WebsiteSectionTeam: "Team",
WebsiteSectionTestimonials: "Testimonials",
WebsiteSectionCTA: "CTA",
WebsiteSectionContact: "Contact",
WebsiteSectionFooter: "Footer",
WebsiteSectionGallery: "Gallery",
WebsiteSectionOther: "Other",
}

var serviceItemIndexPattern = regexp.MustCompile(`_\d+$`)

func WebsiteSectionOptions() []string {
out := make([]string, len(websiteSectionOrder))
copy(out, websiteSectionOrder)
return out
}

func WebsiteSectionLabel(section string) string {
normalized := NormalizeWebsiteSection(section)
if label, ok := websiteSectionLabels[normalized]; ok {
return label
}
return websiteSectionLabels[WebsiteSectionOther]
}

func NormalizeWebsiteSection(section string) string {
normalized := strings.ToLower(strings.TrimSpace(section))
for _, candidate := range websiteSectionOrder {
if normalized == candidate {
return candidate
}
}
return WebsiteSectionOther
}

func SuggestWebsiteSection(field TemplateField) string {
section := strings.ToLower(strings.TrimSpace(field.Section))
key := strings.ToLower(strings.TrimSpace(field.KeyName))
path := strings.ToLower(strings.TrimSpace(field.Path))
sample := strings.ToLower(strings.TrimSpace(field.SampleValue))
fieldKind := strings.ToLower(strings.TrimSpace(field.FieldKind))

combined := strings.Join([]string{section, key, path}, " ")
if fieldKind == "image" || containsAny(combined, "gallery", "image", "img", "photo", "picture", "media") {
return WebsiteSectionGallery
}
if containsAny(combined, "testimonial") {
return WebsiteSectionTestimonials
}
if section == "services" || containsAny(combined, "service") {
if containsAny(combined, "item", "card") || serviceItemIndexPattern.MatchString(key) {
return WebsiteSectionServiceItem
}
return WebsiteSectionServices
}
if containsAny(combined, "team", "member", "staff", "employee", "founder") {
return WebsiteSectionTeam
}
if containsAny(combined, "welcome", "headline", "hero", "banner") {
return WebsiteSectionHero
}
if containsAny(combined, "intro", "introduction", "lead") {
return WebsiteSectionIntro
}
if containsAny(combined, "about", "company", "mission", "story") || len(sample) > 180 {
return WebsiteSectionAbout
}
if containsAny(combined, "cta", "highlight", "button", "calltoaction", "call_to_action") {
return WebsiteSectionCTA
}
if containsAny(combined, "contact", "email", "phone", "address") {
return WebsiteSectionContact
}
if containsAny(combined, "footer", "copyright", "imprint", "legal") {
return WebsiteSectionFooter
}
return WebsiteSectionOther
}

func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}

+ 2
- 0
internal/httpserver/handlers/handlers.go 查看文件

@@ -99,6 +99,7 @@ type updateTemplateFieldItem struct {
IsRequiredByUs *bool `json:"isRequiredByUs,omitempty"`
DisplayLabel *string `json:"displayLabel,omitempty"`
DisplayOrder *int `json:"displayOrder,omitempty"`
WebsiteSection *string `json:"websiteSection,omitempty"`
Notes *string `json:"notes,omitempty"`
}

@@ -128,6 +129,7 @@ func (a *API) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
IsRequiredByUs: f.IsRequiredByUs,
DisplayLabel: f.DisplayLabel,
DisplayOrder: f.DisplayOrder,
WebsiteSection: f.WebsiteSection,
Notes: f.Notes,
})
}


+ 628
- 11
internal/httpserver/handlers/ui.go 查看文件

@@ -5,8 +5,11 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"unicode"

"github.com/go-chi/chi/v5"

@@ -65,23 +68,69 @@ type templateFieldView struct {
IsRequiredByUs bool
DisplayLabel string
DisplayOrder int
WebsiteSection string
Notes string
SampleValue string
}

type websiteSectionOptionView struct {
Value string
Label string
}

type templateDetailPageData struct {
pageData
Detail *templatesvc.TemplateDetail
Fields []templateFieldView
Detail *templatesvc.TemplateDetail
Fields []templateFieldView
WebsiteSectionOptions []websiteSectionOptionView
}

type buildFieldView struct {
Index int
Path string
DisplayLabel string
SampleValue string
Value string
}

type buildFieldGroupView struct {
Title string
Fields []buildFieldView
}

type buildFieldSectionView struct {
Key string
Title string
Description string
EditableGroups []buildFieldGroupView
EditableFields []buildFieldView
DisabledFields []buildFieldView
}

type pendingField struct {
Field domain.TemplateField
View buildFieldView
}

type fieldRole struct {
Label string
Order int
}

var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)

var knownBlockAreas = map[string]string{
"m1710": "Hero / Haupttitel",
"c7886": "Intro / Einleitung",
"r4830": "Services",
"m4178": "Gallery / Medien",
"c2929": "Ueber uns / About",
"r4748": "Team",
"r1508": "Testimonials",
"c1165": "CTA / Highlight / Banner",
}

type buildNewPageData struct {
pageData
Templates []domain.Template
@@ -89,6 +138,8 @@ type buildNewPageData struct {
SelectedDraftID string
SelectedTemplateID int64
SelectedManifestID string
FieldSections []buildFieldSectionView
EditableFields []buildFieldView
EnabledFields []buildFieldView
Form buildFormInput
}
@@ -188,11 +239,17 @@ func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) {
IsRequiredByUs: f.IsRequiredByUs,
DisplayLabel: f.DisplayLabel,
DisplayOrder: f.DisplayOrder,
WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection),
Notes: f.Notes,
SampleValue: f.SampleValue,
})
}
u.render.Render(w, "template_detail", templateDetailPageData{pageData: basePageData(r, "Template Detail", "/templates"), Detail: detail, Fields: fields})
u.render.Render(w, "template_detail", templateDetailPageData{
pageData: basePageData(r, "Template Detail", "/templates"),
Detail: detail,
Fields: fields,
WebsiteSectionOptions: websiteSectionOptions(),
})
}

func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
@@ -228,6 +285,7 @@ func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on"
label := r.FormValue(fmt.Sprintf("field_label_%d", i))
notes := r.FormValue(fmt.Sprintf("field_notes_%d", i))
websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i))
order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i))))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther)
@@ -239,6 +297,7 @@ func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
IsRequiredByUs: boolPtr(required),
DisplayLabel: strPtr(label),
DisplayOrder: intPtr(order),
WebsiteSection: strPtr(websiteSection),
Notes: strPtr(notes),
})
}
@@ -506,18 +565,564 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI
return data, nil
}
data.SelectedManifestID = detail.Manifest.ID
for _, f := range detail.Fields {
if !f.IsEnabled || f.FieldKind != "text" {
data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues)
data.EnabledFields = data.EditableFields
return data, nil
}

func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) {
sectionOrder := []string{
domain.WebsiteSectionHero,
domain.WebsiteSectionIntro,
domain.WebsiteSectionServices,
domain.WebsiteSectionAbout,
domain.WebsiteSectionTeam,
domain.WebsiteSectionTestimonials,
domain.WebsiteSectionCTA,
domain.WebsiteSectionContact,
domain.WebsiteSectionGallery,
domain.WebsiteSectionFooter,
domain.WebsiteSectionOther,
}

sectionDescriptions := map[string]string{
domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.",
domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.",
domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.",
}

sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder))
pendingByKey := make(map[string][]pendingField, len(sectionOrder))
for _, key := range sectionOrder {
sectionsByKey[key] = buildFieldSectionView{
Key: key,
Title: domain.WebsiteSectionLabel(key),
Description: sectionDescriptions[key],
}
}

for _, f := range fields {
targetSection := preferredBuildSection(f)
if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery {
labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName)
if blockID := extractBlockID(f); blockID != "" {
labelFallback = "Media - " + blockGroupTitle(blockID)
}
media := sectionsByKey[domain.WebsiteSectionGallery]
media.DisabledFields = append(media.DisabledFields, buildFieldView{
Path: f.Path,
DisplayLabel: effectiveLabel(f, labelFallback),
SampleValue: f.SampleValue,
Value: "",
})
sectionsByKey[domain.WebsiteSectionGallery] = media
continue
}
if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
continue
}
pf := pendingField{
Field: f,
View: buildFieldView{
Path: f.Path,
DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
SampleValue: f.SampleValue,
Value: strings.TrimSpace(fieldValues[f.Path]),
},
}
pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
}

for _, key := range sectionOrder {
section := sectionsByKey[key]
items := pendingByKey[key]
switch key {
case domain.WebsiteSectionServices:
section = applyServicesGrouping(section, items)
case domain.WebsiteSectionTestimonials:
section = applyTestimonialsGrouping(section, items)
case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter:
section = applyTextGrouping(section, items)
case domain.WebsiteSectionOther:
section = applyOtherGrouping(section, items)
case domain.WebsiteSectionGallery:
// Gallery fields are handled as disabled entries only in this MVP.
default:
section = applyOtherGrouping(section, items)
}
sectionsByKey[key] = section
}

sections := make([]buildFieldSectionView, 0, len(sectionOrder))
for _, key := range sectionOrder {
sections = append(sections, sectionsByKey[key])
}
return assignEditableIndexes(sections)
}

func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback)
}

func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
titles := make([]pendingField, 0)
descriptions := make([]pendingField, 0)
other := make([]buildFieldView, 0)

for _, pf := range fields {
key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
switch {
case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"):
titles = append(titles, pf)
case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"):
descriptions = append(descriptions, pf)
default:
pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName))
other = append(other, pf.View)
}
}

maxCount := len(titles)
if len(descriptions) > maxCount {
maxCount = len(descriptions)
}
for i := 0; i < maxCount; i++ {
block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)}
if i < len(titles) {
item := titles[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1))
block.Fields = append(block.Fields, item.View)
}
if i < len(descriptions) {
item := descriptions[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1))
block.Fields = append(block.Fields, item.View)
}
if len(block.Fields) > 0 {
section.EditableGroups = append(section.EditableGroups, block)
}
}
section.EditableFields = append(section.EditableFields, other...)
return section
}

func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback)
}

func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
names := make([]pendingField, 0)
titles := make([]pendingField, 0)
descriptions := make([]pendingField, 0)
other := make([]buildFieldView, 0)

for _, pf := range fields {
key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
switch {
case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"):
names = append(names, pf)
case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"):
titles = append(titles, pf)
case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"):
descriptions = append(descriptions, pf)
default:
pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName))
other = append(other, pf.View)
}
}

maxCount := len(names)
if len(titles) > maxCount {
maxCount = len(titles)
}
if len(descriptions) > maxCount {
maxCount = len(descriptions)
}
for i := 0; i < maxCount; i++ {
block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)}
if i < len(names) {
item := names[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1))
block.Fields = append(block.Fields, item.View)
}
if i < len(titles) {
item := titles[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1))
block.Fields = append(block.Fields, item.View)
}
if i < len(descriptions) {
item := descriptions[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1))
block.Fields = append(block.Fields, item.View)
}
if len(block.Fields) > 0 {
section.EditableGroups = append(section.EditableGroups, block)
}
}
section.EditableFields = append(section.EditableFields, other...)
return section
}

func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback)
}

func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
titles := make([]pendingField, 0)
descriptions := make([]pendingField, 0)
names := make([]pendingField, 0)
other := make([]buildFieldView, 0)

for _, pf := range fields {
key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
switch {
case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"):
titles = append(titles, pf)
case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"):
descriptions = append(descriptions, pf)
case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"):
names = append(names, pf)
default:
pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName))
other = append(other, pf.View)
}
}

maxCount := len(titles)
if len(descriptions) > maxCount {
maxCount = len(descriptions)
}
if len(names) > maxCount {
maxCount = len(names)
}
for i := 0; i < maxCount; i++ {
block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)}
if i < len(titles) {
item := titles[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1))
block.Fields = append(block.Fields, item.View)
}
if i < len(descriptions) {
item := descriptions[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1))
block.Fields = append(block.Fields, item.View)
}
if i < len(names) {
item := names[i]
item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1))
block.Fields = append(block.Fields, item.View)
}
if len(block.Fields) > 0 {
section.EditableGroups = append(section.EditableGroups, block)
}
}
section.EditableFields = append(section.EditableFields, other...)
return section
}

func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback)
}

func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
for _, pf := range fields {
pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName))
section.EditableFields = append(section.EditableFields, pf.View)
}
sort.SliceStable(section.EditableFields, func(i, j int) bool {
return section.EditableFields[i].Path < section.EditableFields[j].Path
})
return section
}

func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView {
grouped := map[string][]pendingField{}
withoutBlockID := make([]pendingField, 0)
for _, pf := range fields {
blockID := extractBlockID(pf.Field)
if blockID == "" {
withoutBlockID = append(withoutBlockID, pf)
continue
}
data.EnabledFields = append(data.EnabledFields, buildFieldView{
Path: f.Path,
DisplayLabel: f.DisplayLabel,
SampleValue: f.SampleValue,
Value: strings.TrimSpace(fieldValues[f.Path]),
grouped[blockID] = append(grouped[blockID], pf)
}

if len(grouped) > 0 {
blockIDs := make([]string, 0, len(grouped))
for blockID := range grouped {
blockIDs = append(blockIDs, blockID)
}
sort.SliceStable(blockIDs, func(i, j int) bool {
li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j])
if li != lj {
return li < lj
}
return blockIDs[i] < blockIDs[j]
})

for _, blockID := range blockIDs {
items := grouped[blockID]
sort.SliceStable(items, func(i, j int) bool {
ri := deriveFieldRole(items[i].Field.KeyName)
rj := deriveFieldRole(items[j].Field.KeyName)
if ri.Order != rj.Order {
return ri.Order < rj.Order
}
return items[i].Field.Path < items[j].Field.Path
})

group := buildFieldGroupView{Title: blockGroupTitle(blockID)}
for _, item := range items {
role := deriveFieldRole(item.Field.KeyName)
fallbackLabel := role.Label
if fallbackLabel == "" {
fallbackLabel = humanizeKey(item.Field.KeyName)
}
item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel)
group.Fields = append(group.Fields, item.View)
}
if len(group.Fields) > 0 {
section.EditableGroups = append(section.EditableGroups, group)
}
}
}
return data, nil

if len(withoutBlockID) > 0 {
if fallback != nil {
return fallback(section, withoutBlockID)
}
for _, pf := range withoutBlockID {
labelPrefix := fallbackPrefix
if labelPrefix == "" {
labelPrefix = normalizedSectionTitle(pf.Field.Section)
}
pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName))
section.EditableFields = append(section.EditableFields, pf.View)
}
}
return section
}

func extractBlockID(f domain.TemplateField) string {
candidates := []string{f.KeyName, f.Path}
for _, candidate := range candidates {
normalized := strings.ToLower(strings.TrimSpace(candidate))
if normalized == "" {
continue
}
if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
return match[1]
}
if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
return match[1]
}
}
return ""
}

func blockGroupTitle(blockID string) string {
blockID = strings.ToLower(strings.TrimSpace(blockID))
if blockID == "" {
return "Unbekannter Block"
}
if known, ok := knownBlockAreas[blockID]; ok {
return fmt.Sprintf("%s (%s)", known, blockID)
}
return "Block " + blockID
}

func blockSortRank(blockID string) int {
switch strings.ToLower(strings.TrimSpace(blockID)) {
case "m1710":
return 10
case "c7886":
return 20
case "r4830":
return 30
case "c2929":
return 40
case "r4748":
return 50
case "r1508":
return 60
case "c1165":
return 70
case "m4178":
return 80
default:
return 1000
}
}

func deriveFieldRole(key string) fieldRole {
normalized := strings.ToLower(strings.TrimSpace(key))
switch {
case strings.Contains(normalized, "subtitle"):
return fieldRole{Label: "Untertitel", Order: 15}
case strings.Contains(normalized, "title"):
return fieldRole{Label: "Titel", Order: 20}
case strings.Contains(normalized, "description"):
return fieldRole{Label: "Beschreibung", Order: 30}
case strings.Contains(normalized, "name"):
return fieldRole{Label: "Name", Order: 40}
case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"):
return fieldRole{Label: "CTA Text", Order: 50}
default:
return fieldRole{Label: humanizeKey(key), Order: 100}
}
}

func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) {
editable := make([]buildFieldView, 0)
nextIndex := 0
for si := range sections {
for gi := range sections[si].EditableGroups {
for fi := range sections[si].EditableGroups[gi].Fields {
sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex
editable = append(editable, sections[si].EditableGroups[gi].Fields[fi])
nextIndex++
}
}
for fi := range sections[si].EditableFields {
sections[si].EditableFields[fi].Index = nextIndex
editable = append(editable, sections[si].EditableFields[fi])
nextIndex++
}
}
return editable, sections
}

func preferredBuildSection(f domain.TemplateField) string {
websiteSection := strings.TrimSpace(f.WebsiteSection)
if websiteSection != "" {
normalized := domain.NormalizeWebsiteSection(websiteSection)
if normalized == domain.WebsiteSectionServiceItem {
return domain.WebsiteSectionServices
}
return normalized
}
return fallbackBuildSection(f)
}

func fallbackBuildSection(f domain.TemplateField) string {
switch normalizedSection(f.Section) {
case "services":
return domain.WebsiteSectionServices
case "testimonials":
return domain.WebsiteSectionTestimonials
case "text":
if isMediaOrGalleryField(f) {
return domain.WebsiteSectionGallery
}
return domain.WebsiteSectionOther
default:
if isMediaOrGalleryField(f) {
return domain.WebsiteSectionGallery
}
return domain.WebsiteSectionOther
}
}

func normalizedSection(raw string) string {
section := strings.ToLower(strings.TrimSpace(raw))
switch section {
case "ext":
return "text"
default:
return section
}
}

func normalizedSectionTitle(raw string) string {
switch normalizedSection(raw) {
case "text":
return "Text"
case "services":
return "Services"
case "testimonials":
return "Testimonials"
case "gallery", "media":
return "Media"
default:
return "Feld"
}
}

func isMediaOrGalleryField(f domain.TemplateField) bool {
if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") {
return true
}
section := strings.ToLower(strings.TrimSpace(f.Section))
key := strings.ToLower(strings.TrimSpace(f.KeyName))
path := strings.ToLower(strings.TrimSpace(f.Path))
if section == "gallery" || section == "media" {
return true
}
hints := []string{"gallery", "image", "img", "photo", "picture"}
for _, hint := range hints {
if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) {
return true
}
}
return false
}

func effectiveLabel(f domain.TemplateField, fallback string) string {
if !isRawPathLikeLabel(f.DisplayLabel, f.Path) {
return strings.TrimSpace(f.DisplayLabel)
}
return strings.TrimSpace(fallback)
}

func isRawPathLikeLabel(label string, path string) bool {
l := strings.TrimSpace(label)
if l == "" {
return true
}
if strings.EqualFold(l, strings.TrimSpace(path)) {
return true
}
if strings.Contains(l, ".") || strings.Contains(l, "_") {
return true
}
return false
}

func humanizeKey(key string) string {
raw := strings.TrimSpace(key)
if raw == "" {
return "Inhalt"
}
base := raw
if idx := strings.Index(base, "_"); idx > 0 {
base = base[:idx]
}
runes := make([]rune, 0, len(base)+4)
for i, r := range base {
if i > 0 && unicode.IsUpper(r) {
runes = append(runes, ' ')
}
runes = append(runes, r)
}
human := strings.TrimSpace(string(runes))
if human == "" {
return "Inhalt"
}
words := strings.Fields(strings.ToLower(human))
for i := range words {
if len(words[i]) > 0 {
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
}
}
return strings.Join(words, " ")
}

func buildFormInputFromRequest(r *http.Request) buildFormInput {
@@ -642,3 +1247,15 @@ func defaultDraftStatus(status string) string {
return "draft"
}
}

func websiteSectionOptions() []websiteSectionOptionView {
values := domain.WebsiteSectionOptions()
out := make([]websiteSectionOptionView, 0, len(values))
for _, value := range values {
out = append(out, websiteSectionOptionView{
Value: value,
Label: domain.WebsiteSectionLabel(value),
})
}
return out
}

+ 117
- 0
internal/httpserver/handlers/ui_grouping_test.go 查看文件

@@ -0,0 +1,117 @@
package handlers

import (
"testing"

"qctextbuilder/internal/domain"
)

func TestExtractBlockID(t *testing.T) {
t.Parallel()

tests := []struct {
name string
f domain.TemplateField
want string
}{
{
name: "from key",
f: domain.TemplateField{
KeyName: "textTitle_m1710_1",
Path: "text.textTitle_m1710_1",
},
want: "m1710",
},
{
name: "from path",
f: domain.TemplateField{
KeyName: "servicesTitle_8",
Path: "services.servicesTitle_r4830_8",
},
want: "r4830",
},
{
name: "none",
f: domain.TemplateField{
KeyName: "plainTitle",
Path: "text.plainTitle",
},
want: "",
},
}

for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := extractBlockID(tc.f)
if got != tc.want {
t.Fatalf("extractBlockID() = %q, want %q", got, tc.want)
}
})
}
}

func TestApplyTextGroupingPrefersBlockID(t *testing.T) {
t.Parallel()

section := buildFieldSectionView{Key: "text", Title: "Text"}
fields := []pendingField{
{
Field: domain.TemplateField{
Section: "text",
KeyName: "textTitle_m1710_1",
Path: "text.textTitle_m1710_1",
DisplayLabel: "text.textTitle_m1710_1",
},
View: buildFieldView{Path: "text.textTitle_m1710_1"},
},
{
Field: domain.TemplateField{
Section: "ext",
KeyName: "textDescription_c7886_3",
Path: "ext.textDescription_c7886_3",
DisplayLabel: "ext.textDescription_c7886_3",
},
View: buildFieldView{Path: "ext.textDescription_c7886_3"},
},
{
Field: domain.TemplateField{
Section: "text",
KeyName: "textDescription_m1710_2",
Path: "text.textDescription_m1710_2",
DisplayLabel: "text.textDescription_m1710_2",
},
View: buildFieldView{Path: "text.textDescription_m1710_2"},
},
}

got := applyTextGrouping(section, fields)
if len(got.EditableGroups) != 2 {
t.Fatalf("expected 2 block groups, got %d", len(got.EditableGroups))
}
if got.EditableGroups[0].Title != "Hero / Haupttitel (m1710)" {
t.Fatalf("unexpected first group title: %q", got.EditableGroups[0].Title)
}
if len(got.EditableGroups[0].Fields) != 2 {
t.Fatalf("expected 2 fields in first group, got %d", len(got.EditableGroups[0].Fields))
}
if got.EditableGroups[1].Title != "Intro / Einleitung (c7886)" {
t.Fatalf("unexpected second group title: %q", got.EditableGroups[1].Title)
}
}

func TestPreferredBuildSectionUsesWebsiteSectionFirst(t *testing.T) {
t.Parallel()

field := domain.TemplateField{
Section: "text",
WebsiteSection: domain.WebsiteSectionCTA,
KeyName: "textTitle",
Path: "text.textTitle",
}
got := preferredBuildSection(field)
if got != domain.WebsiteSectionCTA {
t.Fatalf("preferredBuildSection() = %q, want %q", got, domain.WebsiteSectionCTA)
}
}

+ 15
- 4
internal/onboarding/service.go 查看文件

@@ -43,6 +43,7 @@ type FieldPatch struct {
IsRequiredByUs *bool
DisplayLabel *string
DisplayOrder *int
WebsiteSection *string
Notes *string
}

@@ -150,6 +151,9 @@ func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, ma
if patch.DisplayOrder != nil {
fields[idx].DisplayOrder = *patch.DisplayOrder
}
if patch.WebsiteSection != nil {
fields[idx].WebsiteSection = domain.NormalizeWebsiteSection(*patch.WebsiteSection)
}
if patch.Notes != nil {
fields[idx].Notes = strings.TrimSpace(*patch.Notes)
}
@@ -203,10 +207,17 @@ func flattenDiscovery(templateID int64, manifestID string, data qcclient.Generat
enabled := kind == "text"

fields = append(fields, domain.TemplateField{
ID: fmt.Sprintf("%s-%d", manifestID, order+1),
TemplateID: templateID,
ManifestID: manifestID,
Section: section,
ID: fmt.Sprintf("%s-%d", manifestID, order+1),
TemplateID: templateID,
ManifestID: manifestID,
Section: section,
WebsiteSection: domain.SuggestWebsiteSection(domain.TemplateField{
Section: section,
KeyName: key,
Path: path,
FieldKind: kind,
SampleValue: sample,
}),
KeyName: key,
Path: path,
FieldKind: kind,


+ 8
- 0
internal/onboarding/service_test.go 查看文件

@@ -72,9 +72,11 @@ func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) {

byPath := map[string]string{}
enabled := map[string]bool{}
websiteSections := map[string]string{}
for _, f := range fields {
byPath[f.Path] = f.FieldKind
enabled[f.Path] = f.IsEnabled
websiteSections[f.Path] = f.WebsiteSection
}

imagePath := "gallery.galleryImage_m4178_15"
@@ -84,6 +86,9 @@ func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) {
if enabled[imagePath] {
t.Fatalf("field %s should be disabled by default", imagePath)
}
if websiteSections[imagePath] != "gallery" {
t.Fatalf("field %s websiteSection = %q, want gallery", imagePath, websiteSections[imagePath])
}

textPath := "hero.title"
if byPath[textPath] != "text" {
@@ -92,4 +97,7 @@ func TestFlattenDiscovery_DisablesImageLikeFields(t *testing.T) {
if !enabled[textPath] {
t.Fatalf("field %s should be enabled by default", textPath)
}
if websiteSections[textPath] != "hero" {
t.Fatalf("field %s websiteSection = %q, want hero", textPath, websiteSections[textPath])
}
}

+ 71
- 0
internal/store/sqlite/migrations/002_add_website_section_to_fields.sql 查看文件

@@ -0,0 +1,71 @@
ALTER TABLE qc_template_fields
ADD COLUMN website_section TEXT NOT NULL DEFAULT 'other';

UPDATE qc_template_fields
SET website_section = CASE
WHEN lower(field_kind) = 'image'
OR lower(section) LIKE '%gallery%'
OR lower(section) LIKE '%media%'
OR lower(key_name) LIKE '%gallery%'
OR lower(key_name) LIKE '%image%'
OR lower(key_name) LIKE '%photo%'
OR lower(path) LIKE '%gallery%'
OR lower(path) LIKE '%image%'
OR lower(path) LIKE '%photo%'
THEN 'gallery'
WHEN lower(section) LIKE '%testimonial%'
OR lower(key_name) LIKE '%testimonial%'
OR lower(path) LIKE '%testimonial%'
THEN 'testimonials'
WHEN lower(section) = 'services'
AND (lower(key_name) LIKE '%item%' OR lower(key_name) GLOB '*_[0-9]*')
THEN 'service_item'
WHEN lower(section) = 'services'
OR lower(key_name) LIKE '%service%'
OR lower(path) LIKE '%service%'
THEN 'services'
WHEN lower(section) LIKE '%team%'
OR lower(key_name) LIKE '%team%'
OR lower(key_name) LIKE '%member%'
OR lower(path) LIKE '%team%'
OR lower(path) LIKE '%member%'
THEN 'team'
WHEN lower(section) LIKE '%hero%'
OR lower(key_name) LIKE '%hero%'
OR lower(path) LIKE '%hero%'
OR lower(key_name) LIKE '%welcome%'
OR lower(path) LIKE '%welcome%'
OR lower(key_name) LIKE '%banner%'
OR lower(path) LIKE '%banner%'
THEN 'hero'
WHEN lower(section) LIKE '%intro%'
OR lower(key_name) LIKE '%intro%'
OR lower(path) LIKE '%intro%'
THEN 'intro'
WHEN lower(section) LIKE '%about%'
OR lower(key_name) LIKE '%about%'
OR lower(path) LIKE '%about%'
OR length(sample_value) > 180
THEN 'about'
WHEN lower(section) LIKE '%cta%'
OR lower(key_name) LIKE '%cta%'
OR lower(path) LIKE '%cta%'
OR lower(key_name) LIKE '%button%'
OR lower(path) LIKE '%button%'
OR lower(key_name) LIKE '%highlight%'
OR lower(path) LIKE '%highlight%'
THEN 'cta'
WHEN lower(section) LIKE '%contact%'
OR lower(key_name) LIKE '%contact%'
OR lower(path) LIKE '%contact%'
OR lower(key_name) LIKE '%email%'
OR lower(path) LIKE '%email%'
OR lower(key_name) LIKE '%phone%'
OR lower(path) LIKE '%phone%'
THEN 'contact'
WHEN lower(section) LIKE '%footer%'
OR lower(key_name) LIKE '%footer%'
OR lower(path) LIKE '%footer%'
THEN 'footer'
ELSE 'other'
END;

+ 9
- 8
internal/store/sqlite/store.go 查看文件

@@ -188,10 +188,10 @@ func (s *Store) CreateManifest(ctx context.Context, manifest domain.TemplateMani
for _, f := range fields {
_, err := tx.ExecContext(ctx, `
INSERT INTO qc_template_fields (
id, template_id, manifest_id, section, key_name, path, field_kind,
id, template_id, manifest_id, section, website_section, key_name, path, field_kind,
sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind,
f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
)
if err != nil {
@@ -218,7 +218,7 @@ func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID in

func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, template_id, manifest_id, section, key_name, path, field_kind, sample_value,
SELECT id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value,
is_enabled, is_required_by_us, display_label, display_order, notes
FROM qc_template_fields
WHERE manifest_id = ?
@@ -233,13 +233,14 @@ func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) (
var f domain.TemplateField
var isEnabled, isRequired int
if err := rows.Scan(
&f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue,
&f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.WebsiteSection, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue,
&isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes,
); err != nil {
return nil, err
}
f.IsEnabled = isEnabled == 1
f.IsRequiredByUs = isRequired == 1
f.WebsiteSection = domain.NormalizeWebsiteSection(f.WebsiteSection)
fields = append(fields, f)
}
if err := rows.Err(); err != nil {
@@ -271,10 +272,10 @@ func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []do
for _, f := range fields {
_, err := tx.ExecContext(ctx, `
INSERT INTO qc_template_fields (
id, template_id, manifest_id, section, key_name, path, field_kind,
id, template_id, manifest_id, section, website_section, key_name, path, field_kind,
sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind,
f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
)
if err != nil {


+ 56
- 9
web/templates/build_new.gohtml 查看文件

@@ -39,7 +39,7 @@
<input type="hidden" name="draft_id" value="{{.Form.DraftID}}">
<input type="hidden" name="template_id" value="{{.SelectedTemplateID}}">
<input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}">
<input type="hidden" name="field_count" value="{{len .EnabledFields}}">
<input type="hidden" name="field_count" value="{{len .EditableFields}}">

<h2>Global Data</h2>
<div class="grid2">
@@ -90,26 +90,73 @@
<div><label>Land<input type="text" name="address_country" value="{{.Form.AddressCountry}}"></label></div>
</div>

<h2>Enabled Text Fields</h2>
<h2>Template-Felder</h2>
{{range .FieldSections}}
<h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}

{{range .EditableGroups}}
<h4>{{.Title}}</h4>
<table>
<thead>
<tr><th>Field</th><th>Value</th><th>Sample</th></tr>
</thead>
<tbody>
{{range $i, $f := .EnabledFields}}
{{range .Fields}}
<tr>
<td>
<input type="hidden" name="field_path_{{$i}}" value="{{$f.Path}}">
{{$f.DisplayLabel}}<br><span class="mono">{{$f.Path}}</span>
<input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}">
{{.DisplayLabel}}<br><span class="mono">{{.Path}}</span>
</td>
<td><textarea name="field_value_{{$i}}">{{$f.Value}}</textarea></td>
<td class="mono">{{$f.SampleValue}}</td>
<td><textarea name="field_value_{{.Index}}">{{.Value}}</textarea></td>
<td class="mono">{{.SampleValue}}</td>
</tr>
{{else}}
<tr><td colspan="3">No enabled text fields found for this template.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

{{if .EditableFields}}
<table>
<thead>
<tr><th>Field</th><th>Value</th><th>Sample</th></tr>
</thead>
<tbody>
{{range .EditableFields}}
<tr>
<td>
<input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}">
{{.DisplayLabel}}<br><span class="mono">{{.Path}}</span>
</td>
<td><textarea name="field_value_{{.Index}}">{{.Value}}</textarea></td>
<td class="mono">{{.SampleValue}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

{{if .DisabledFields}}
<table>
<thead>
<tr><th>Field</th><th>Status</th><th>Sample</th></tr>
</thead>
<tbody>
{{range .DisabledFields}}
<tr>
<td>{{.DisplayLabel}}<br><span class="mono">{{.Path}}</span></td>
<td>Erkannt, deaktiviert (MVP ohne Bildlogik)</td>
<td class="mono">{{.SampleValue}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}

{{if eq (len .EditableFields) 0}}
<p>No enabled text fields found for this template.</p>
{{end}}

<button type="submit" formaction="/builds/drafts">Save Draft</button>
<button type="submit">Start Build</button>


+ 8
- 0
web/templates/template_detail.gohtml 查看文件

@@ -50,6 +50,7 @@
<th>Required</th>
<th>Label</th>
<th>Order</th>
<th>Website Section</th>
<th>Notes</th>
<th>Sample</th>
</tr>
@@ -66,6 +67,13 @@
<td><input type="checkbox" name="field_required_{{$i}}" {{if $f.IsRequiredByUs}}checked{{end}}></td>
<td><input type="text" name="field_label_{{$i}}" value="{{$f.DisplayLabel}}"></td>
<td><input type="number" name="field_order_{{$i}}" value="{{$f.DisplayOrder}}"></td>
<td>
<select name="field_website_section_{{$i}}">
{{range $opt := $.WebsiteSectionOptions}}
<option value="{{$opt.Value}}" {{if eq $f.WebsiteSection $opt.Value}}selected{{end}}>{{$opt.Label}}</option>
{{end}}
</select>
</td>
<td><input type="text" name="field_notes_{{$i}}" value="{{$f.Notes}}"></td>
<td class="mono">{{$f.SampleValue}}</td>
</tr>


正在加载...
取消
保存