// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package user import ( "encoding/csv" "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" "go.opentelemetry.io/otel/trace" "github.com/pkg/errors" "github.com/sirupsen/logrus" "gorm.io/datatypes" "github.com/tamakyi/TamaBox/internal/cache" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/context" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/security/censor" "github.com/tamakyi/TamaBox/internal/security/moderation" votepkg "github.com/tamakyi/TamaBox/route/vote" ) // voteTextRepliesCache 表单投票「回复明细」的短 TTL 缓存:该视图需逐条 json.Unmarshal // 全部提交记录(O(N)),结果页非强一致,30s 陈旧可接受。 var voteTextRepliesCache = cache.NewTTLCache(64) const voteTextRepliesCacheTTL = 30 * time.Second // ---------------- 表单解析 ---------------- // voteFormFieldInput 表单字段的前端输入结构(vote-form.html 中的 JS 数组)。 type voteFormFieldInput struct { FieldType string `json:"field_type"` Label string `json:"label"` Required bool `json:"required"` MaxLength int `json:"max_length"` Options []string `json:"options"` // 选项数组(select/radio/checkbox/rank),前端逐条添加 AllowCustom bool `json:"allow_custom"` // 允许补充自定义选项(radio/select/checkbox) } // voteForm 解析后的完整表单(含选项与表单字段)。 type voteForm struct { opts db.CreateVoteOptions options []string formFields []db.CreateVoteFormFieldOptions censorMetadata datatypes.JSON // 标题审核结果 } // censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫, // 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。 func censorCheck(ctx context.Context, text string) error { _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), text) return err } // parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空串返回 nil。 // 用 time.Local 而非 time.Parse:后者会解析成 UTC,与 time.Now() 比较时产生时区偏移。 func parseTimeInput(ctx context.Context, s string) (*time.Time, error) { s = strings.TrimSpace(s) if s == "" { return nil, nil } t, err := time.ParseInLocation("2006-01-02T15:04", s, time.Local) if err != nil { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.timeFormat")) } return &t, nil } // parseVoteForm 手动解析投票表单(元信息 + 类型 + 选项/字段)。 // 不用 form.Bind:其反射绑定会把 string 直接赋给 int 字段导致 panic, // 且选项/字段数组需 JSON 解析。draft=true 时放宽数量与审核要求,便于随时暂存。 func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) { form, err := parseVoteMeta(ctx, draft) if err != nil { return nil, err } voteType := strings.TrimSpace(ctx.Request().FormValue("type")) switch voteType { case db.VoteTypeSingle, db.VoteTypeMultiple, db.VoteTypeRating, db.VoteTypeRank, db.VoteTypeForm: default: return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.type")) } form.opts.Type = voteType maxChoices := 1 if voteType == db.VoteTypeMultiple { maxChoices, _ = strconv.Atoi(strings.TrimSpace(ctx.Request().FormValue("max_choices"))) if maxChoices < 1 { maxChoices = 1 } if maxChoices > 20 { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.maxChoicesLimit")) } } form.opts.MaxChoices = maxChoices if voteType == db.VoteTypeForm { if err := parseFormFields(ctx, form, draft); err != nil { return nil, err } } else { if err := parseOptions(ctx, form, voteType, maxChoices, draft); err != nil { return nil, err } } return form, nil } // parseVoteMetaForm 仅解析元信息(发布后编辑用):标题/描述/可见性/范围/结果策略/ // 自定义开关/时间窗/防刷/验证码。类型与选项/字段发布后不可修改(避免破坏历史数据归因)。 func parseVoteMetaForm(ctx context.Context) (*voteForm, error) { return parseVoteMeta(ctx, false) } // parseVoteMeta 解析投票元信息(不含类型与选项/字段)。draft=true 时放宽校验并跳过 // 内容审核:草稿为私有内容,不应因审核服务抖动而丢失;审核统一在发布时补齐。 func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) { get := func(name string) string { return strings.TrimSpace(ctx.Request().FormValue(name)) } title := get("title") // 草稿允许暂存空标题(发布前再补全);非草稿必须填写 if title == "" && !draft { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.titleRequired")) } if len([]rune(title)) > 100 { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.titleTooLong")) } description := get("description") if len([]rune(description)) > 5000 { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.descriptionTooLong")) } // 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明) var censorMeta datatypes.JSON if !draft { titleCensor, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), title) if err != nil { return nil, err } censorMeta = datatypes.JSON(titleCensor.ToJSON()) if description != "" { if err := censorCheck(ctx, description); err != nil { return nil, err } } } visibility := get("visibility") switch visibility { case db.VoteVisibilityPublic, db.VoteVisibilityLinkOnly: default: return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.visibility")) } voterScope := get("voter_scope") switch voterScope { case db.VoteVoterScopeAnyone, db.VoteVoterScopeRegisteredOnly: default: return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.voterScope")) } showResult := get("show_result") switch showResult { case db.VoteShowResultAfterVote, db.VoteShowResultAfterEnd, db.VoteShowResultOwnerOnly: default: return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.showResult")) } allowCustom := get("allow_custom") == "on" // 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启,关闭复选框无法解除。 requireCaptcha := true // 一人一票由业务规则固定;不从客户端读取已废弃字段,避免与数据库约束冲突。 maxVotes := 1 startAt, err := parseTimeInput(ctx, get("start_at")) if err != nil { return nil, err } endAt, err := parseTimeInput(ctx, get("end_at")) if err != nil { return nil, err } if startAt != nil && endAt != nil && !endAt.After(*startAt) { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.timeRange")) } // 定量收集上限:收集到指定份数后自动结束该投票(0 = 不限制) quotaLimit, err := parseQuotaLimit(get("quota_limit")) if err != nil { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.quota")) } return &voteForm{ opts: db.CreateVoteOptions{ UserID: ctx.User.ID, Title: title, Description: description, Visibility: visibility, VoterScope: voterScope, ShowResult: showResult, AllowCustom: allowCustom, StartAt: startAt, EndAt: endAt, MaxVotesPerFingerprint: maxVotes, RequireCaptcha: requireCaptcha, QuotaLimit: int(quotaLimit), }, censorMetadata: censorMeta, }, nil } func parseOptions(ctx context.Context, form *voteForm, voteType string, maxChoices int, draft bool) error { raw := strings.TrimSpace(ctx.Request().FormValue("options")) if raw == "" { // 草稿允许暂未添加选项;非草稿必须至少 2 个 if !draft { return errors.New(i18n.T(ctx.Lang(), "vote.err.optionsTooFew")) } form.options = nil return nil } var options []string if err := json.Unmarshal([]byte(raw), &options); err != nil { return errors.New(i18n.T(ctx.Lang(), "vote.err.optionsFormat")) } cleaned := make([]string, 0, len(options)) for _, o := range options { o = strings.TrimSpace(o) if o == "" { continue } if len([]rune(o)) > conf.Limits.VoteOptionLen { return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen)) } // 选项内容审核(fail-closed):草稿跳过,发布时统一审核 if !draft { if err := censorCheck(ctx, o); err != nil { return err } } cleaned = append(cleaned, o) } // 评分题不需要选项(固定 1-10 分) if voteType == db.VoteTypeRating { form.options = nil return nil } // 草稿允许暂存少于 2 个选项;非草稿必须至少 2 个 if !draft && len(cleaned) < 2 { return errors.New(i18n.T(ctx.Lang(), "vote.err.optionsTooFew")) } if voteType == db.VoteTypeMultiple && maxChoices > len(cleaned) { return errors.New(i18n.T(ctx.Lang(), "vote.err.maxChoicesExceed")) } form.options = cleaned return nil } func parseFormFields(ctx context.Context, form *voteForm, draft bool) error { raw := strings.TrimSpace(ctx.Request().FormValue("form_fields")) if raw == "" { // 草稿允许暂未添加字段;非草稿必须至少 1 个 if !draft { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsTooFew")) } return nil } var inputs []voteFormFieldInput if err := json.Unmarshal([]byte(raw), &inputs); err != nil { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsFormat")) } if len(inputs) == 0 { if !draft { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsTooFew")) } return nil } if len(inputs) > 50 { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsTooMany")) } // label 必须唯一:FormData 以 label 为 key,重复会导致提交数据互相覆盖 seenLabels := make(map[string]bool, len(inputs)) for i, in := range inputs { switch in.FieldType { case db.VoteFormFieldTypeText, db.VoteFormFieldTypeTextarea, db.VoteFormFieldTypeSelect, db.VoteFormFieldTypeRadio, db.VoteFormFieldTypeCheckbox, db.VoteFormFieldTypeNumber, db.VoteFormFieldTypeRating, db.VoteFormFieldTypeRank: default: return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldType")) } label := strings.TrimSpace(in.Label) if label == "" { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldLabelMissing", i+1)) } if len([]rune(label)) > 100 { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldLabelTooLong", i+1)) } if seenLabels[label] { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldLabelDuplicate", label)) } seenLabels[label] = true if in.MaxLength < 0 || in.MaxLength > 2000 { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldMaxLength", i+1)) } // 字段标签审核(fail-closed):草稿跳过,发布时统一审核 if !draft { if err := censorCheck(ctx, label); err != nil { return err } } field := db.CreateVoteFormFieldOptions{ FieldType: in.FieldType, Label: label, Required: in.Required, MaxLength: in.MaxLength, SortOrder: i, AllowCustom: in.AllowCustom, } switch in.FieldType { case db.VoteFormFieldTypeSelect, db.VoteFormFieldTypeRadio, db.VoteFormFieldTypeCheckbox, db.VoteFormFieldTypeRank: var opts []string for _, raw := range in.Options { opts = append(opts, splitOptionTokens(raw)...) } // "其他"保留标记不允许作为预设选项内容(参与端用它触发自定义输入) for _, o := range opts { if o == db.CustomFieldMarker { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label)) } // 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验) if len([]rune(o)) > conf.Limits.VoteOptionLen { return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen)) } } if len(opts) < 1 { // 草稿允许暂未填写字段选项;非草稿必须至少 1 个 if !draft { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldOptionsTooFew", i+1, label)) } } if in.FieldType == db.VoteFormFieldTypeRank && len(opts) < 2 { if !draft { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldRankTooFew", i+1, label)) } } // 预置选项审核:草稿跳过,发布时统一审核 if !draft { for _, o := range opts { if err := censorCheck(ctx, o); err != nil { return err } } } optsJSON, err := json.Marshal(opts) if err != nil { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldSerialize")) } field.Options = string(optsJSON) } form.formFields = append(form.formFields, field) } return nil } // ---------------- 归属校验 ---------------- // getOwnedVote 获取当前登录用户拥有的投票,校验归属。 func getOwnedVote(ctx context.Context, voteIDStr string) (*db.Vote, error) { voteID, err := strconv.ParseUint(voteIDStr, 10, 64) if err != nil { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.invalidID")) } vote, err := db.Votes.GetByID(ctx.Request().Context(), uint(voteID)) if err != nil { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.notExist")) } if vote.UserID != ctx.User.ID { return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.noPermission")) } return vote, nil } // ---------------- 处理器 ---------------- // effectiveVoteStatus 计算投票的有效展示状态(status + 时间窗联合判断): // published 且未到开始时间 → 未开始;已过结束时间 → 已结束。 // 避免「已过结束时间仍显示进行中」。 func effectiveVoteStatus(v *db.Vote, now time.Time) string { if v.Status != db.VoteStatusPublished { return v.Status } if v.StartAt != nil && now.Before(*v.StartAt) { return db.VoteStatusNotStarted } if v.EndAt != nil && !now.Before(*v.EndAt) { return db.VoteStatusEnded } return db.VoteStatusPublished } // formatRemaining 格式化投票剩余时间(如"剩余 2 天 3 小时"),多语言文案。 func formatRemaining(lang string, endAt time.Time, now time.Time) string { d := endAt.Sub(now) if d <= 0 { return "" } days := int(d.Hours()) / 24 hours := int(d.Hours()) % 24 mins := int(d.Minutes()) % 60 parts := []string{} if days > 0 { parts = append(parts, fmt.Sprintf("%d%s", days, i18n.T(lang, "vote.durationDay"))) } if hours > 0 { parts = append(parts, fmt.Sprintf("%d%s", hours, i18n.T(lang, "vote.durationHour"))) } if mins > 0 && days == 0 { parts = append(parts, fmt.Sprintf("%d%s", mins, i18n.T(lang, "vote.durationMinute"))) } if len(parts) == 0 { return "" } return i18n.T(lang, "vote.remainLabel") + strings.Join(parts, " ") } // voteItem 管理面板/投票列表所需的富信息条目(参与数、有效状态、剩余时间)。 type voteItem struct { *db.Vote SubmissionCount int64 OptionCount int64 EffectiveStatus string RemainingText string // 有效进行中:剩余时间;无结束时间:无限期;其余为空 } // buildVoteItems 将投票列表转换为管理面板所需的富信息条目。 // 性能:参与数与选项数经 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN), // 替代原先每票各发 2 次 COUNT 的 N+1 查询(2N 条 SQL 降为 2 条)。 func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem { lang := i18n.Default if l, ok := ctx.Data["Lang"].(string); ok && l != "" { lang = l } now := time.Now() items := make([]*voteItem, 0, len(votes)) ids := make([]uint, 0, len(votes)) for _, v := range votes { ids = append(ids, v.ID) } subCounts := map[uint]int64{} optCounts := map[uint]int64{} if len(ids) > 0 { if m, err := db.VoteSubmissions.CountByVoteIDs(ctx.Request().Context(), ids); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch count submissions") } else { subCounts = m } if m, err := db.VoteOptions.CountByVoteIDs(ctx.Request().Context(), ids); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch count options") } else { optCounts = m } } for _, v := range votes { count := subCounts[v.ID] optionCount := optCounts[v.ID] eff := effectiveVoteStatus(v, now) remaining := "" if eff == db.VoteStatusPublished { if v.EndAt != nil { remaining = formatRemaining(lang, *v.EndAt, now) } else { remaining = i18n.T(lang, "vote.perpetual") } } items = append(items, &voteItem{ Vote: v, SubmissionCount: count, OptionCount: optionCount, EffectiveStatus: eff, RemainingText: remaining, }) } return items } // VoteList 投票管理列表(保留以兼容旧链接):投票管理已合并到「我的提问箱」的投票面板, // 直接跳转 /user/questions#votes,避免两套功能重复的页面。 func VoteList(ctx context.Context) { ctx.Redirect("/user/questions#votes") } // VoteNew 新建投票页面。 func VoteNew(ctx context.Context) { ctx.Data["Title"] = "新建投票" ctx.Data["IsEdit"] = false ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) // 默认投票类型为表单收集(用户要求);同时给定模板零值兜底,避免切到多选时 max_choices 显示 0。 ctx.Data["Vote"] = &db.Vote{ Type: db.VoteTypeForm, MaxChoices: 3, MaxVotesPerFingerprint: 1, } ctx.Data["InitialOptions"] = []string{} ctx.Data["InitialFields"] = []voteFormFieldInput{} ctx.Success("user/vote-form") } // renderVoteFormWithInput 在保存失败时重新渲染表单并回填已提交内容,避免整页输入被清空。 // isEdit=true 时回填 vote.ID,保留编辑动作的提交地址。 // // 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data, // 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。 func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) { ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) } title := get("title") description := get("description") voteType := get("type") visibility := get("visibility") voterScope := get("voter_scope") showResult := get("show_result") maxChoices, _ := strconv.Atoi(get("max_choices")) if maxChoices < 1 { maxChoices = 1 } // 与数据库唯一索引保持一致;该策略不再由表单配置。 maxVotes := 1 allowCustom := get("allow_custom") == "on" // 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启。 requireCaptcha := true startAt, _ := parseTimeInput(ctx, get("start_at")) endAt, _ := parseTimeInput(ctx, get("end_at")) quotaLimit, _ := parseQuotaLimit(get("quota_limit")) var initialOptions []string if raw := strings.TrimSpace(ctx.Request().FormValue("options")); raw != "" { _ = json.Unmarshal([]byte(raw), &initialOptions) } var initialFields []voteFormFieldInput if raw := strings.TrimSpace(ctx.Request().FormValue("form_fields")); raw != "" { _ = json.Unmarshal([]byte(raw), &initialFields) } vote := &db.Vote{ Title: title, Description: description, Type: voteType, Visibility: visibility, VoterScope: voterScope, ShowResult: showResult, MaxChoices: maxChoices, MaxVotesPerFingerprint: maxVotes, AllowCustom: allowCustom, RequireCaptcha: requireCaptcha, StartAt: startAt, EndAt: endAt, QuotaLimit: int(quotaLimit), } if isEdit { vote.ID = voteID } ctx.Data["Title"] = "新建投票" if isEdit { ctx.Data["Title"] = "编辑投票" } ctx.Data["IsEdit"] = isEdit ctx.Data["Vote"] = vote ctx.Data["LockedContent"] = false ctx.Data["InitialOptions"] = initialOptions ctx.Data["InitialFields"] = initialFields if startAt != nil { ctx.Data["StartAtStr"] = startAt.Format("2006-01-02T15:04") } if endAt != nil { ctx.Data["EndAtStr"] = endAt.Format("2006-01-02T15:04") } // 同请求错误提示:直接写入 c.Data(见函数注释,不能用 session flash) if internal { ctx.Data["HasError"] = true ctx.Data["Error"] = "服务内部错误,请稍后重试。" span := trace.SpanFromContext(ctx.Request().Context()) ctx.Data["FlashTip"] = fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", span.SpanContext().TraceID().String()) } else if flashMsg != "" { ctx.Data["HasError"] = true ctx.Data["Error"] = flashMsg } ctx.Success("user/vote-form") } // VoteCreate 创建投票(默认草稿状态)。草稿保存放宽校验并跳过内容审核; // 任何错误均回填表单而非清空输入。action=publish 时先存草稿再做发布校验, // 失败则保留草稿并停留在编辑页,避免重复创建。 func VoteCreate(ctx context.Context) { action := strings.TrimSpace(ctx.Request().FormValue("action")) form, err := parseVoteForm(ctx, true) if err != nil { renderVoteFormWithInput(ctx, false, 0, err.Error(), false) return } form.opts.CensorMetadata = form.censorMetadata vote, err := db.Votes.Create(ctx.Request().Context(), form.opts) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote") renderVoteFormWithInput(ctx, false, 0, "", true) return } if len(form.options) > 0 { if _, err := db.VoteOptions.BulkCreate(ctx.Request().Context(), vote.ID, form.options); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote options") _ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录 renderVoteFormWithInput(ctx, false, 0, "", true) return } } if len(form.formFields) > 0 { for i := range form.formFields { form.formFields[i].VoteID = vote.ID } if _, err := db.VoteFormFields.BulkCreate(ctx.Request().Context(), vote.ID, form.formFields); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote form fields") _ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录 renderVoteFormWithInput(ctx, false, 0, "", true) return } } editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit" if action == "publish" { if err := validateForPublish(ctx, vote); err != nil { ctx.SetErrorFlash(err.Error()) ctx.Redirect(editURL) return } if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusPublished); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to publish vote") ctx.SetInternalErrorFlash() ctx.Redirect(editURL) return } shareURL := fmt.Sprintf("%s/vote/%s", strings.TrimRight(conf.App.ExternalURL, "/"), vote.LinkToken) ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.published", shareURL)) ctx.Redirect("/user/questions#votes") return } // 草稿保存成功:停留在编辑页,使用页面 toast 提示,避免 success flash 与 toast 重复展示。 ctx.Redirect(editURL + "?saved=1") } // VoteEdit 编辑投票页面(预填选项与表单字段)。 func VoteEdit(ctx context.Context) { vote, err := getOwnedVote(ctx, ctx.Param("voteID")) if err != nil { ctx.SetErrorFlash(err.Error()) ctx.Redirect("/user/questions#votes") return } ctx.Data["Vote"] = vote ctx.Data["IsEdit"] = true ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) ctx.Data["Title"] = "编辑投票" // 草稿保存成功仅通过本次 URL 参数驱动统一 alert,不使用 session flash。 if ctx.Query("saved") == "1" { ctx.Data["Success"] = i18n.T(ctx.Lang(), "vote.flash.draftSaved") ctx.Data["DraftSaved"] = true } // 发布后的投票锁定类型与选项/字段(避免破坏历史投票数据归因),仅可编辑元信息 ctx.Data["LockedContent"] = vote.Status == db.VoteStatusPublished // 选项 / 字段回填传结构化数据,模板经 toJSON(template.JS)注入