This commit is contained in:
toom1996
2026-09-30 11:58:32 +08:00
parent 065d57457e
commit 1b06bd5bda
24 changed files with 2588 additions and 1659 deletions

View File

@ -207,9 +207,56 @@
if (focusMain) setExpanded(focusMain, true);
}
// ── 编辑页:实时翻译建议 + 季节码预览 ──────────────────────────────
// 与后端 pkg/season.Derive 同规则;仅用于只读预览,服务端保存时仍按同规则重算 season_code。
function deriveSeasonCode(year, collectionType, season) {
if (!year) return '';
var y = String(year).slice(-2);
var prefix = 'SS';
switch (collectionType) {
case 'resort': prefix = 'RES'; break;
case 'pre_fall': prefix = 'PF'; break;
case 'couture': case 'menswear': prefix = 'FW'; break;
default:
if (season === 'fall' || season === 'winter') prefix = 'FW';
else prefix = 'SS';
}
return prefix + y;
}
function initEditForm() {
var en = document.getElementById('rw-title-en') || document.getElementById('snap-title-en');
if (!en) return;
var cn = document.getElementById('rw-title-cn') || document.getElementById('snap-title-cn');
// 英文标题失焦且中文为空时,拉建议回填(不覆盖手填)。
en.addEventListener('blur', function () {
if (cn && cn.value.trim() === '') {
fetch('/admin/api/suggest-title-cn?title_en=' + encodeURIComponent(en.value))
.then(function (r) { return r.json(); })
.then(function (d) { if (d && d.title_cn) cn.value = d.title_cn; })
.catch(function () {});
}
});
// 走秀:年份/季节/系列变动即时算季节码(只读预览;服务端保存时仍按同规则重算)。
var year = document.getElementById('rw-year');
var season = document.getElementById('rw-season');
var collection = document.getElementById('rw-collection');
var code = document.getElementById('rw-season-code');
function refreshCode() {
if (!year || !code) return;
code.value = deriveSeasonCode(year.value, collection ? collection.value : '', season ? season.value : '');
}
if (year) year.addEventListener('input', refreshCode);
if (season) season.addEventListener('input', refreshCode);
if (collection) collection.addEventListener('input', refreshCode);
}
document.addEventListener('DOMContentLoaded', function () {
initZoom();
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
if (document.getElementById('batch')) initArticleDetail();
initEditForm();
});
})();

View File

@ -5,14 +5,16 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/repository"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/pkg/season"
"fashionapi/internal/repository"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
@ -34,11 +36,12 @@ type BackstageHandler struct {
review service.ReviewService
ingest *service.IngestService
adminRepo repository.AdminRepository
translate *service.TranslateService // 标题翻译(可为 nil:未启用时跳过记忆写入/建议)
}
// NewBackstageHandler 创建后台 handler。
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository, translate *service.TranslateService) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo, translate: translate}
}
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
@ -534,6 +537,23 @@ type ingestJobView struct {
// IngestJobs 分页列出入库任务,展示状态 / 重试次数 / 来源 / 错误 / 时间。
// 支持 ?page(从 1 起)与 ?pageSize(默认 50,上限 200);其余查询串原样忽略。
// SuggestTitleCn 后台编辑页实时翻译建议:给定英文标题,返回建议中文标题(JSON)。
// 仅对已登录后台暴露;翻译服务未注入时返回空 title_cn(前端据此不回填)。
func (h *BackstageHandler) SuggestTitleCn(c *gin.Context) {
en := c.Query("title_en")
if strings.TrimSpace(en) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing title_en"})
return
}
cn := ""
if h.translate != nil {
if s, err := h.translate.SuggestTitleCn(c.Request.Context(), en); err == nil {
cn = s
}
}
c.JSON(http.StatusOK, gin.H{"title_cn": cn})
}
func (h *BackstageHandler) IngestJobs(c *gin.Context) {
p := pagination.Parse(c, "page", "pageSize", 1, 50, 200)
offset := p.Offset()
@ -754,15 +774,24 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) {
year = uint16(y)
}
}
seasonVal := c.PostForm("season")
collectionType := c.PostForm("collection_type")
titleEn := c.PostForm("title_en")
titleCn := strings.TrimSpace(c.PostForm("title_cn"))
fields := map[string]any{
"title_en": c.PostForm("title_en"),
"title_cn": c.PostForm("title_cn"),
"title_en": titleEn,
"title_cn": titleCn,
"description_en": c.PostForm("description_en"),
"description_cn": c.PostForm("description_cn"),
"season": c.PostForm("season"),
"collection_type": c.PostForm("collection_type"),
"season_code": c.PostForm("season_code"),
"year": year,
"season": seasonVal,
"collection_type": collectionType,
// season_code 一律由「年份 + 系列 + 季节」推导,忽略表单原值,杜绝错码(见 pkg/season)。
"season_code": season.Derive(year, collectionType, seasonVal),
"year": year,
}
// 人工填写的中文标题写入翻译记忆,供后续入库/编辑优先命中(永久精确);翻译服务未注入时跳过。
if titleCn != "" && h.translate != nil {
_ = h.translate.Remember(c.Request.Context(), titleEn, titleCn)
}
if err := h.article.UpdateRunway(c.Request.Context(), numeric, fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
@ -999,12 +1028,18 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
year = uint16(y)
}
}
titleEn := c.PostForm("title")
titleCn := strings.TrimSpace(c.PostForm("title_cn"))
fields := map[string]any{
"title": c.PostForm("title"),
"title_cn": c.PostForm("title_cn"),
"title": titleEn,
"title_cn": titleCn,
"city": c.PostForm("city"),
"year": year,
}
// 人工填写的中文标题写入翻译记忆,供后续入库/编辑优先命中(永久精确);翻译服务未注入时跳过。
if titleCn != "" && h.translate != nil {
_ = h.translate.Remember(c.Request.Context(), titleEn, titleCn)
}
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return

View File

@ -7,10 +7,16 @@
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
<form method="post" action="/admin/runways/{{.UID}}/edit">
<div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div>
<div class="row"><div><label>英文标题</label><input name="title_en" id="rw-title-en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" id="rw-title-cn" value="{{.Runway.TitleCn}}"></div></div>
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" value="{{.Runway.Season}}"></div></div>
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" value="{{.Runway.CollectionType}}"></div><div><label>季节码 (season_code)</label><input name="season_code" value="{{.Runway.SeasonCode}}"></div></div>
<div class="row"><div><label>年份</label><input name="year" id="rw-year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" id="rw-season" list="season-list" value="{{.Runway.Season}}"></div></div>
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" id="rw-collection" list="collection-list" value="{{.Runway.CollectionType}}"></div><div><label>季节码(自动)</label><input name="season_code" id="rw-season-code" value="{{.Runway.SeasonCode}}" readonly></div></div>
<datalist id="season-list">
<option value="Spring"><option value="Summer"><option value="Fall"><option value="Winter"><option value="Resort"><option value="Pre-Spring"><option value="Pre-Fall">
</datalist>
<datalist id="collection-list">
<option value="rtw"><option value="menswear"><option value="couture"><option value="resort"><option value="pre_fall">
</datalist>
<button type="submit">保存字段</button>
</form>

View File

@ -7,7 +7,7 @@
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
<div class="row"><div><label>英文标题</label><input name="title" id="snap-title-en" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" id="snap-title-cn" value="{{.Snap.TitleCn}}"></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
<button type="submit">保存字段</button>
</form>