This commit is contained in:
谢宇宁
2026-09-29 15:30:33 +07:00
parent 0fbdba5ccf
commit 9abe0b1b76
181 changed files with 608 additions and 18501 deletions
-52
View File
@@ -1,52 +0,0 @@
package web
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
const maxAccountPage = 10000
type accountPage struct {
Current int
Total int
Count int64
PrevURL string
NextURL string
}
func accountPageNum(c *gin.Context) (int, bool) {
if c.Request.URL.Query().Has("page") {
return 0, false
}
raw := strings.TrimSpace(c.Param("page"))
if raw == "" {
return 1, true
}
page, err := strconv.Atoi(raw)
if err != nil || page < 1 || page > maxAccountPage {
return 0, false
}
return page, true
}
func makeAccountPage(path string, page, size int, count int64) accountPage {
total := int((count + int64(size) - 1) / int64(size))
p := accountPage{Current: page, Total: total, Count: count}
link := func(n int) string {
path = strings.TrimRight(path, "/")
if n <= 1 {
return path
}
return path + "/page/" + strconv.Itoa(n)
}
if page > 1 {
p.PrevURL = link(page - 1)
}
if page < total {
p.NextURL = link(page + 1)
}
return p
}
-290
View File
@@ -1,290 +0,0 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
type registerBody struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
Password string `json:"password"`
AgentInviteCode string `json:"agentInviteCode"`
DevID string `json:"devID"`
Channel string `json:"channel"`
}
type loginBody struct {
Account string `json:"account"`
Password string `json:"password"`
DevID string `json:"devID"`
Channel string `json:"channel"`
}
// Register 代理上游注册(POST /api/app/auth/register),成功后写入登录 cookie。
// 请求头带游客 Authorization 时升级该游客;邮箱缺省时用账号生成占位邮箱。
func Register(c *gin.Context) {
var body registerBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.AccountNo = strings.TrimSpace(body.AccountNo)
body.Email = strings.TrimSpace(body.Email)
body.AgentInviteCode = strings.TrimSpace(body.AgentInviteCode)
body.DevID = strings.TrimSpace(body.DevID)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.AccountNo == "" || body.Password == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写账号和密码",
"data": nil,
})
return
}
// 上游 registerReq 要求 email;前端无邮箱时用账号生成唯一占位邮箱。
if body.Email == "" {
body.Email = body.AccountNo + "@hgdj.local"
}
env, user, err := authser.Register(c, authser.RegisterReq{
AccountNo: body.AccountNo,
Email: body.Email,
Password: body.Password,
AgentInviteCode: body.AgentInviteCode,
DevID: body.DevID,
Channel: body.Channel,
})
if err != nil {
log.Error("register upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "register failed",
"tip": "注册失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "注册失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "注册成功"),
"data": user,
})
}
// Login 代理上游登录(POST /api/app/auth/login),成功后写入登录 cookie。
func Login(c *gin.Context) {
var body loginBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.Account = strings.TrimSpace(body.Account)
body.DevID = strings.TrimSpace(body.DevID)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.Account == "" || body.Password == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写账号和密码",
"data": nil,
})
return
}
env, user, err := authser.Login(c, authser.LoginReq{
Account: body.Account,
Password: body.Password,
DevID: body.DevID,
Channel: body.Channel,
})
if err != nil {
log.Error("login upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "login failed",
"tip": "登录失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "登录失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "登录成功"),
"data": user,
})
}
// Guest 代理上游游客登录,成功后写入登录 cookie。
// 对应 POST /api/app/auth/guest;需传 devID,上游要求 X-User-Agent / X-Real-IP(由 BFF 组装)。
func Guest(c *gin.Context) {
var body struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId"`
Mac string `json:"mac"`
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.DevID = strings.TrimSpace(body.DevID)
body.DeviceID = strings.TrimSpace(body.DeviceID)
body.Mac = strings.TrimSpace(body.Mac)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.DevID == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少设备标识",
"data": nil,
})
return
}
if body.DeviceID == "" {
body.DeviceID = body.DevID
}
env, user, err := authser.GuestLogin(c, authser.GuestLoginReq{
DevID: body.DevID,
DeviceID: body.DeviceID,
Mac: body.Mac,
Channel: body.Channel,
})
if err != nil {
log.Error("guest login upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "guest login failed",
"tip": "游客登录失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "游客登录失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "游客登录成功"),
"data": user,
})
}
// Logout 清除登录 cookie,并标记跳过紧接着的一次自动游客登录。
func Logout(c *gin.Context) {
authser.ClearAuthCookies(c)
authser.MarkGuestSkipAuto(c)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"tip": "已退出登录",
"data": nil,
})
}
func firstNonEmptyAuth(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
-120
View File
@@ -1,120 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ChapterLikeSet BFF:代理 POST /api/app/playlet/chapter/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_like_set
func ChapterLikeSet(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/isLike required",
"tip": "缺少剧集 ID 或点赞状态",
"data": nil,
})
return
}
env, err := playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{
ChapterID: body.ChapterID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
// ChapterCollectSet BFF:代理 POST /api/app/playlet/chapter/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_collect_set
func ChapterCollectSet(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/isCollect required",
"tip": "缺少剧集 ID 或收藏状态",
"data": nil,
})
return
}
env, err := playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{
ChapterID: body.ChapterID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-64
View File
@@ -1,64 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ChapterUnlock BFF:代理 POST /api/app/playlet/chapter/unlock。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_unlock
func ChapterUnlock(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/playletId required",
"tip": "缺少剧集或短剧 ID",
"data": nil,
})
return
}
env, err := playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-75
View File
@@ -1,75 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// ChapterWatchReport BFF:代理 POST /api/app/playlet/chapter/watch/report。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_watch_report
func ChapterWatchReport(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录",
"data": nil,
})
return
}
var body struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId required",
"tip": "缺少剧集 ID",
"data": nil,
})
return
}
env, err := playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-112
View File
@@ -3,7 +3,6 @@ package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/commentser"
"github.com/gin-gonic/gin"
@@ -42,114 +41,3 @@ func CommentList(c *gin.Context) {
}
// CommentSend BFF:代理 POST /api/app/comment/send。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_send
func CommentSend(c *gin.Context) {
var req commentser.CommentSendReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
env, err := commentser.Send(c, req)
if err != nil {
tip := "发布失败"
msg := err.Error()
switch {
case strings.Contains(msg, "empty text"):
tip = "请输入评论内容"
case strings.Contains(msg, "too long"):
tip = "评论不能超过 200 字"
case strings.Contains(msg, "invalid objectId"):
tip = "缺少评论对象"
}
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "发布失败",
"data": nil,
})
return
}
tip := env.Tip
if tip == "" {
tip = env.Msg
}
// 上游参数错误兜底提示(例如 objectType 不被支持)
if env.Code == 1001 && (tip == "" || tip == "参数错误") {
tip = "评论参数错误,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": tip,
"data": env.Data,
})
}
// CommentLikeSet BFF:代理 POST /api/app/comment/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_like_set
func CommentLikeSet(c *gin.Context) {
var body struct {
CommentID int64 `json:"commentId"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.CommentID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "commentId/isLike required",
"tip": "缺少评论 ID 或点赞状态",
"data": nil,
})
return
}
env, err := commentser.LikeSet(c, commentser.CommentLikeSetReq{
CommentID: body.CommentID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-338
View File
@@ -1,338 +0,0 @@
package web
import (
"fmt"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type favoriteWork struct {
ID int64
Title string
Genre string
DesktopMeta string
Episodes string
Badge string
Image string
Kind string
URL string
DramaID int64
Episode int
VideoID int64
TotalEpisode int
}
// Favorites 我的收藏:短剧 / 剧集(视频) / 章节收藏。
// 文档:
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_collect_list
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list
func Favorites(c *gin.Context) {
filter, page, validRoute := favoritesRoute(c)
if !validRoute {
NotFound(c)
return
}
data := gin.H{"title": "我的收藏 - 黄果短剧", "navActive": "favorites", "accountTab": "favorites"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
status := 0
switch filter {
case "short", "chapter", "series":
case "following":
status = 1
case "finished":
status = 2
default:
filter = "all"
}
data["filter"] = filter
pageSize := 12
query := playletser.CollectListQuery{PageNum: page, PageSize: pageSize, WatchStatus: status}
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/collect/list"] = gin.H{"error": "暂无登录会话"}
responses["video/collect/list"] = gin.H{"error": "暂无登录会话"}
data["favoritesResponse"] = responses["playlet/collect/list"]
data["playletCollectResponse"] = responses["playlet/collect/list"]
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
if filter == "chapter" {
list, code, err := playletser.FetchChapterCollectList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = playletser.FetchChapterCollectList(c, query)
}
if err != nil {
log.Error("fetch chapter favorites page failed", log.E(err))
responses["playlet/chapter/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
data["favoritesUnavailable"] = true
} else if list != nil {
responses["playlet/chapter/collect/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeFavoritesPage(query.PageNum, query.PageSize, list.TotalCount, filter)
data["favorites"] = chapterFavoriteWorks(list.List)
} else {
responses["playlet/chapter/collect/list"] = gin.H{"code": code, "data": list}
}
data["favoritesResponse"] = responses["playlet/chapter/collect/list"]
data["playletCollectResponse"] = responses["playlet/chapter/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
if filter == "series" {
loadVideoFavorites(c, data, responses, page, pageSize)
data["favoritesResponse"] = responses["video/collect/list"]
data["playletCollectResponse"] = gin.H{}
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
var works []favoriteWork
var totalCount int64
playletFailed := false
videoFailed := false
if filter == "all" || filter == "short" || filter == "following" || filter == "finished" {
list, code, err := playletser.FetchCollectList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, code, err = playletser.FetchCollectList(c, query)
}
if err == nil && list != nil {
responses["playlet/collect/list"] = gin.H{"code": 200, "data": list}
works = append(works, playletFavoriteWorks(list.List)...)
totalCount += list.TotalCount
} else if err != nil {
responses["playlet/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
log.Error("fetch favorites page failed", log.E(err))
playletFailed = true
} else {
responses["playlet/collect/list"] = gin.H{"code": code, "data": list}
}
}
if filter == "all" {
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
videoList, videoCode, videoErr := videoser.FetchCollectList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
videoList, videoCode, videoErr = videoser.FetchCollectList(c, videoQuery)
}
if videoErr == nil && videoList != nil {
responses["video/collect/list"] = gin.H{"code": 200, "data": videoList}
works = append(works, videoFavoriteWorks(videoList.List)...)
totalCount += videoList.TotalCount
} else if videoErr != nil {
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
log.Error("fetch video favorites page failed", log.E(videoErr))
videoFailed = true
} else {
responses["video/collect/list"] = gin.H{"code": videoCode, "data": videoList}
}
}
if playletFailed && (filter != "all" || videoFailed) {
data["favoritesUnavailable"] = true
}
if filter == "all" && playletFailed && videoFailed {
data["favoritesUnavailable"] = true
}
data["paging"] = makeFavoritesPage(page, pageSize, totalCount, filter)
data["favorites"] = works
data["favoritesResponse"] = responses["playlet/collect/list"]
data["playletCollectResponse"] = responses["playlet/collect/list"]
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
}
// renderFavorites 收藏为空时附带「大家都在看」后渲染。
func renderFavorites(c *gin.Context, data gin.H) {
attachFavoritesRecommended(c, data)
HTMLOK(c, "favorites.html", data)
}
// attachFavoritesRecommended 列表为空时拉取最热短剧(与搜索无结果一致)。
// 上游:GET /api/app/playlet/all/list sortType=2
func attachFavoritesRecommended(c *gin.Context, data gin.H) {
if works, ok := data["favorites"].([]favoriteWork); ok && len(works) > 0 {
return
}
watchingQ := playletser.AllListQuery{
PageNum: 1,
PageSize: 6,
SortType: 2, // 最热
UpdateStatus: 0,
}
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
if watching == nil || len(watching.List) == 0 {
return
}
list := watching.List
if len(list) > 6 {
list = list[:6]
}
data["recommended"] = list
}
func loadVideoFavorites(c *gin.Context, data gin.H, responses gin.H, page, pageSize int) {
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
list, code, err := videoser.FetchCollectList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, code, err = videoser.FetchCollectList(c, videoQuery)
}
if err != nil {
log.Error("fetch video favorites page failed", log.E(err))
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
data["favoritesUnavailable"] = true
return
}
if list == nil {
responses["video/collect/list"] = gin.H{"code": code, "data": list}
return
}
responses["video/collect/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeFavoritesPage(page, pageSize, list.TotalCount, "series")
data["favorites"] = videoFavoriteWorks(list.List)
}
func favoritesRoute(c *gin.Context) (filter string, page int, valid bool) {
if c.Request.URL.Query().Has("filter") {
return "", 0, false
}
filter = strings.TrimSpace(c.Param("filter"))
switch filter {
case "":
filter = "all"
case "short", "chapter", "series", "following", "finished":
default:
return "", 0, false
}
page, valid = accountPageNum(c)
if !valid {
return "", 0, false
}
return filter, page, true
}
func favoritesPagePath(filter string, page int) string {
path := "/favorites"
if filter != "" && filter != "all" {
path += "/" + filter
}
if page > 1 {
path += "/page/" + strconv.Itoa(page)
}
return path
}
func makeFavoritesPage(page, size int, count int64, filter string) accountPage {
total := int((count + int64(size) - 1) / int64(size))
pagination := accountPage{Current: page, Total: total, Count: count}
if page > 1 {
pagination.PrevURL = favoritesPagePath(filter, page-1)
}
if page < total {
pagination.NextURL = favoritesPagePath(filter, page+1)
}
return pagination
}
func playletFavoriteWorks(items []playletser.PlayletCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
work := favoriteWork{
ID: item.ID, Title: title, Genre: strings.Join(item.ThemeNames, " "),
Image: profileCover(item.Cover), Kind: "short",
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
DramaID: item.ID, Episode: item.LastEpisode, VideoID: item.LastChapterID,
TotalEpisode: item.TotalEpisode,
}
if work.Episode <= 0 {
work.Episode = 1
}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
work.DesktopMeta = work.Episodes
}
if item.IsFeatured {
work.Badge = "爆剧"
}
works = append(works, work)
}
return works
}
func chapterFavoriteWorks(items []playletser.PlayletChapterCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayletTitle)
if title == "" {
continue
}
work := favoriteWork{
ID: item.ChapterID, Title: title, Image: profileCover(item.PlayletCover), Kind: "chapter",
URL: playletser.ChapterPlayPath(item.PlayletSlug, item.PlayletID, item.ChapterID), DesktopMeta: item.ChapterTitle,
DramaID: item.PlayletID, Episode: item.CurrentEpisode, VideoID: item.ChapterID,
TotalEpisode: item.TotalEpisode,
}
if work.Episode <= 0 {
work.Episode = 1
}
if item.CurrentEpisode > 0 {
work.Genre = fmt.Sprintf("第 %d 集", item.CurrentEpisode)
}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
}
works = append(works, work)
}
return works
}
func videoFavoriteWorks(items []videoser.VideoCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
work := favoriteWork{
ID: item.ID, Title: title, Genre: strings.Join(item.Keyworld, " "),
Image: profileCover(item.CoverImg), Kind: "series",
URL: videoser.VideoPlayURL(item.Slug, item.ID),
}
if item.VideoTime > 0 {
work.Episodes = formatVideoDuration(item.VideoTime)
work.DesktopMeta = work.Episodes
} else {
work.Episodes = "剧集"
work.DesktopMeta = "剧集"
}
works = append(works, work)
}
return works
}
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"net/http"
"path/filepath"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/feedbackser"
"github.com/gin-gonic/gin"
@@ -113,7 +112,7 @@ func UploadImage(c *gin.Context) {
if name == "" || name == "." || name == "/" {
name = "image.jpg"
}
resp, err := authser.UploadImage(c, name, f)
resp, err := feedbackser.UploadImage(c, name, f)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
-375
View File
@@ -1,375 +0,0 @@
package web
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type historyEntry struct {
ID int64
Title string
Current int
Total int
EpisodeText string
Genre string
Time string
Image string
Progress string
Kind string
URL string
sortAt time.Time
}
type historyGroup struct {
Title string
Entries []historyEntry
}
// History 观看历史页:短剧 GET /api/app/playlet/history/list + 剧集 GET /api/app/video/history/list。
// 文档:
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_history_list
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list
func History(c *gin.Context) {
page, validPage := accountPageNum(c)
if !validPage {
NotFound(c)
return
}
data := gin.H{"title": "观看历史 - 黄果短剧", "navActive": "history", "accountTab": "history"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
pageSize := 20
var entries []historyEntry
var totalCount int64
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/history/list"] = gin.H{"error": "暂无登录会话"}
responses["video/history/list"] = gin.H{"error": "暂无登录会话"}
} else {
playletQuery := playletser.HistoryListQuery{PageNum: page, PageSize: pageSize}
playletList, playletCode, playletErr := playletser.FetchHistoryList(c, playletQuery)
if retry, _ := authser.OnSessionExpired(c, playletCode); retry {
playletList, playletCode, playletErr = playletser.FetchHistoryList(c, playletQuery)
}
if playletErr == nil && playletList != nil {
responses["playlet/history/list"] = gin.H{"code": 200, "data": playletList}
entries = append(entries, playletHistoryEntries(playletList.List)...)
totalCount += playletList.TotalCount
} else if playletErr != nil {
responses["playlet/history/list"] = gin.H{"error": "加载失败", "msg": playletErr.Error()}
log.Error("fetch playlet history page failed", log.E(playletErr))
} else {
responses["playlet/history/list"] = gin.H{"code": playletCode, "data": playletList}
}
videoQuery := videoser.HistoryListQuery{PageNum: page, PageSize: pageSize}
videoList, videoCode, videoErr := videoser.FetchHistoryList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
videoList, videoCode, videoErr = videoser.FetchHistoryList(c, videoQuery)
}
if videoErr == nil && videoList != nil {
responses["video/history/list"] = gin.H{"code": 200, "data": videoList}
entries = append(entries, videoHistoryEntries(videoList.List)...)
totalCount += videoList.TotalCount
} else if videoErr != nil {
responses["video/history/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
log.Error("fetch video history page failed", log.E(videoErr))
} else {
responses["video/history/list"] = gin.H{"code": videoCode, "data": videoList}
}
if playletErr != nil && videoErr != nil {
data["historyUnavailable"] = true
}
}
data["paging"] = makeAccountPage("/history", page, pageSize, totalCount)
data["groups"] = groupHistoryEntries(entries)
data["historyResponse"] = responses["playlet/history/list"]
data["playletHistoryResponse"] = responses["playlet/history/list"]
data["videoHistoryResponse"] = responses["video/history/list"]
data["historyDebug"] = responses
HTMLOK(c, "history.html", data)
}
// HistoryClear BFF:代理 POST /api/app/playlet/history/clear。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear
func HistoryClear(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
env, err := playletser.HistoryClear(c)
if err != nil {
log.Error("clear playlet history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "清空失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// VideoHistoryClear BFF:代理 POST /api/app/video/history/clear。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_clear
func VideoHistoryClear(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
env, err := videoser.HistoryClear(c)
if err != nil {
log.Error("clear video history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "清空失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// HistoryDelete BFF:代理 POST /api/app/playlet/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete
func HistoryDelete(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body playletser.PlayletHistoryDeleteReq
if err := c.ShouldBindJSON(&body); err != nil || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "playletId required", "tip": "参数错误", "data": nil})
return
}
env, err := playletser.HistoryDelete(c, body)
if err != nil {
log.Error("delete playlet history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "删除失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// VideoHistoryDelete BFF:代理 POST /api/app/video/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete
func VideoHistoryDelete(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body videoser.VideoHistoryDeleteReq
if err := c.ShouldBindJSON(&body); err != nil || body.VideoID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "videoId required", "tip": "参数错误", "data": nil})
return
}
env, err := videoser.HistoryDelete(c, body)
if err != nil {
log.Error("delete video history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "删除失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
func playletHistoryEntries(items []playletser.PlayletHistoryData) []historyEntry {
out := make([]historyEntry, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
out = append(out, historyEntry{
ID: item.ID,
Title: title,
Current: item.LastEpisode,
Total: item.TotalEpisode,
Genre: strings.Join(item.ThemeNames, " "),
Time: playletser.FormatHistoryListTime(item.HistoryAt),
Image: profileCover(item.Cover),
Progress: fmt.Sprintf("%d%%", progress),
Kind: "short",
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
sortAt: historySortAt(item.HistoryAt),
})
}
return out
}
func videoHistoryEntries(items []videoser.VideoHistoryData) []historyEntry {
out := make([]historyEntry, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
out = append(out, historyEntry{
ID: item.ID,
Title: title,
EpisodeText: videoHistoryEpisodeText(item),
Genre: strings.Join(item.Keyworld, " "),
Time: playletser.FormatHistoryListTime(item.HistoryAt),
Image: profileCover(item.CoverImg),
Progress: "",
Kind: "series",
URL: videoser.VideoPlayURL(item.Slug, item.ID),
sortAt: historySortAt(item.HistoryAt),
})
}
return out
}
func videoHistoryEpisodeText(item videoser.VideoHistoryData) string {
if item.VideoTime > 0 {
return formatVideoDuration(item.VideoTime)
}
return "剧集"
}
func formatVideoDuration(sec int) string {
if sec <= 0 {
return ""
}
h := sec / 3600
m := (sec % 3600) / 60
s := sec % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
func historySortAt(raw string) time.Time {
t, ok := playletser.ParseHistoryTime(raw)
if !ok {
return time.Time{}
}
return t
}
func groupHistoryEntries(entries []historyEntry) []historyGroup {
if len(entries) == 0 {
return nil
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].sortAt.Equal(entries[j].sortAt) {
return entries[i].ID > entries[j].ID
}
return entries[i].sortAt.After(entries[j].sortAt)
})
order := []string{"今天", "昨天", "更早"}
buckets := map[string][]historyEntry{
"今天": {},
"昨天": {},
"更早": {},
}
for _, entry := range entries {
bucket := historyBucketFromTime(entry.sortAt)
buckets[bucket] = append(buckets[bucket], entry)
}
groups := make([]historyGroup, 0, 3)
for _, title := range order {
if len(buckets[title]) == 0 {
continue
}
groups = append(groups, historyGroup{Title: title, Entries: buckets[title]})
}
return groups
}
func historyBucketFromTime(t time.Time) string {
if t.IsZero() {
return "更早"
}
now := time.Now()
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
switch {
case dayDiff <= 0:
return "今天"
case dayDiff == 1:
return "昨天"
default:
return "更早"
}
}
-8
View File
@@ -9,7 +9,6 @@ import (
// Index 首页 SSR:拉取 GET /api/app/playlet/home,按接口字段注入模板。
// 接口文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home
// 继续观看:GET /api/app/playlet/history/list?pageNum=1&pageSize=6
// 热播榜:短剧 hot/rank/list;剧集 video/all/list sortType=最热
func Index(c *gin.Context) {
raw := playletser.FetchHomeOrEmpty(c)
@@ -17,12 +16,6 @@ func Index(c *gin.Context) {
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
history := playletser.FetchHistoryListOrEmpty(c, playletser.HistoryListQuery{PageNum: 1, PageSize: 6})
continueWatch := history.List
if len(continueWatch) > 6 {
continueWatch = continueWatch[:6]
}
hotPlaylets := playletser.FetchHotRankListOrEmpty(c, playletser.HotRankQuery{
PageNum: 1,
PageSize: 10,
@@ -52,7 +45,6 @@ func Index(c *gin.Context) {
"homeConfigData": playletser.ActiveHomeConfigs(raw.HomeConfigData),
// GET /api/app/playlet/tag/options → list(已上架标签)
"tagOptions": playletser.FetchTagOptionsOrEmpty(c),
"continueWatch": continueWatch,
"hotRankPlaylets": hotPlaylets,
"hotRankVideos": hotVideos,
})
-11
View File
@@ -1,11 +0,0 @@
package web
import "github.com/gin-gonic/gin"
// LoginPage H5 登录页:渲染 template/pages/login.html。
// PC 端由页面内脚本回首页并打开 loginPop;H5 直接展示本页。
func LoginPage(c *gin.Context) {
HTMLOK(c, "login.html", gin.H{
"title": "登录 - 黄果短剧",
})
}
-83
View File
@@ -1,9 +1,7 @@
package web
import (
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/commentser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
@@ -92,13 +90,9 @@ func Play(c *gin.Context) {
SortOrder: commentser.SortHot,
})
unlockCost := int64(0)
balance := int64(0)
canWatch := false
episode := 0
if chapter != nil {
unlockCost = chapter.UnlockCost()
balance = chapter.Balance
canWatch = chapter.AllowWatch()
episode = chapter.CurrentEpisode
}
@@ -117,9 +111,6 @@ func Play(c *gin.Context) {
if episode <= 0 {
episode = 1
}
if unlockCost <= 0 {
unlockCost = int64(playletser.FirstUnlockCost(playlet.PlayletChapterInfo))
}
previewURL := strings.TrimSpace(playlet.Preview)
mainURL := ""
@@ -139,9 +130,6 @@ func Play(c *gin.Context) {
"mainURL": mainURL,
"previewURL": previewURL,
"hasPreview": previewURL != "",
"unlockCost": unlockCost,
"balance": balance,
"balanceText": authser.FormatRecharge(balance),
"nextChapterID": playletser.NextChapterID(playlet, chapterID),
"advitise": playlet.Advitise,
"related": playlet.RelatedRecommendData,
@@ -157,7 +145,6 @@ func Play(c *gin.Context) {
"commentSize": comments.PageSize,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
"trackerDrama": dramaTrackingData(playlet, chapter, chapterID, episode),
"playResponse": gin.H{
"playlet": playlet,
"chapter": chapter,
@@ -174,76 +161,9 @@ func Play(c *gin.Context) {
},
}
data["canonicalPath"] = playletser.PlayPath(playlet.Slug, playlet.ID)
attachRechargeModal(c, data)
HTMLOK(c, "play.html", data)
}
func dramaTrackingData(playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp, chapterID int64, episode int) gin.H {
name := strings.TrimSpace(playlet.PlayName)
if name == "" {
name = strings.TrimSpace(playlet.Title)
}
totalEpisode := playlet.TotalEpisode
if totalEpisode <= 0 {
totalEpisode = len(playlet.PlayletChapterInfo)
}
if totalEpisode <= 0 {
totalEpisode = 1
}
data := gin.H{
"MediaID": "",
"DramaID": playlet.ID,
"DramaTitle": name,
"Episode": max(1, episode),
"TotalEpisode": totalEpisode,
"VideoID": chapterID,
"DramaTypeID": "default",
"DramaTypeName": "默认分类",
"DramaTagKey": "default",
"DramaTagName": "默认标签",
"DramaDuration": 0,
"RecommendTraceID": "",
}
if chapter != nil {
if chapter.ID > 0 {
data["VideoID"] = chapter.ID
}
if chapter.MediaTime > 0 {
data["DramaDuration"] = chapter.MediaTime
}
}
for i, id := range playlet.ThemeIDs {
if id <= 0 || i >= len(playlet.ThemeNames) {
continue
}
name := strings.TrimSpace(playlet.ThemeNames[i])
if name == "" {
continue
}
data["DramaTypeID"] = strconv.FormatInt(id, 10)
data["DramaTypeName"] = name
break
}
tagIDs := make([]string, 0, len(playlet.TagIDs))
tagNames := make([]string, 0, len(playlet.TagIDs))
for i, id := range playlet.TagIDs {
if id <= 0 || i >= len(playlet.TagNames) {
continue
}
name := strings.TrimSpace(playlet.TagNames[i])
if name == "" {
continue
}
tagIDs = append(tagIDs, strconv.FormatInt(id, 10))
tagNames = append(tagNames, name)
}
if len(tagIDs) > 0 {
data["DramaTagKey"] = strings.Join(tagIDs, ",")
data["DramaTagName"] = strings.Join(tagNames, ",")
}
return data
}
// SeriesPlay 视频播放页:GET /api/app/video/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_detail
func SeriesPlay(c *gin.Context) {
@@ -369,8 +289,6 @@ func SeriesPlay(c *gin.Context) {
"list": comments.List,
"apis": []string{
"GET /api/app/comment/list",
"POST /api/app/comment/send",
"POST /api/app/comment/like/set",
},
},
"playDebug": gin.H{
@@ -383,6 +301,5 @@ func SeriesPlay(c *gin.Context) {
if strings.TrimSpace(c.Param("guessPage")) != "" {
data["canonicalPath"] = videoser.VideoPlayURL(video.Slug, video.ID)
}
attachRechargeModal(c, data)
HTMLOK(c, "seriesPlay.html", data)
}
-64
View File
@@ -1,64 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PlayletLikeSet BFF:代理 POST /api/app/playlet/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_like_set
func PlayletLikeSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isLike required",
"tip": "缺少短剧 ID 或点赞状态",
"data": nil,
})
return
}
env, err := playletser.LikeSet(c, playletser.PlayletLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-64
View File
@@ -1,64 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PlayletCollectSet BFF:代理 POST /api/app/playlet/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_collect_set
func PlayletCollectSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isCollect required",
"tip": "缺少短剧 ID 或收藏状态",
"data": nil,
})
return
}
env, err := playletser.CollectSet(c, playletser.PlayletCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-229
View File
@@ -1,229 +0,0 @@
package web
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/usercenterser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type profileWork struct {
Title, Meta, Episodes, Badge, Image, Progress, Remaining, URL string
}
// Profile 从当前会话拉取用户信息、观看历史和收藏,不展示演示账号数据。
func Profile(c *gin.Context) {
data := gin.H{
"title": "个人中心 - 黄果短剧", "navActive": "profile", "accountTab": "profile",
}
responses := gin.H{}
loadAccountSummary(c, data, responses)
if useragent.TokenFromRequest(c.Request) != "" {
fetchProfileLists(c, data, responses)
}
if pretty, err := json.MarshalIndent(responses, "", " "); err == nil {
data["responseJSON"] = string(pretty)
}
HTMLOK(c, "profile.html", data)
}
func loadAccountSummary(c *gin.Context, data, responses gin.H) {
data["userName"] = "未登录"
data["balance"] = "—"
data["historyCount"] = "—"
data["favoriteCount"] = "—"
data["chasingCount"] = "—"
data["boughtChapterCount"] = "—"
user := authser.SyncUserOnEnter(c)
if user == nil {
if useragent.TokenFromRequest(c.Request) != "" {
responses["auth/getInfo"] = gin.H{"error": "加载失败"}
fetchProfileStats(c, data, responses)
return
}
responses["auth/getInfo"] = gin.H{"error": "暂无登录会话"}
return
}
applyProfileInfo(data, responses, user)
if useragent.TokenFromRequest(c.Request) != "" {
fetchProfileStats(c, data, responses)
}
}
func fetchProfileStats(c *gin.Context, data, responses gin.H) {
stats, code, err := usercenterser.Fetch(c)
if retry, _ := authser.OnSessionExpired(c, code); retry {
stats, code, err = usercenterser.Fetch(c)
}
if err != nil || stats == nil {
responses["user/center/data"] = gin.H{"error": "加载失败"}
if err != nil {
log.Error("fetch user center data failed", log.E(err))
}
return
}
responses["user/center/data"] = gin.H{"code": code, "data": stats}
data["historyCount"] = fmt.Sprint(stats.WatchCount)
data["favoriteCount"] = fmt.Sprint(stats.CollectCount)
data["chasingCount"] = fmt.Sprint(stats.ChasingCount)
data["boughtChapterCount"] = fmt.Sprint(stats.BoughtChapterCount)
}
func fetchProfileLists(c *gin.Context, data, responses gin.H) {
query := playletser.HistoryListQuery{PageNum: 1, PageSize: 6}
history, code, err := playletser.FetchHistoryList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
history, _, err = playletser.FetchHistoryList(c, query)
}
if err == nil && history != nil {
responses["playlet/history/list"] = gin.H{"code": 200, "data": history}
data["history"] = profileHistoryWorks(history.List)
} else if err != nil {
responses["playlet/history/list"] = gin.H{"error": "加载失败"}
log.Error("fetch profile history failed", log.E(err))
data["historyUnavailable"] = true
}
collectQuery := playletser.CollectListQuery{PageNum: 1, PageSize: 6, WatchStatus: 0}
favorites, code, err := playletser.FetchCollectList(c, collectQuery)
if retry, _ := authser.OnSessionExpired(c, code); retry {
favorites, _, err = playletser.FetchCollectList(c, collectQuery)
}
if err == nil && favorites != nil {
responses["playlet/collect/list"] = gin.H{"code": 200, "data": favorites}
data["favorites"] = profileFavoriteWorks(favorites.List)
} else if err != nil {
responses["playlet/collect/list"] = gin.H{"error": "加载失败"}
log.Error("fetch profile favorites failed", log.E(err))
data["favoritesUnavailable"] = true
}
}
func applyProfileInfo(data, responses gin.H, user *authser.LoginResp) {
if user == nil {
return
}
// 登录响应可能包含 token;调试区域只展示非凭证字段。
rawUser, _ := json.Marshal(user)
var safeUser map[string]any
_ = json.Unmarshal(rawUser, &safeUser)
delete(safeUser, "token")
responses["auth/getInfo"] = gin.H{"code": 200, "data": safeUser}
name := strings.TrimSpace(user.NickName)
if name == "" {
name = strings.TrimSpace(user.AccountNo)
}
if name == "" {
name = "用户"
}
data["userName"] = name
data["setting"] = authser.BuildSettingVM(user)
data["userID"] = user.UserID
data["userId"] = fmt.Sprint(user.UserID)
data["balance"] = fmt.Sprint(user.Recharge)
data["recharge"] = user.Recharge
data["rechargeText"] = authser.FormatRecharge(user.Recharge)
if avatar := safeProfileImage(user.Avatar); avatar != "" {
data["avatar"] = avatar
}
}
func safeProfileImage(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "/static/") {
return raw
}
u, err := url.Parse(raw)
if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
return raw
}
return ""
}
func profileHistoryWorks(items []playletser.PlayletHistoryData) []profileWork {
works := make([]profileWork, 0, len(items))
for _, item := range items {
if len(works) == 6 {
break
}
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.PlayName)
}
if name == "" {
continue
}
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
meta := "观看记录"
if item.LastEpisode > 0 {
meta = fmt.Sprintf("看至第 %d 集", item.LastEpisode)
}
remaining := ""
if item.UnwatchedCount > 0 {
remaining = fmt.Sprintf("剩 %d 集", item.UnwatchedCount)
}
works = append(works, profileWork{Title: name, Meta: meta, Image: profileCover(item.Cover), Progress: fmt.Sprintf("%d%%", progress), Remaining: remaining, URL: profilePlayURL(item.Slug, item.ID, item.LastEpisode)})
}
return works
}
func profileFavoriteWorks(items []playletser.PlayletCollectData) []profileWork {
works := make([]profileWork, 0, len(items))
for _, item := range items {
if len(works) == 6 {
break
}
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.PlayName)
}
if name == "" {
continue
}
work := profileWork{Title: name, Meta: strings.Join(item.ThemeNames, " "), Image: profileCover(item.Cover), URL: profilePlayURL(item.Slug, item.ID, 1)}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
}
if item.IsFeatured {
work.Badge = "爆剧"
}
works = append(works, work)
}
return works
}
func profileCover(raw string) string {
if safe := safeProfileImage(raw); safe != "" {
return safe
}
return "/static/images/placeholder.png?v=2"
}
func profilePlayURL(slug string, id int64, episode int) string {
if slug == "" {
if id <= 0 {
return ""
}
slug = fmt.Sprint(id)
}
if episode < 1 {
episode = 1
}
return "/play/" + url.PathEscape(slug) + "/" + fmt.Sprint(episode)
}
-87
View File
@@ -1,87 +0,0 @@
package web
import (
"fmt"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type purchasedWork struct {
Title, Image, Info, Date, Progress, URL string
}
// PurchasedSeries 已购剧集:GET /api/app/playlet/bought/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_bought_list
func PurchasedSeries(c *gin.Context) {
page, validPage := accountPageNum(c)
if !validPage {
NotFound(c)
return
}
data := gin.H{"title": "已购剧集 - 黄果短剧", "navActive": "purchased", "accountTab": "purchased"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
size := 20
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/bought/list"] = gin.H{"error": "暂无登录会话"}
data["purchasedResponse"] = responses["playlet/bought/list"]
HTMLOK(c, "purchased.html", data)
return
}
list, code, err := playletser.FetchBoughtList(c, page, size)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = playletser.FetchBoughtList(c, page, size)
}
if err == nil && list != nil {
responses["playlet/bought/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeAccountPage("/purchased", page, size, list.TotalCount)
works := make([]purchasedWork, 0, len(list.List))
for _, item := range list.List {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
info := fmt.Sprintf("已购 %d 集", item.BoughtChapterCount)
if item.TotalEpisode > 0 {
info += fmt.Sprintf(" / 共 %d 集", item.TotalEpisode)
}
info += fmt.Sprintf(" · %d 金币", item.CostCoins)
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
works = append(works, purchasedWork{
Title: title, Image: profileCover(item.Cover), Info: info,
Date: playletser.FormatHistoryAt(item.BoughtAt), Progress: fmt.Sprintf("%d%%", progress),
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
})
}
data["purchased"] = works
} else if err != nil {
responses["playlet/bought/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
log.Error("fetch bought playlets failed", log.E(err))
data["purchasedUnavailable"] = true
} else {
responses["playlet/bought/list"] = gin.H{"code": code, "data": list}
}
data["purchasedResponse"] = responses["playlet/bought/list"]
HTMLOK(c, "purchased.html", data)
}
-279
View File
@@ -1,279 +0,0 @@
package web
import (
"net/http"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/rechargeser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const rechargePageSize = 5
// Recharge 金币充值页:套餐 GET /api/app/recharge/package/list,购买 POST /api/app/coin/buy,流水 GET /api/app/coin/trans/list。
func Recharge(c *gin.Context) {
data := gin.H{
"title": "金币充值 - 黄果短剧", "pageHeading": "金币充值",
"backHref": "/profile", "helpHref": "/help",
}
responses := gin.H{}
loadAccountSummary(c, data, responses)
packages, err := rechargeser.FetchPackageList(c)
if err != nil {
log.Error("fetch recharge packages failed", log.E(err))
data["packageUnavailable"] = true
packages = &rechargeser.RechargePackageListResp{}
responses["recharge/package/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
} else {
responses["recharge/package/list"] = gin.H{"code": 200, "data": packages}
}
vm := rechargeser.BuildDepositPageVM(packages, "coin")
data["packages"] = vm.CoinList
data["paymentMethods"] = vm.PayMethods
data["rechargePackageResponse"] = packages
balance := authser.RechargeFromCookies(c)
if _, ok := data["recharge"]; ok {
balance = toInt64(data["recharge"])
}
data["balance"] = balance
data["balanceText"] = authser.FormatRecharge(balance)
page, _, err := fetchRechargeTransactions(c, 1, rechargePageSize, 0)
if err != nil {
log.Error("fetch recharge transactions failed", log.E(err))
data["transactionsUnavailable"] = true
page = &rechargeser.CoinTransPage{PageNum: 1, PageSize: rechargePageSize}
responses["coin/trans/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
} else {
responses["coin/trans/list"] = gin.H{"code": 200, "data": page}
}
data["transactions"] = rechargeser.BuildTxListFromCoinTrans(page.List, 0)
data["transactionPage"] = page
data["transactionResponse"] = page
data["rechargeDebug"] = responses
HTMLOK(c, "recharge.html", data)
}
// RechargeCoinBuy BFF:代理 POST /api/app/coin/buy。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/post_api_app_coin_buy
func RechargeCoinBuy(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body rechargeser.BuyPayReq
if err := c.ShouldBindJSON(&body); err != nil || body.PackageID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "packageId required", "tip": "请选择充值套餐", "data": nil})
return
}
body.PayMode = rechargeser.NormalizePayMode(body.PayMode)
if body.PayMode == "" {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "payMode required", "tip": "请选择支付方式", "data": nil})
return
}
if !validCoinPackagePayment(c, body.PackageID, body.PayMode) {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "invalid package/payMode", "tip": "套餐或支付方式不可用,请刷新后重试", "data": nil})
return
}
env, err := rechargeser.BuyCoin(c, body)
if err != nil {
log.Error("buy coin failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
return
}
if env != nil {
if retry, needLogin := authser.OnSessionExpired(c, env.Code); needLogin {
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": "登录已过期,请重新登录", "data": nil})
return
} else if retry {
env, err = rechargeser.BuyCoin(c, body)
if err != nil {
log.Error("buy coin retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
return
}
}
}
if env == nil || env.Code != 200 {
code, tip, msg := 500, "创建支付订单失败", "buy failed"
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmptyAuth(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{"code": code, "msg": msg, "tip": tip, "data": nil})
return
}
safeURL := safePaymentURL(env.Data.PayURL)
if safeURL == "" {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "invalid payUrl", "tip": "支付地址无效,请稍后重试", "data": nil})
return
}
env.Data.PayURL = safeURL
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmptyAuth(env.Tip, "订单创建成功"),
"data": env.Data,
})
}
// RechargeTransactions BFF:代理 GET /api/app/coin/trans/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_list
// query:page / pageSize / type(0全部 1充值 2消费 3奖励)
func RechargeTransactions(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
page := queryInt(c, "page", 1)
pageSize := queryInt(c, "pageSize", rechargePageSize)
typ := queryIntAllowZero(c, "type", 0)
if typ < 0 || typ > 3 {
typ = 0
}
result, code, err := fetchRechargeTransactions(c, page, pageSize, typ)
if err != nil {
log.Error("fetch recharge transactions failed", log.E(err))
tip := "交易明细加载失败"
if code > 0 {
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "tip": tip, "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": tip, "data": nil})
return
}
if result == nil {
result = &rechargeser.CoinTransPage{PageNum: page, PageSize: pageSize}
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"tip": "",
"data": gin.H{
"list": rechargeser.BuildTxListFromCoinTrans(result.List, 0),
"rawList": result.List,
"pageNum": result.PageNum,
"pageSize": result.PageSize,
"totalCount": result.TotalCount,
},
})
}
// RechargeCoinTransExport BFF:代理 GET /api/app/coin/trans/export。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_export
// query:type(0全部 1充值 2消费 3奖励),返回 CSV。
func RechargeCoinTransExport(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
typ := queryIntAllowZero(c, "type", 0)
if typ < 0 || typ > 3 {
typ = 0
}
result, err := rechargeser.FetchCoinTransExport(c, typ)
if err != nil {
log.Error("export coin trans failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "导出失败,请稍后重试", "data": nil})
return
}
ct := strings.TrimSpace(result.ContentType)
if ct == "" || strings.Contains(strings.ToLower(ct), "json") {
ct = "text/csv; charset=utf-8"
}
disposition := strings.TrimSpace(result.ContentDisposition)
if disposition == "" {
disposition = `attachment; filename="coin-trans.csv"`
}
c.Header("Content-Type", ct)
c.Header("Content-Disposition", disposition)
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, ct, result.Body)
}
func fetchRechargeTransactions(c *gin.Context, page, pageSize, typ int) (*rechargeser.CoinTransPage, int, error) {
result, code, err := rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
if retry, _ := authser.OnSessionExpired(c, code); retry {
result, code, err = rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
}
return result, code, err
}
// attachRechargeModal 注入充值弹窗数据:GET /api/app/recharge/package/list → coinList。
func attachRechargeModal(c *gin.Context, data gin.H) {
if data == nil {
return
}
packages := rechargeser.FetchPackageListOrEmpty(c)
vm := rechargeser.BuildDepositPageVM(packages, "coin")
data["rechargePackages"] = vm.CoinList
data["rechargePayMethods"] = vm.PayMethods
data["rechargePackageResponse"] = packages
if _, ok := data["unlockCost"]; !ok {
data["unlockCost"] = int64(0)
}
if _, ok := data["balance"]; !ok {
data["balance"] = authser.RechargeFromCookies(c)
}
if _, ok := data["balanceText"]; !ok {
data["balanceText"] = authser.FormatRecharge(toInt64(data["balance"]))
}
}
func validCoinPackagePayment(c *gin.Context, packageID int64, payMode string) bool {
data, err := rechargeser.FetchPackageList(c)
if err != nil || data == nil {
return false
}
for _, item := range data.CoinList {
if item.ID != packageID {
continue
}
for _, method := range item.PayMethods {
if rechargeser.NormalizePayMode(method.Type) == payMode {
return true
}
}
return false
}
return false
}
func safePaymentURL(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return raw
}
func queryInt(c *gin.Context, name string, fallback int) int {
v, err := strconv.Atoi(strings.TrimSpace(c.Query(name)))
if err != nil || v <= 0 {
return fallback
}
return v
}
// queryIntAllowZero 允许 0(如 type=0 全部)。
func queryIntAllowZero(c *gin.Context, name string, fallback int) int {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
-11
View File
@@ -1,11 +0,0 @@
package web
import "github.com/gin-gonic/gin"
// RegisterPage H5 注册页:渲染 template/pages/register.html。
// PC 端由页面内脚本回首页并打开 registerPop;H5 直接展示本页。
func RegisterPage(c *gin.Context) {
HTMLOK(c, "register.html", gin.H{
"title": "注册账号 - 黄果短剧",
})
}
+1 -61
View File
@@ -4,7 +4,6 @@ import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
@@ -14,68 +13,12 @@ import (
"github.com/gin-gonic/gin"
)
// HTML 渲染页面并注入登录态(以 cookie 为准)。
// 无 token 时自动游客登录(POST /api/app/auth/guest),保证默认进入即有会话。
// HTML 渲染页面并注入公共导航、配置与 SEO 数据。
func HTML(c *gin.Context, code int, name string, data gin.H) {
if data == nil {
data = gin.H{}
}
// 所有服务端页面先完成渠道首次归因;已有会话也不会漏记落地页渠道。
if channel := authser.ResolveChannel(c, ""); channel != "" {
data["channel"] = channel
}
authser.SyncUserOnEnter(c)
loggedIn, userName, avatar, email := authser.AuthFromCookies(c)
registered := authser.IsRegisteredFromCookies(c)
data["isLoggedIn"] = loggedIn
data["isRegistered"] = registered
// 已登录(含游客)展示用户菜单;未登录才展示「登录」按钮。
if _, ok := data["guestHeader"]; !ok {
data["guestHeader"] = !loggedIn
}
if _, ok := data["isVip"]; !ok {
data["isVip"] = loggedIn && authser.IsActiveVipFromCookies(c)
}
isVip, _ := data["isVip"].(bool)
data["isVip"] = isVip
if loggedIn {
if _, provided := data["userName"]; !provided && userName != "" {
data["userName"] = userName
}
if _, provided := data["avatar"]; !provided && avatar != "" {
data["avatar"] = avatar
}
if email != "" {
data["email"] = email
}
if _, ok := data["userId"]; !ok {
if uid := authser.UserIDFromCookies(c); uid > 0 {
data["userId"] = authser.FormatUserID(uid)
}
}
if _, ok := data["recharge"]; !ok {
recharge := authser.RechargeFromCookies(c)
data["recharge"] = recharge
data["rechargeText"] = authser.FormatRecharge(recharge)
} else if _, ok := data["rechargeText"]; !ok {
data["rechargeText"] = authser.FormatRecharge(toInt64(data["recharge"]))
}
if _, ok := data["vipType"]; !ok {
data["vipType"] = authser.VipTypeFromCookies(c)
}
} else {
delete(data, "userName")
delete(data, "avatar")
delete(data, "email")
delete(data, "userId")
delete(data, "recharge")
delete(data, "rechargeText")
delete(data, "vipType")
}
// 导航分类:GET /api/app/translation/category/list → categoryList(包含分类 seoInfo)
if _, ok := data["categoryList"]; !ok {
data["categoryList"] = translationser.FetchHeaderCategoryListOrDefault(c)
@@ -119,9 +62,6 @@ func HTML(c *gin.Context, code int, name string, data gin.H) {
}
}
data["trackerAppId"] = configser.ResolveTrackerAppID()
attachTrackerPage(c.Request.URL.Path, code, data)
attachPageSEO(c, code, data)
c.HTML(code, name, data)
}
-85
View File
@@ -1,85 +0,0 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// Setting 展示账号设置,字段来自最新的用户信息接口。
func Setting(c *gin.Context) {
data := gin.H{"title": "账号设置 - 黄果短剧", "navActive": "setting", "accountTab": "setting"}
loadAccountSummary(c, data, gin.H{})
HTMLOK(c, "setting.html", data)
}
// UpdateProfile 代理账号资料修改。可选字段必须显式传入,避免覆盖未修改内容。
func UpdateProfile(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "tip": "请先登录"})
return
}
var body authser.UpdateProfileReq
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "参数错误"})
return
}
if body.NickName != nil {
v := strings.TrimSpace(*body.NickName)
if v == "" || len([]rune(v)) > 40 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "昵称须为 1 至 40 个字符"})
return
}
body.NickName = &v
}
if body.Introduction != nil {
v := strings.TrimSpace(*body.Introduction)
if len([]rune(v)) > 300 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "简介不能超过 300 个字符"})
return
}
body.Introduction = &v
}
if body.Avatar != nil {
v := strings.TrimSpace(*body.Avatar)
if safeProfileImage(v) == "" || strings.HasPrefix(v, "/static/") {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "头像地址无效"})
return
}
body.Avatar = &v
}
if (body.OldPassword == nil) != (body.NewPassword == nil) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "请输入旧密码和新密码"})
return
}
if body.OldPassword != nil && (*body.OldPassword == "" || *body.NewPassword == "") {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "密码不能为空"})
return
}
if body.NickName == nil && body.Introduction == nil && body.Avatar == nil && body.OldPassword == nil && body.PrivacyMode == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "没有需要保存的修改"})
return
}
env, err := authser.UpdateProfile(c, body)
if err != nil {
log.Error("update profile failed", log.E(err))
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
return
}
if env == nil || env.Code != 200 {
if env == nil {
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
return
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "tip": firstNonEmptyAuth(env.Tip, env.Msg, "保存失败")})
return
}
if infoEnv, user, err := authser.GetInfo(c); err == nil && infoEnv != nil && infoEnv.Code == 200 && user != nil {
authser.SetAuthCookies(c, user)
}
c.JSON(http.StatusOK, gin.H{"code": 200, "tip": firstNonEmptyAuth(env.Tip, "保存成功")})
}
-80
View File
@@ -1,80 +0,0 @@
package web
import (
"net/http"
"strings"
)
// attachTrackerPage 为所有 HTML 页面补齐稳定的 pageKey/pageName,避免事件落入 unknown。
func attachTrackerPage(path string, code int, data map[string]any) {
if data == nil {
return
}
key, name := trackerPageFromRequest(path, code)
if current, _ := data["pageKey"].(string); strings.TrimSpace(current) == "" {
data["pageKey"] = key
}
if current, _ := data["pageName"].(string); strings.TrimSpace(current) == "" {
data["pageName"] = name
}
}
func trackerPageFromRequest(path string, code int) (string, string) {
if code >= http.StatusInternalServerError {
return "server_error", "服务异常页面"
}
if code >= http.StatusBadRequest {
return "not_found", "404页面"
}
return trackerPageFromPath(path)
}
func trackerPageFromPath(path string) (string, string) {
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
if p == "" {
return "home", "首页"
}
rules := []struct {
prefix string
key string
name string
}{
{"/series-play", "series_play", "AI成人剧集播放页"},
{"/series", "series", "AI成人剧集"},
{"/play", "play", "短剧播放页"},
{"/detail", "detail", "短剧详情"},
{"/list", "list", "短剧列表"},
{"/recently-added", "recently_added", "最新短剧"},
{"/popular-recommend", "popular_recommend", "热门推荐"},
{"/rank", "rank", "排行榜"},
{"/topic-detail", "topic_detail", "专题详情"},
{"/topic", "topic", "专题列表"},
{"/tags", "tags", "标签详情"},
{"/more-series-list", "more_series_list", "成人剧集列表"},
{"/more-list", "more_list", "短剧扩展列表"},
{"/search-mobile", "search", "搜索结果页"},
{"/search", "search", "搜索结果页"},
{"/history", "history", "观看历史"},
{"/favorites", "favorites", "我的收藏"},
{"/purchased", "purchased", "已购剧集"},
{"/profile", "profile", "个人中心"},
{"/setting", "setting", "设置"},
{"/recharge", "recharge", "充值中心"},
{"/download-app", "download_app", "下载APP"},
{"/feedback", "feedback", "问题反馈"},
{"/help", "help", "帮助中心"},
{"/about", "about", "关于我们"},
{"/terms", "terms", "用户协议"},
{"/privacy", "privacy", "隐私政策"},
{"/2257", "compliance_2257", "2257合规声明"},
{"/dmca", "dmca", "DMCA版权声明"},
{"/login", "login", "登录"},
{"/register", "register", "注册"},
}
for _, rule := range rules {
if p == rule.prefix || strings.HasPrefix(p, rule.prefix+"/") {
return rule.key, rule.name
}
}
return "internal_other", "站内其他页面"
}
-64
View File
@@ -1,64 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// VideoCollectSet BFF:代理 POST /api/app/video/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set
func VideoCollectSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isCollect required",
"tip": "缺少视频 ID 或收藏状态",
"data": nil,
})
return
}
env, err := videoser.CollectSet(c, videoser.VideoCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
-64
View File
@@ -1,64 +0,0 @@
package web
import (
"net/http"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// VideoLikeSet BFF:代理 POST /api/app/video/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set
func VideoLikeSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isLike required",
"tip": "缺少视频 ID 或点赞状态",
"data": nil,
})
return
}
env, err := videoser.LikeSet(c, videoser.VideoLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+6 -24
View File
@@ -1,9 +1,6 @@
package appg
import (
"strings"
"videoweb/pub/pubdef"
"github.com/patrickmn/go-cache"
)
@@ -15,13 +12,12 @@ var (
)
type GlobalCfgEx struct {
ServerAddr string `yaml:"serverAddr"`
ListenAddr string `yaml:"listenAddr"`
DHListenAddr string `yaml:"dhListenAddr"`
RunMod string `yaml:"runMod"`
TsAuthKey string `yaml:"tsAuthKey"`
SEO SEOConf `yaml:"seo"`
Tracker TrackerConf `yaml:"tracker"`
ServerAddr string `yaml:"serverAddr"`
ListenAddr string `yaml:"listenAddr"`
DHListenAddr string `yaml:"dhListenAddr"`
RunMod string `yaml:"runMod"`
TsAuthKey string `yaml:"tsAuthKey"`
SEO SEOConf `yaml:"seo"`
}
// SEOConf 站长平台验证与 SEO 相关配置。
@@ -30,17 +26,3 @@ type SEOConf struct {
BaiduHTMLFile string `yaml:"baiduHtmlFile"` // 如 baidu_verify_codeva-xxx.html
IndexNowKey string `yaml:"indexNowKey"` // Bing IndexNow 密钥,提供 /{key}.txt
}
// TrackerConf 数据上报 SDK 本地兜底(上游 reportSdkConfig 优先)。
type TrackerConf struct {
AppID string `yaml:"appId"`
EncryptedConfig string `yaml:"encryptedConfig"`
}
// TrackerAppID 返回页面 SDK 与 BFF 透传校验共用的可信应用 ID。
func TrackerAppID() string {
if id := strings.TrimSpace(Conf.Tracker.AppID); id != "" {
return id
}
return pubdef.TrackerAppId()
}
+1 -7
View File
@@ -1,11 +1,5 @@
serverAddr: "http://172.104.181.99:25319"
serverAddr: "https://webhg2api.mfcg.me"
listenAddr: "25121"
dhListenAddr: "26813"
runMod: "dev"
tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN"
# 数据上报 SDK(web-sdk)。encryptedConfig 优先用上游 config/info.reportSdkConfig;
# 若上游暂未下发,可在此填写密文兜底。
tracker:
appId: "JHA-1045"
encryptedConfig: ""
+1 -42
View File
@@ -147,24 +147,6 @@ func registerRoutes(r *gin.Engine) {
r.GET("/search", web.Search)
r.GET("/search/:q", web.Search)
r.GET("/search/:q/page/:page", web.Search)
r.GET("/profile", web.Profile)
r.GET("/history", web.History)
r.GET("/history/page/:page", web.History)
r.POST("/api/playlet/history/clear", web.HistoryClear)
r.POST("/api/playlet/history/delete", web.HistoryDelete)
r.POST("/api/video/history/clear", web.VideoHistoryClear)
r.POST("/api/video/history/delete", web.VideoHistoryDelete)
r.GET("/favorites", web.Favorites)
r.GET("/favorites/page/:page", web.Favorites)
r.GET("/favorites/:filter", web.Favorites)
r.GET("/favorites/:filter/page/:page", web.Favorites)
r.GET("/purchased", web.PurchasedSeries)
r.GET("/purchased/page/:page", web.PurchasedSeries)
r.GET("/setting", web.Setting)
r.GET("/settings", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/setting")
})
r.GET("/recharge", web.Recharge)
r.GET("/about", web.About)
r.GET("/help", web.Help)
r.GET("/terms", web.Terms)
@@ -219,38 +201,15 @@ func registerRoutes(r *gin.Engine) {
r.GET("/play/:slug/:ep/recommendations/page/:guessPage", web.Play)
r.GET("/series-play/:slug", web.SeriesPlay)
r.GET("/series-play/:slug/recommendations/page/:guessPage", web.SeriesPlay)
r.GET("/login", web.LoginPage)
r.GET("/register", web.RegisterPage)
r.GET("/feedback", web.Feedback)
// 认证 BFF:SSR 进页已自动游客;前端登录/注册/补游客会话走以下接口。
r.POST("/api/auth/guest", web.Guest)
r.POST("/api/auth/login", web.Login)
r.POST("/api/auth/register", web.Register)
r.POST("/api/auth/logout", web.Logout)
r.POST("/api/auth/profile", web.UpdateProfile)
r.POST("/api/recharge/coin/buy", web.RechargeCoinBuy)
r.POST("/api/coin/buy", web.RechargeCoinBuy)
r.GET("/api/recharge/transactions", web.RechargeTransactions)
r.GET("/api/coin/trans/list", web.RechargeTransactions)
r.GET("/api/coin/trans/export", web.RechargeCoinTransExport)
r.GET("/api/recharge/transactions/export", web.RechargeCoinTransExport)
// 意见反馈(含截图上传)
r.POST("/api/upload/image", web.UploadImage)
r.POST("/api/feedback/add", web.FeedbackAdd)
// 播放页 BFF
r.GET("/api/playlet/guess-like/list", web.GuessLikeList)
r.GET("/api/comment/list", web.CommentList)
r.POST("/api/comment/send", web.CommentSend)
r.POST("/api/comment/like/set", web.CommentLikeSet)
r.POST("/api/playlet/chapter/like/set", web.ChapterLikeSet)
r.POST("/api/playlet/chapter/collect/set", web.ChapterCollectSet)
r.POST("/api/playlet/chapter/watch/report", web.ChapterWatchReport)
r.POST("/api/playlet/chapter/unlock", web.ChapterUnlock)
r.POST("/api/playlet/like/set", web.PlayletLikeSet)
r.POST("/api/playlet/collect/set", web.PlayletCollectSet)
r.POST("/api/video/like/set", web.VideoLikeSet)
r.POST("/api/video/collect/set", web.VideoCollectSet)
r.GET("/api/web/search/suggest", web.SearchSuggest)
// 搜索引擎抓取、发现与站点所有权验证。
-290
View File
@@ -1,290 +0,0 @@
package authser
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const (
registerPath = "/api/app/auth/register"
loginPath = "/api/app/auth/login"
resetPath = "/api/app/auth/resetPassword"
guestPath = "/api/app/auth/guest"
getInfoPath = "/api/app/auth/getInfo"
profilePath = "/api/app/auth/profile"
uploadPath = "/api/app/upload/image"
cookieMaxAge = 7 * 24 * 3600
)
// Register 调用上游注册接口。
func Register(c *gin.Context, req RegisterReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, registerPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// ResetPassword 通过账号和邮箱校验后重置密码。
func ResetPassword(c *gin.Context, req ResetPasswordReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, resetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// Login 调用上游登录接口。
func Login(c *gin.Context, req LoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, loginPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// GuestLogin 调用上游游客登录(按设备 ID 创建或找回游客账号)。
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// GetInfo 通过 token 拉取最新用户信息。
func GetInfo(c *gin.Context) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if err := upstream.GetJSON(c, getInfoPath, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// UpdateProfile 修改资料(昵称 / 头像等)。
func UpdateProfile(c *gin.Context, req UpdateProfileReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, profilePath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// UploadImage 上传图片;头像场景不加水印。
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
var resp ImageUploadResp
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
"watermark": "0",
}, &upstream.MultipartFile{
FieldName: "file",
FileName: fileName,
Reader: r,
}, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
return nil, fmt.Errorf("empty login data")
}
var user LoginResp
if err := json.Unmarshal(raw, &user); err != nil {
return nil, err
}
return &user, nil
}
// SetAuthCookies 只持久化 SSR 与前端登录态实际依赖的字段。
// 个人简介、邮箱、邀请码、展示卡信息由 getInfo 即时提供,不再随每个页面响应重复写入;
// sid 已存在于请求时也无需原样回写。ClearAuthCookies 仍保留这些历史键用于兼容清理。
func SetAuthCookies(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
secure := c.Request != nil && c.Request.TLS != nil
if strings.TrimSpace(user.Token) != "" {
setCookie(c, "token", user.Token, secure)
}
setCookie(c, "userId", strconv.FormatInt(user.UserID, 10), secure)
setCookie(c, "accountNo", user.AccountNo, secure)
setCookie(c, "nickName", user.NickName, secure)
setCookie(c, "avatar", user.Avatar, secure)
setCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10), secure)
setCookie(c, "vipType", strconv.Itoa(user.VipType), secure)
setCookie(c, "currentVipExpire", user.CurrentVipExpire, secure)
setCookie(c, "userType", strconv.Itoa(user.Type), secure)
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
setCookie(c, "isRegister", isReg, secure)
// 后续 SSR 服务必须能立即读取本次刚建立/刷新的会话;同时记录为本请求的
// 权威结果,避免 HTML 渲染阶段再次调用 getInfo 并重复写整套 Cookie。
patchAuthCookiesToRequest(c, user)
rememberRequestAuthUser(c, user)
}
func setCookie(c *gin.Context, name, value string, secure bool) {
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
writeCookie(c, &http.Cookie{
Name: name,
Value: url.PathEscape(value),
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour),
})
}
// writeCookie 对同一响应中同名、同 Path、同 Domain 的 Cookie 做 upsert,避免会话恢复的
// 「先清除、再重建」产生两套响应头,同时保留其他作用域下可能存在的同名 Cookie。
func writeCookie(c *gin.Context, cookie *http.Cookie) {
if c == nil || c.Writer == nil || cookie == nil || cookie.Name == "" {
return
}
header := c.Writer.Header()
existing := header.Values("Set-Cookie")
if len(existing) > 0 {
header.Del("Set-Cookie")
for _, raw := range existing {
if sameCookieScope(raw, cookie) {
continue
}
header.Add("Set-Cookie", raw)
}
}
http.SetCookie(c.Writer, cookie)
}
func sameCookieScope(raw string, target *http.Cookie) bool {
response := &http.Response{Header: http.Header{"Set-Cookie": []string{raw}}}
for _, existing := range response.Cookies() {
if existing.Name == target.Name && existing.Path == target.Path && strings.EqualFold(existing.Domain, target.Domain) {
return true
}
}
return false
}
func cookieValue(c *gin.Context, name string) string {
ck, err := c.Request.Cookie(name)
if err != nil || ck == nil {
return ""
}
raw := strings.TrimSpace(ck.Value)
if raw == "" {
return ""
}
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
if decoded, err := url.QueryUnescape(raw); err == nil {
return strings.TrimSpace(decoded)
}
return raw
}
// ClearAuthCookies 清除登录相关 cookie。
func ClearAuthCookies(c *gin.Context) {
forgetRequestAuthUser(c)
for _, name := range authCookieNames() {
writeCookie(c, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
}
func authCookieNames() []string {
return []string{
"token", "_token_", "sid", "_s_token_",
"userId", "accountNo", "nickName", "introduction", "avatar",
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
"userType", "isRegister",
}
}
// AuthFromCookies 从 cookie 读取登录态,供 SSR 头部 / 个人中心展示。
func AuthFromCookies(c *gin.Context) (loggedIn bool, userName, avatar, email string) {
token := useragent.TokenFromRequest(c.Request)
if token == "" {
return false, "", "", ""
}
userName = cookieValue(c, "nickName")
if userName == "" {
userName = cookieValue(c, "accountNo")
}
avatar = usableAvatarURL(cookieValue(c, "avatar"))
email = cookieValue(c, "email")
return true, userName, avatar, email
}
// IsRegisteredFromCookies 是否正式注册账号(非游客)。
func IsRegisteredFromCookies(c *gin.Context) bool {
if useragent.TokenFromRequest(c.Request) == "" {
return false
}
if cookieValue(c, "isRegister") == "1" {
return true
}
return cookieValue(c, "userType") == "1"
}
func usableAvatarURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/static/") {
return raw
}
// 无域名的相对路径暂不可用(解密拉取需要完整 URL)
return ""
}
-314
View File
@@ -1,314 +0,0 @@
package authser
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const guestDevIDCookie = "guest_dev_id"
const guestSkipAutoCookie = "guest_skip_auto"
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
// 已有任意会话(游客或正式账号)则跳过
if useragent.TokenFromRequest(c.Request) != "" {
return nil
}
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
if IsGuestSkipAuto(c) {
ClearGuestSkipAuto(c)
dropRequestCookie(c, guestSkipAutoCookie)
suppressRequestAutoAuth(c)
return nil
}
devID := ensureGuestDevID(c)
env, user, err := GuestLogin(c, GuestLoginReq{
DevID: devID,
DeviceID: devID,
Channel: guestChannel(c),
})
if err != nil {
log.Error("ensure guest on enter failed", log.E(err))
return nil
}
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
tip := ""
if env != nil {
tip = firstNonEmptyTip(env.Tip, env.Msg)
}
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
return nil
}
SetAuthCookies(c, user)
return user
}
// SyncUserOnEnter 是 SSR 页面统一的会话同步入口。同一请求已经通过游客登录、
// getInfo 或其他认证流程取得过最新用户时直接复用,避免再次访问上游和重复写 Cookie。
func SyncUserOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
if useragent.TokenFromRequest(c.Request) == "" {
return EnsureGuestOnEnter(c)
}
return RefreshUserOnEnter(c)
}
// RefreshUserOnEnter 页面进入时用 GET /api/app/auth/getInfo 刷新用户信息并写回 cookie。
// 保证刷新后头部昵称、头像、金币等与上游一致(如解锁后余额变化)。
func RefreshUserOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
token := useragent.TokenFromRequest(c.Request)
if token == "" {
return nil
}
env, user, err := GetInfo(c)
if err != nil {
log.Error("refresh user on enter failed", log.E(err))
return nil
}
if env != nil && IsSessionExpiredCode(env.Code) {
if retry, _ := OnSessionExpired(c, env.Code); retry {
// 游客重建已经返回完整用户并写好 Cookie,无需立刻再 getInfo 一次。
if fresh := freshRequestAuthUser(c); fresh != nil {
return fresh
}
env, user, err = GetInfo(c)
if err != nil {
log.Error("refresh user on enter retry failed", log.E(err))
return nil
}
} else {
return nil
}
}
if env == nil || env.Code != 200 || user == nil {
return nil
}
if strings.TrimSpace(user.Token) == "" {
user.Token = useragent.TokenFromRequest(c.Request)
if user.Token == "" {
user.Token = token
}
}
SetAuthCookies(c, user)
return user
}
// RefreshGuestSession 游客会话失效时(如上游 6001 用户不存在)重新游客登录。
// 正式注册账号不处理,避免把用户踢成游客。
func RefreshGuestSession(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if IsRegisteredFromCookies(c) {
return nil
}
forgetRequestAuthUser(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return EnsureGuestOnEnter(c)
}
func dropRequestCookie(c *gin.Context, name string) {
if c == nil || c.Request == nil || name == "" {
return
}
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
// MarkGuestSkipAuto 手动退出后标记:跳过紧接着的一次自动游客登录。
func MarkGuestSkipAuto(c *gin.Context) {
if c == nil {
return
}
suppressRequestAutoAuth(c)
secure := c.Request != nil && c.Request.TLS != nil
writeCookie(c, &http.Cookie{
Name: guestSkipAutoCookie,
Value: "1",
Path: "/",
MaxAge: 0, // session cookie
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// IsGuestSkipAuto 是否处于「手动退出后跳过自动游客」状态。
func IsGuestSkipAuto(c *gin.Context) bool {
return cookieValue(c, guestSkipAutoCookie) == "1"
}
// ClearGuestSkipAuto 清除手动退出跳过标记。
func ClearGuestSkipAuto(c *gin.Context) {
if c == nil {
return
}
writeCookie(c, &http.Cookie{
Name: guestSkipAutoCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
func ensureGuestDevID(c *gin.Context) string {
if id := cookieValue(c, guestDevIDCookie); id != "" {
return id
}
id := newGuestDevID()
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, guestDevIDCookie, id, secure)
patchRequestCookie(c, guestDevIDCookie, id)
return id
}
func newGuestDevID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
now := time.Now()
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
}
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
// ResolveChannel 统一解析渠道码并按首次归因落盘。
// 优先级:已有 channel cookie > 调用方传入值 > dc/ch/pc 查询参数;已有渠道不覆盖。
func ResolveChannel(c *gin.Context, candidate string) string {
channel := strings.TrimSpace(candidate)
if c == nil || c.Request == nil {
return channel
}
if stored := cookieValue(c, "channel"); stored != "" {
return stored
}
if channel == "" {
for _, key := range []string{"dc", "ch", "pc"} {
if value := strings.TrimSpace(c.Query(key)); value != "" {
channel = value
break
}
}
}
if channel == "" {
return ""
}
secure := c.Request.TLS != nil
setCookie(c, "channel", channel, secure)
patchRequestCookie(c, "channel", channel)
return channel
}
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
func guestChannel(c *gin.Context) string {
return ResolveChannel(c, "")
}
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
if strings.TrimSpace(user.Token) != "" {
patchRequestCookie(c, "token", user.Token)
}
patchRequestCookie(c, "userId", strconv.FormatInt(user.UserID, 10))
patchRequestCookie(c, "accountNo", user.AccountNo)
patchRequestCookie(c, "nickName", user.NickName)
patchRequestCookie(c, "introduction", user.Introduction)
patchRequestCookie(c, "avatar", user.Avatar)
patchRequestCookie(c, "email", user.Email)
patchRequestCookie(c, "inviteCode", user.InviteCode)
patchRequestCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10))
patchRequestCookie(c, "vipType", strconv.Itoa(user.VipType))
patchRequestCookie(c, "currentVipExpire", user.CurrentVipExpire)
patchRequestCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10))
patchRequestCookie(c, "showCardName", user.ShowCardName)
patchRequestCookie(c, "userType", strconv.Itoa(user.Type))
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
patchRequestCookie(c, "isRegister", isReg)
}
func patchRequestCookie(c *gin.Context, name, value string) {
if c == nil || c.Request == nil || name == "" {
return
}
escaped := url.PathEscape(value)
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
parts = append(parts, name+"="+escaped)
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
func envCode(env *APIEnvelope) int {
if env == nil {
return 0
}
return env.Code
}
func firstNonEmptyTip(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
-345
View File
@@ -1,345 +0,0 @@
package authser
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"videoweb/app/appg"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
var authCookieNamesWritten = []string{
"token",
"userId",
"accountNo",
"nickName",
"avatar",
"recharge",
"vipType",
"currentVipExpire",
"userType",
"isRegister",
}
func TestSyncUserOnEnterCreatesGuestWithoutRefreshingIt(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
if r.Method != http.MethodPost {
t.Errorf("guest method = %s, want POST", r.Method)
}
var request GuestLoginReq
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode guest request: %v", err)
}
if request.DevID == "" || request.DeviceID != request.DevID {
t.Errorf("guest device ids = %#v, want matching non-empty ids", request)
}
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "new-guest"))
case getInfoPath:
getInfoCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "unexpected-refresh"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext()
user := SyncUserOnEnter(c)
if user == nil || user.Token != "guest-token" {
t.Fatalf("SyncUserOnEnter user = %#v, want guest-token", user)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
if got := getInfoCalls.Load(); got != 0 {
t.Errorf("getInfo calls = %d, want 0", got)
}
assertRequestToken(t, c, "guest-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestSyncUserOnEnterRefreshesExistingTokenOnlyOnce(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
authorization := make(chan string, 1)
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("unexpected-guest-token", "unexpected-guest"))
case getInfoPath:
getInfoCalls.Add(1)
select {
case authorization <- r.Header.Get("Authorization"):
default:
}
// getInfo may omit token; the existing request token must be preserved.
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("", "fresh-user"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext(
&http.Cookie{Name: "token", Value: "existing-token"},
&http.Cookie{Name: "sid", Value: "existing-sid"},
)
first := SyncUserOnEnter(c)
second := SyncUserOnEnter(c)
if first == nil || first.Token != "existing-token" || first.NickName != "fresh-user" {
t.Fatalf("first SyncUserOnEnter user = %#v, want refreshed user with existing token", first)
}
if second == nil || second.Token != "existing-token" || second.NickName != "fresh-user" {
t.Fatalf("second SyncUserOnEnter user = %#v, want memoized refreshed user", second)
}
if got := guestCalls.Load(); got != 0 {
t.Errorf("guest calls = %d, want 0", got)
}
if got := getInfoCalls.Load(); got != 1 {
t.Errorf("getInfo calls = %d, want 1", got)
}
select {
case got := <-authorization:
if got != "existing-token" {
t.Errorf("Authorization = %q, want existing-token", got)
}
default:
t.Error("getInfo did not capture Authorization header")
}
assertRequestToken(t, c, "existing-token")
if got := cookieValue(c, "nickName"); got != "fresh-user" {
t.Errorf("request nickName cookie = %q, want fresh-user", got)
}
assertSingleAuthCookieSet(t, recorder)
}
func TestSyncUserOnEnterReusesGuestCreatedBeforeRender(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "pre-render-guest"))
case getInfoPath:
getInfoCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "unexpected-refresh"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext()
guest := EnsureGuestOnEnter(c)
user := SyncUserOnEnter(c)
if guest == nil || guest.Token != "pre-render-token" {
t.Fatalf("EnsureGuestOnEnter user = %#v, want pre-render guest", guest)
}
if user == nil || user.Token != "pre-render-token" || user.NickName != "pre-render-guest" {
t.Fatalf("SyncUserOnEnter user = %#v, want memoized pre-render guest", user)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
if got := getInfoCalls.Load(); got != 0 {
t.Errorf("getInfo calls = %d, want 0", got)
}
assertRequestToken(t, c, "pre-render-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestRefreshUserOnEnterReturnsRebuiltGuestWithoutRefreshingItAgain(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
getInfoAuthorization := make(chan string, 1)
guestAuthorization := make(chan string, 1)
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case getInfoPath:
getInfoCalls.Add(1)
select {
case getInfoAuthorization <- r.Header.Get("Authorization"):
default:
}
writeAuthTestEnvelope(w, http.StatusOK, 6001, nil)
case guestPath:
guestCalls.Add(1)
select {
case guestAuthorization <- r.Header.Get("Authorization"):
default:
}
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("rebuilt-guest-token", "rebuilt-guest"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext(
&http.Cookie{Name: "token", Value: "expired-guest-token"},
&http.Cookie{Name: "isRegister", Value: "0"},
&http.Cookie{Name: "userType", Value: "0"},
)
user := RefreshUserOnEnter(c)
// The rebuilt guest must also be reusable by the page-level sync call.
synced := SyncUserOnEnter(c)
if user == nil || user.Token != "rebuilt-guest-token" || user.NickName != "rebuilt-guest" {
t.Fatalf("RefreshUserOnEnter user = %#v, want rebuilt guest", user)
}
if synced == nil || synced.Token != "rebuilt-guest-token" {
t.Fatalf("SyncUserOnEnter user = %#v, want memoized rebuilt guest", synced)
}
if got := getInfoCalls.Load(); got != 1 {
t.Errorf("getInfo calls = %d, want 1 (no refresh of rebuilt guest)", got)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
select {
case got := <-getInfoAuthorization:
if got != "expired-guest-token" {
t.Errorf("getInfo Authorization = %q, want expired-guest-token", got)
}
default:
t.Error("getInfo did not capture Authorization header")
}
select {
case got := <-guestAuthorization:
if got != "" {
t.Errorf("guest Authorization = %q, want empty after expired token is removed", got)
}
default:
t.Error("guest login did not capture Authorization header")
}
assertRequestToken(t, c, "rebuilt-guest-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestSetAuthCookiesUpsertsSameNameWithinResponse(t *testing.T) {
c, recorder := newAuthTestContext()
SetAuthCookies(c, testLoginUser("first-token", "first-user"))
SetAuthCookies(c, testLoginUser("latest-token", "latest-user"))
assertSingleAuthCookieSet(t, recorder)
assertRequestToken(t, c, "latest-token")
if got := cookieValue(c, "nickName"); got != "latest-user" {
t.Errorf("request nickName cookie = %q, want latest-user", got)
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == "token" && cookie.Value != "latest-token" {
t.Errorf("response token cookie = %q, want latest-token", cookie.Value)
}
}
}
func useAuthTestUpstream(t *testing.T, handler http.Handler) {
t.Helper()
server := httptest.NewServer(handler)
previousServerAddr := appg.Conf.ServerAddr
appg.Conf.ServerAddr = server.URL
t.Cleanup(func() {
appg.Conf.ServerAddr = previousServerAddr
server.Close()
})
}
func newAuthTestContext(cookies ...*http.Cookie) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "https://site.test/page", nil)
for _, cookie := range cookies {
c.Request.AddCookie(cookie)
}
return c, recorder
}
func writeAuthTestEnvelope(w http.ResponseWriter, status, code int, data *LoginResp) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *LoginResp `json:"data"`
}{
Code: code,
Msg: http.StatusText(status),
Data: data,
})
}
func testLoginUser(token, nickName string) *LoginResp {
return &LoginResp{
Token: token,
UserID: 101,
AccountNo: "account-101",
Email: "user101@example.test",
NickName: nickName,
// This field is intentionally large: it must stay out of Set-Cookie headers.
Introduction: strings.Repeat("简介", 150),
Avatar: "https://cdn.example.test/avatar.png",
InviteCode: "INVITE101",
VipType: 1,
CurrentVipExpire: "2099-01-01T00:00:00Z",
ShowCardID: 7,
ShowCardName: "monthly",
Type: 0,
Recharge: 88,
}
}
func assertRequestToken(t *testing.T, c *gin.Context, want string) {
t.Helper()
if got := useragent.TokenFromRequest(c.Request); got != want {
t.Errorf("request token = %q, want %q", got, want)
}
}
func assertSingleAuthCookieSet(t *testing.T, recorder *httptest.ResponseRecorder) {
t.Helper()
counts := make(map[string]int)
for _, cookie := range recorder.Result().Cookies() {
counts[cookie.Name]++
}
for name, count := range counts {
if count > 1 {
t.Errorf("Set-Cookie %q written %d times, want at most once", name, count)
}
}
for _, name := range authCookieNamesWritten {
if got := counts[name]; got != 1 {
t.Errorf("auth Set-Cookie %q written %d times, want exactly once", name, got)
}
}
for _, name := range []string{"introduction", "email", "inviteCode", "showCardId", "showCardName", "sid"} {
if got := counts[name]; got != 0 {
t.Errorf("non-session Set-Cookie %q written %d times, want none", name, got)
}
}
var headerBytes int
for _, raw := range recorder.Header().Values("Set-Cookie") {
headerBytes += len("Set-Cookie: ") + len(raw) + len("\r\n")
}
if headerBytes >= 4096 {
t.Errorf("Set-Cookie response headers = %d bytes, want below 4096", headerBytes)
}
}
-67
View File
@@ -1,67 +0,0 @@
package authser
import "github.com/gin-gonic/gin"
const requestAuthStateKey = "authser.request-auth-state"
// requestAuthState 只在单个 Gin 请求内有效,用来避免多个 SSR 服务重复刷新同一会话。
// freshUser 表示本请求已经从上游取得并写回过的最新用户;suppressAutoAuth
// 表示本请求明确不应再自动创建游客(例如用户刚退出或正式账号已过期)。
type requestAuthState struct {
freshUser *LoginResp
suppressAutoAuth bool
}
func authState(c *gin.Context) *requestAuthState {
if c == nil {
return nil
}
if value, ok := c.Get(requestAuthStateKey); ok {
if state, ok := value.(*requestAuthState); ok && state != nil {
return state
}
}
state := &requestAuthState{}
c.Set(requestAuthStateKey, state)
return state
}
func freshRequestAuthUser(c *gin.Context) *LoginResp {
state := authState(c)
if state == nil {
return nil
}
return state.freshUser
}
func rememberRequestAuthUser(c *gin.Context, user *LoginResp) {
state := authState(c)
if state == nil {
return
}
state.freshUser = user
state.suppressAutoAuth = false
}
func forgetRequestAuthUser(c *gin.Context) {
if state := authState(c); state != nil {
state.freshUser = nil
}
}
func suppressRequestAutoAuth(c *gin.Context) {
if state := authState(c); state != nil {
state.suppressAutoAuth = true
}
}
func allowRequestAutoAuth(c *gin.Context) {
if state := authState(c); state != nil {
state.suppressAutoAuth = false
}
}
func requestAutoAuthSuppressed(c *gin.Context) bool {
state := authState(c)
return state != nil && state.suppressAutoAuth
}
-58
View File
@@ -1,58 +0,0 @@
package authser
import "github.com/gin-gonic/gin"
// TokenExpiredCode 上游约定:token 失效,需重新登录。
const TokenExpiredCode = 1000
// IsSessionExpiredCode 是否为会话失效类业务码。
func IsSessionExpiredCode(code int) bool {
switch code {
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
return true
default:
return false
}
}
// OnSessionExpired 处理上游会话失效。
// retry=true:游客已重新登录,调用方应重试原请求;
// needLogin=true:注册账号已清 cookie,调用方应跳转登录弹窗/页。
func OnSessionExpired(c *gin.Context, code int) (retry, needLogin bool) {
if c == nil || !IsSessionExpiredCode(code) {
return false, false
}
if IsRegisteredFromCookies(c) {
ClearAuthCookies(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
// 正式账号失效后,本请求保持退出态,避免后续 HTML 渲染又静默转成游客。
suppressRequestAutoAuth(c)
return false, true
}
if RefreshGuestSession(c) != nil {
return true, false
}
ClearAuthCookies(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return false, false
}
// RecoverContentSession 用于允许游客访问的内容读取接口。
// 正式账号或游客 token 失效后,清理旧登录态、重新建立游客会话,调用方随后重试一次原请求。
// 写操作仍应使用 OnSessionExpired,避免把用户操作静默降级为游客操作。
func RecoverContentSession(c *gin.Context, code int) bool {
if c == nil || !IsSessionExpiredCode(code) {
return false
}
ClearAuthCookies(c)
for _, name := range authCookieNames() {
dropRequestCookie(c, name)
}
dropRequestCookie(c, guestSkipAutoCookie)
ClearGuestSkipAuto(c)
// 内容读取接口明确允许降级为游客,因此覆盖本请求可能已有的自动认证抑制。
allowRequestAutoAuth(c)
return EnsureGuestOnEnter(c) != nil
}
-95
View File
@@ -1,95 +0,0 @@
package authser
import "encoding/json"
// RegisterReq 对应 swagger authapi.registerReq
type RegisterReq struct {
AccountNo string `json:"accountNo"`
Email string `json:"email,omitempty"`
Password string `json:"password"`
AgentInviteCode string `json:"agentInviteCode,omitempty"`
DevID string `json:"devID,omitempty"`
Channel string `json:"channel,omitempty"`
}
// LoginReq 对应 swagger authapi.loginReq
type LoginReq struct {
Account string `json:"account"`
Password string `json:"password"`
DevID string `json:"devID,omitempty"`
Channel string `json:"channel,omitempty"`
}
// ResetPasswordReq 对应 swagger authapi.resetPasswordReq。
type ResetPasswordReq struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NewPassword string `json:"newPassword"`
}
// GuestLoginReq 对应 swagger authser.GuestLoginReq
// POST /api/app/auth/guest
type GuestLoginReq struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId,omitempty"`
Mac string `json:"mac,omitempty"`
Channel string `json:"channel,omitempty"`
}
// UpdateProfileReq 对应 swagger authapi.updateProfileReq
type UpdateProfileReq struct {
Avatar *string `json:"avatar,omitempty"`
Introduction *string `json:"introduction,omitempty"`
NickName *string `json:"nickName,omitempty"`
OldPassword *string `json:"oldPassword,omitempty"`
NewPassword *string `json:"newPassword,omitempty"`
PrivacyMode *bool `json:"privacyMode,omitempty"`
}
// LoginResp 对应 swagger authser.LoginResp
// GET /api/app/auth/getInfo、登录/注册/游客成功 data。
type LoginResp struct {
Token string `json:"token"`
UserID int64 `json:"userId"`
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NickName string `json:"nickName"`
Introduction string `json:"introduction"` // 简介
Avatar string `json:"avatar"` // 头像
InviteCode string `json:"inviteCode"` // 邀请码
VipType int `json:"vipType"` // 0没卡 1月卡 2年卡
CurrentVipExpire string `json:"currentVipExpire"` // VIP 过期时间,需结合 vipType 判断是否有效
ShowCardID int64 `json:"showCardId"` // 最新会员卡 id
ShowCardName string `json:"showCardName"` // 最新会员卡名称,如「1个月会员」
Status int `json:"status"` // 0正常 1封禁 2禁言
Type int `json:"type"` // 身份类型:0游客 1普通用户
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
IsRegister bool `json:"isRegister"` // 是否已注册
HasPassword *bool `json:"hasPassword,omitempty"` // 是否已设置密码;上游未返回时由账号状态推断
LoginType int `json:"loginType"` // 登录方式
PrivacyMode bool `json:"privacyMode"` // 隐私模式
Score int64 `json:"score"` // 积分
Recharge int64 `json:"recharge"` // 金币余额
}
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
type APIEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
// ImageUploadData 对应 swagger uploadser.ImageUploadData
type ImageUploadData struct {
ID string `json:"id"`
SourceNameFs string `json:"sourceNameFs"`
}
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
type ImageUploadResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data *ImageUploadData `json:"data"`
}
-97
View File
@@ -1,97 +0,0 @@
package authser
import (
"fmt"
"strings"
)
// SettingPageVM 账号设置页展示模型。
type SettingPageVM struct {
UserID int64
AccountNo string
HasAccount bool
HasPassword bool
NickName string
Introduction string
Email string
EmailMasked string
EmailBound bool
Avatar string // 可用头像 URL;空则模板用默认图
InviteCode string
PrivacyMode bool
VipType int
VipExpire string
Recharge int64
Score int64
DisplayName string
}
// BuildSettingVM 由 LoginResp 构建设置页 VM。
func BuildSettingVM(user *LoginResp) SettingPageVM {
if user == nil {
return SettingPageVM{}
}
nick := strings.TrimSpace(user.NickName)
account := strings.TrimSpace(user.AccountNo)
registered := user.IsRegister || user.Type == 1
hasAccount := registered && account != ""
hasPassword := hasAccount
if user.HasPassword != nil {
hasPassword = *user.HasPassword
}
email := strings.TrimSpace(user.Email)
intro := strings.TrimSpace(user.Introduction)
display := nick
if display == "" {
display = account
}
if display == "" {
display = "用户"
}
return SettingPageVM{
UserID: user.UserID,
AccountNo: account,
HasAccount: hasAccount,
HasPassword: hasPassword,
NickName: nick,
Introduction: intro,
Email: email,
EmailMasked: MaskEmail(email),
EmailBound: email != "",
Avatar: usableAvatarURL(user.Avatar),
InviteCode: strings.TrimSpace(user.InviteCode),
PrivacyMode: user.PrivacyMode,
VipType: user.VipType,
VipExpire: strings.TrimSpace(user.CurrentVipExpire),
Recharge: user.Recharge,
Score: user.Score,
DisplayName: display,
}
}
// MaskEmail 邮箱脱敏,如 ab***@mail.com。
func MaskEmail(email string) string {
email = strings.TrimSpace(email)
if email == "" {
return ""
}
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return email
}
local := email[:at]
domain := email[at:]
runes := []rune(local)
if len(runes) <= 2 {
return string(runes[:1]) + "***" + domain
}
return string(runes[:2]) + "***" + domain
}
// FormatUserID 展示用 ID。
func FormatUserID(id int64) string {
if id <= 0 {
return ""
}
return fmt.Sprintf("%d", id)
}
-187
View File
@@ -1,187 +0,0 @@
package authser
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func parseVipExpireMs(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
if n > 0 && n < 1e12 {
return n * 1000
}
return n
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.UnixMilli()
}
}
return 0
}
// UserIDFromCookies 读取 cookie userId。
func UserIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipTypeFromCookies 读取 cookie vipType。
func VipTypeFromCookies(c *gin.Context) int {
if c == nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
return n
}
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
func VipTypeLabel(vipType int) string {
switch vipType {
case 1:
return "月卡会员"
case 2:
return "年卡会员"
default:
return "普通会员"
}
}
// ShowCardNameFromCookies 读取 cookie showCardName。
func ShowCardNameFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return strings.TrimSpace(cookieValue(c, "showCardName"))
}
// ShowCardIDFromCookies 读取 cookie showCardId。
func ShowCardIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
func VipDisplayLabel(showCardName string, vipType int) string {
if s := strings.TrimSpace(showCardName); s != "" {
return s
}
return VipTypeLabel(vipType)
}
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
func VipDisplayLabelFromCookies(c *gin.Context) string {
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
}
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
if vipType == 0 {
return false
}
expireMs := parseVipExpireMs(currentVipExpire)
if expireMs <= 0 {
return false
}
return expireMs > time.Now().UnixMilli()
}
// FormatVipExpireText 格式化到期时间展示。
func FormatVipExpireText(currentVipExpire string) string {
ms := parseVipExpireMs(currentVipExpire)
if ms <= 0 {
return ""
}
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
}
// VipExpireTextFromCookies 格式化到期时间展示。
func VipExpireTextFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
}
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
func IsActiveVipFromCookies(c *gin.Context) bool {
if c == nil {
return false
}
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
}
// RechargeFromCookies 读取 cookie recharge(金币余额)。
func RechargeFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
if n < 0 {
return 0
}
return n
}
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
func SetRechargeCookie(c *gin.Context, balance int64) {
if c == nil {
return
}
if balance < 0 {
balance = 0
}
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
}
// FormatRecharge 金币余额千分位展示。
func FormatRecharge(n int64) string {
if n < 0 {
n = 0
}
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
return s
}
var b strings.Builder
rem := len(s) % 3
if rem > 0 {
b.WriteString(s[:rem])
if len(s) > rem {
b.WriteByte(',')
}
}
for i := rem; i < len(s); i += 3 {
b.WriteString(s[i : i+3])
if i+3 < len(s) {
b.WriteByte(',')
}
}
return b.String()
}
-22
View File
@@ -1,22 +0,0 @@
package commentser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const likeSetPath = "/api/app/comment/like/set"
// LikeSet 设置评论点赞/取消点赞。
func LikeSet(c *gin.Context, req CommentLikeSetReq) (*APIEnvelope[CommentLikeSetResp], error) {
if req.CommentID <= 0 {
return nil, fmt.Errorf("comment like invalid commentId")
}
var env APIEnvelope[CommentLikeSetResp]
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-8
View File
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
@@ -57,18 +56,11 @@ func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
authser.EnsureGuestOnEnter(c)
requestPath := listPath + "?" + params.Encode()
env, err := fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
env, err = fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
}
if env.Code != 200 {
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
}
-43
View File
@@ -1,43 +0,0 @@
package commentser
import (
"fmt"
"strings"
"unicode/utf8"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const sendPath = "/api/app/comment/send"
// Send 发布评论(一级 / 二级)。
func Send(c *gin.Context, req CommentSendReq) (*APIEnvelope[any], error) {
req.Text = strings.TrimSpace(req.Text)
if req.ObjectID <= 0 {
return nil, fmt.Errorf("comment send invalid objectId")
}
req.ObjectType = NormalizeObjectType(req.ObjectType)
if req.Text == "" {
return nil, fmt.Errorf("comment send empty text")
}
if utf8.RuneCountInString(req.Text) > 200 {
return nil, fmt.Errorf("comment send text too long")
}
if req.ParentsID < 0 {
req.ParentsID = 0
}
if req.ReplyCommentID < 0 {
req.ReplyCommentID = 0
}
// 二级评论:未显式指定 replyCommentId 时默认回复父评论
if req.ParentsID > 0 && req.ReplyCommentID <= 0 {
req.ReplyCommentID = req.ParentsID
}
var env APIEnvelope[any]
if _, err := upstream.PostJSON(c, sendPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+2 -2
View File
@@ -47,9 +47,9 @@ func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
if configCache != nil {
return *configCache
}
return ApplyTrackerFallback(AppConfigVM{})
return AppConfigVM{}
}
vm := ApplyTrackerFallback(BuildAppConfigVM(info))
vm := BuildAppConfigVM(info)
configCache = &vm
configAt = time.Now()
return vm
-25
View File
@@ -1,25 +0,0 @@
package configser
import (
"strings"
"videoweb/app/appg"
)
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1045。
func ResolveTrackerAppID() string {
return appg.TrackerAppID()
}
// ResolveReportSDKConfig 上游 reportSdkConfig 优先,本地 yaml 兜底。
func ResolveReportSDKConfig(fromAPI string) string {
if v := strings.TrimSpace(fromAPI); v != "" {
return v
}
return strings.TrimSpace(appg.Conf.Tracker.EncryptedConfig)
}
// ApplyTrackerFallback 补齐 AppConfigVM 中的上报密文。
func ApplyTrackerFallback(vm AppConfigVM) AppConfigVM {
vm.ReportSDKConfig = ResolveReportSDKConfig(vm.ReportSDKConfig)
return vm
}
+6 -8
View File
@@ -52,16 +52,14 @@ type FooterContact struct {
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
// GET /api/app/config/info
// reportSdkConfig 与 proto.ConfigInfo 对齐;上游若尚未下发则为空,可由本地 yaml 兜底。
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
type ConfigInfoResp struct {
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
Config ConfigInfo `json:"config"`
Advitise AdvitiseData `json:"advitise"`
SeoInfo SeoInfo `json:"seoInfo"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
NavPageList []NavPageItem `json:"navPageList"`
ReportSDKConfig string `json:"reportSdkConfig"`
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
Config ConfigInfo `json:"config"`
Advitise AdvitiseData `json:"advitise"`
SeoInfo SeoInfo `json:"seoInfo"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
NavPageList []NavPageItem `json:"navPageList"`
}
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
-2
View File
@@ -29,7 +29,6 @@ type AppConfigVM struct {
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
NavPageList []NavPageItem `json:"navPageList,omitempty"`
ReportSDKConfig string `json:"reportSdkConfig,omitempty"`
Advitise AdvitiseData `json:"advitise"`
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
NineGridList []Advertisement `json:"nineGridList,omitempty"`
@@ -64,7 +63,6 @@ func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
TwitterAddr: firstNonEmptyURL(cfg.TwitterAddr, addr.TwitterAddr),
GitAddr: sanitizeURL(cfg.GitAddr),
FreeVPN: sanitizeURL(cfg.FreeVPN),
ReportSDKConfig: strings.TrimSpace(info.ReportSDKConfig),
NavPageList: buildNavPageList(info.NavPageList),
Advitise: info.Advitise,
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
+23 -5
View File
@@ -1,7 +1,7 @@
package feedbackser
import (
"videoweb/app/service/authser"
"io"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
@@ -11,9 +11,11 @@ const addPath = "/api/app/feedback/add"
const contactUsAddPath = "/api/app/contactus/add"
const uploadPath = "/api/app/upload/image"
// Add 调用上游提交意见反馈。
func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
func Add(c *gin.Context, req FeedbackReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, addPath, req, &env); err != nil {
return nil, err
}
@@ -21,10 +23,26 @@ func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
}
// AddContactUs 调用上游联系我们接口。
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, contactUsAddPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// UploadImage 上传反馈截图;不加水印。
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
var resp ImageUploadResp
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
"watermark": "0",
}, &upstream.MultipartFile{
FieldName: "file",
FileName: fileName,
Reader: r,
}, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
+24
View File
@@ -1,5 +1,29 @@
package feedbackser
import "encoding/json"
// APIEnvelope 上游通用响应。
type APIEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
// ImageUploadData 对应 swagger uploadser.ImageUploadData
type ImageUploadData struct {
ID string `json:"id"`
SourceNameFs string `json:"sourceNameFs"`
}
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
type ImageUploadResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data *ImageUploadData `json:"data"`
}
// FeedbackReq 对应 swagger proto.FeedbackReq。
// type(接口说明):1-功能建议、2-播放问题、3-账号问题、4-其他
type FeedbackReq struct {
-56
View File
@@ -1,56 +0,0 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const boughtListPath = "/api/app/playlet/bought/list"
// PlayletBoughtData 对应 proto.PlayletBoughtData;只列出页面所需字段。
type PlayletBoughtData struct {
ID int64 `json:"id"`
Title string `json:"title"`
PlayName string `json:"playName"`
Slug string `json:"slug"`
Cover string `json:"cover"`
ThemeNames []string `json:"themeNames"`
TotalEpisode int `json:"totalEpisode"`
BoughtChapterCount int `json:"boughtChapterCount"`
CostCoins int64 `json:"costCoins"`
BoughtAt string `json:"boughtAt"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
WatchedCount int `json:"watchedCount"`
}
type PlayletBoughtListResp struct {
List []PlayletBoughtData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
func FetchBoughtList(c *gin.Context, pageNum, pageSize int) (*PlayletBoughtListResp, int, error) {
if pageNum < 1 {
pageNum = 1
}
if pageSize < 1 {
pageSize = 20
}
params := url.Values{}
params.Set("pageNum", strconv.Itoa(pageNum))
params.Set("pageSize", strconv.Itoa(pageSize))
var env APIEnvelope[PlayletBoughtListResp]
if err := upstream.GetJSON(c, boughtListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet bought list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
-17
View File
@@ -5,7 +5,6 @@ import (
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
@@ -49,18 +48,10 @@ func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterInfoResp]
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
@@ -91,18 +82,10 @@ func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*Pla
} else {
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterListResp]
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
-84
View File
@@ -1,84 +0,0 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
type PlayletChapterCollectData struct {
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
PlayletID int64 `json:"playletId"`
PlayletSlug string `json:"playletSlug"`
PlayletTitle string `json:"playletTitle"`
PlayletCover string `json:"playletCover"`
CurrentEpisode int `json:"currentEpisode"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
MediaTime int `json:"mediaTime"`
PayType int `json:"payType"`
SingleCoins int `json:"singleCoins"`
VideoURL string `json:"videoUrl"`
}
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
type PlayletChapterCollectListResp struct {
List []PlayletChapterCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletChapterCollectListResp]
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
data, code, err := FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet chapter collect list failed", log.E(err))
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
-63
View File
@@ -1,63 +0,0 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const (
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
)
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
type PlayletChapterLikeSetReq struct {
ChapterID int64 `json:"chapterId"`
IsLike bool `json:"isLike"`
}
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
type PlayletChapterLikeSetResp struct {
ChapterID int64 `json:"chapterId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// ChapterLikeSet 设置剧集点赞/取消点赞。
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter like invalid chapterId")
}
var env APIEnvelope[PlayletChapterLikeSetResp]
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
type PlayletChapterCollectSetReq struct {
ChapterID int64 `json:"chapterId"`
IsCollect bool `json:"isCollect"`
}
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
type PlayletChapterCollectSetResp struct {
ChapterID int64 `json:"chapterId"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// ChapterCollectSet 设置剧集收藏/取消收藏。
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter collect invalid chapterId")
}
var env APIEnvelope[PlayletChapterCollectSetResp]
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-40
View File
@@ -1,40 +0,0 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
// POST /api/app/playlet/chapter/unlock
type PlayletChapterUnlockReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
type PlayletChapterUnlockResp struct {
Balance int64 `json:"balance"`
CanWatch bool `json:"canWatch"`
ChangeCoin int64 `json:"changeCoin"`
ChapterID int64 `json:"chapterId"`
CostCoins int64 `json:"costCoins"`
PlayletID int64 `json:"playletId"`
Unlocked bool `json:"unlocked"`
}
// ChapterUnlock 使用金币解锁剧集(幂等)。
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
if req.ChapterID <= 0 || req.PlayletID <= 0 {
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
}
var env APIEnvelope[PlayletChapterUnlockResp]
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-39
View File
@@ -1,39 +0,0 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterWatchReportPath = "/api/app/playlet/chapter/watch/report"
// PlayletChapterWatchReportReq 对应 swagger proto.PlayletChapterWatchReportReq
type PlayletChapterWatchReportReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId,omitempty"`
}
// PlayletChapterWatchReportResp 对应 swagger proto.PlayletChapterWatchReportResp
type PlayletChapterWatchReportResp struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
CurrentEpisode int `json:"currentEpisode"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
}
// ChapterWatchReport 上报短剧剧集观看。
func ChapterWatchReport(c *gin.Context, req PlayletChapterWatchReportReq) (*APIEnvelope[PlayletChapterWatchReportResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter watch report invalid chapterId")
}
var env APIEnvelope[PlayletChapterWatchReportResp]
if _, err := upstream.PostJSON(c, chapterWatchReportPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-161
View File
@@ -1,161 +0,0 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
collectSetPath = "/api/app/playlet/collect/set"
collectListPath = "/api/app/playlet/collect/list"
defaultCollectPageSize = 40
CollectTypePlaylet = "playlet"
CollectTypeChapter = "chapter"
)
// PlayletCollectSetReq 对应 swagger proto.PlayletCollectSetReq
type PlayletCollectSetReq struct {
ID int64 `json:"id"`
IsCollect bool `json:"isCollect"`
}
// PlayletCollectSetResp 对应 swagger proto.PlayletCollectSetResp
type PlayletCollectSetResp struct {
ID int64 `json:"id"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// PlayletCollectData 对应 swagger proto.PlayletCollectData
type PlayletCollectData struct {
ID int64 `json:"id"`
Title string `json:"title"`
PlayName string `json:"playName"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Desc string `json:"desc"`
TagIDs []int64 `json:"tagIds"`
TagNames []string `json:"tagNames"`
TagSlugs []string `json:"tagSlugs"`
ThemeIDs []int64 `json:"themeIds"`
ThemeNames []string `json:"themeNames"`
ThemeSlugs []string `json:"themeSlugs"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsFeatured bool `json:"isFeatured"`
IsNew bool `json:"isNew"`
IsRecommend bool `json:"isRecommend"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
}
// PlayletCollectListResp 对应 swagger proto.PlayletCollectListResp
type PlayletCollectListResp struct {
List []PlayletCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// CollectListQuery 收藏列表分页参数。
type CollectListQuery struct {
PageNum int
PageSize int
WatchStatus int // 0 全部、1 追剧中、2 已看完;仅短剧收藏列表使用
Type string // playlet | chapter
}
// NormalizeCollectType 归一化收藏类型。
func NormalizeCollectType(raw string) string {
switch strings.TrimSpace(strings.ToLower(raw)) {
case CollectTypeChapter, "2", "episode", "ep":
return CollectTypeChapter
default:
return CollectTypePlaylet
}
}
// Normalize 校正分页默认值。
func (q *CollectListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCollectPageSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
if q.WatchStatus < 0 || q.WatchStatus > 2 {
q.WatchStatus = 0
}
q.Type = NormalizeCollectType(q.Type)
}
// CollectSet 设置短剧收藏/取消收藏。
func CollectSet(c *gin.Context, req PlayletCollectSetReq) (*APIEnvelope[PlayletCollectSetResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("playlet collect invalid id")
}
var env APIEnvelope[PlayletCollectSetResp]
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchCollectList 拉取当前用户短剧收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchCollectList(c *gin.Context, q CollectListQuery) (*PlayletCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("watchStatus", strconv.Itoa(q.WatchStatus))
var env APIEnvelope[PlayletCollectListResp]
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletCollectListResp {
data, code, err := FetchCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet collect list failed", log.E(err))
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
-89
View File
@@ -1,89 +0,0 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
)
func collectPageURL(pageNum int, collectType string) string {
collectType = NormalizeCollectType(collectType)
path := "/collect"
if collectType != CollectTypePlaylet {
path += "/" + url.PathEscape(collectType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path
}
func CollectCanonicalPath(q CollectListQuery) string {
q.Normalize()
return collectPageURL(q.PageNum, q.Type)
}
// BuildCollectPagination 收藏列表分页。
func BuildCollectPagination(q CollectListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return collectPageURL(p, q.Type)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseCollectListQuery 从 query 解析分页与类型(playlet 短剧 / chapter 剧集)。
func ParseCollectListQuery(pageStr, typeStr string) CollectListQuery {
q := CollectListQuery{
PageSize: defaultCollectPageSize,
Type: NormalizeCollectType(typeStr),
}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
// collectProgressText 进度文案:优先最近观看集,其次未看/已看完。
func collectProgressText(item PlayletCollectData) string {
if item.LastEpisode > 0 {
return fmt.Sprintf("第%d集", item.LastEpisode)
}
if item.TotalEpisode > 0 && item.UnwatchedCount <= 0 {
return "已看完"
}
if item.UnwatchedCount > 0 {
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
}
return ""
}
// collectTagText 收藏卡片标签:仅展示 TagNames 第一项,无标签时不显示。
func collectTagText(item PlayletCollectData) string {
return firstName(item.TagNames)
}
// chapterCollectEpisodeLabel 剧集收藏角标:总集数。
func chapterCollectEpisodeLabel(item PlayletChapterCollectData) string {
return episodeText(item.TotalEpisode)
}
// chapterCollectDesc 剧集收藏副标题。
func chapterCollectDesc(item PlayletChapterCollectData) string {
if t := strings.TrimSpace(item.ChapterTitle); t != "" {
return t
}
if item.CurrentEpisode > 0 {
return fmt.Sprintf("第%d集", item.CurrentEpisode)
}
return ""
}
// chapterCollectMeta 剧集收藏右侧文案:第 N 集。
func chapterCollectMeta(item PlayletChapterCollectData) string {
if item.CurrentEpisode > 0 {
return fmt.Sprintf("第%d集", item.CurrentEpisode)
}
return updateStatusText(item.UpdateStatus)
}
-10
View File
@@ -5,7 +5,6 @@ import (
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
@@ -45,19 +44,10 @@ func FetchDetailByKey(c *gin.Context, key string) (*PlayletInfoResp, error) {
}
func fetchDetail(c *gin.Context, queryKey, value string) (*PlayletInfoResp, error) {
authser.EnsureGuestOnEnter(c)
env, err := fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
-166
View File
@@ -1,166 +0,0 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
historyListPath = "/api/app/playlet/history/list"
historyClearPath = "/api/app/playlet/history/clear"
historyDeletePath = "/api/app/playlet/history/delete"
defaultHistoryPageSize = 40
)
// PlayletHistoryData 对应 swagger proto.PlayletHistoryData
// GET /api/app/playlet/history/list
type PlayletHistoryData struct {
ID int64 `json:"id"`
Status int `json:"status"`
IsFeatured bool `json:"isFeatured"`
IsNew bool `json:"isNew"`
IsRecommend bool `json:"isRecommend"`
ShowOnBanner bool `json:"showOnBanner"`
Banner string `json:"banner"`
OnlineAt string `json:"onlineAt"`
MediaCreatedAt string `json:"mediaCreatedAt"`
MediaUpdatedAt string `json:"mediaUpdatedAt"`
ThemeIDs []int64 `json:"themeIds"`
ThemeNames []string `json:"themeNames"`
ThemeSlugs []string `json:"themeSlugs"`
Title string `json:"title"`
PlayName string `json:"playName"`
Slug string `json:"slug"`
Desc string `json:"desc"`
Anchor string `json:"anchor"`
Cover string `json:"cover"`
TagIDs []int64 `json:"tagIds"`
TagNames []string `json:"tagNames"`
TagSlugs []string `json:"tagSlugs"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
IsFinished bool `json:"isFinished"`
HistoryAt string `json:"historyAt"`
}
// PlayletHistoryListResp 对应 swagger proto.PlayletHistoryListResp
type PlayletHistoryListResp struct {
List []PlayletHistoryData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// PlayletHistoryClearResp 对应 swagger proto.PlayletHistoryClearResp
type PlayletHistoryClearResp struct {
DeletedCount int `json:"deletedCount"`
}
// PlayletHistoryDeleteReq 对应 swagger proto.PlayletHistoryDeleteReq
type PlayletHistoryDeleteReq struct {
PlayletID int64 `json:"playletId"`
}
// PlayletHistoryDeleteResp 对应 swagger proto.PlayletHistoryDeleteResp
type PlayletHistoryDeleteResp struct {
DeletedCount int `json:"deletedCount"`
}
// HistoryListQuery 观看历史分页参数。
type HistoryListQuery struct {
PageNum int
PageSize int
}
// Normalize 校正分页默认值。
func (q *HistoryListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultHistoryPageSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
}
// FetchHistoryList 分页查询当前用户观看历史。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchHistoryList(c *gin.Context, q HistoryListQuery) (*PlayletHistoryListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletHistoryListResp]
if err := upstream.GetJSON(c, historyListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet history list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchHistoryListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchHistoryListOrEmpty(c *gin.Context, q HistoryListQuery) *PlayletHistoryListResp {
data, code, err := FetchHistoryList(c, q)
if err == nil {
if data == nil {
return &PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchHistoryList(c, q)
if err == nil {
if data == nil {
return &PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet history list failed", log.E(err))
return &PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
// HistoryClear 清空当前用户短剧观看历史。
// POST /api/app/playlet/history/clear,无请求体。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear
func HistoryClear(c *gin.Context) (*APIEnvelope[PlayletHistoryClearResp], error) {
var env APIEnvelope[PlayletHistoryClearResp]
if _, err := upstream.PostJSON(c, historyClearPath, nil, &env); err != nil {
return nil, err
}
return &env, nil
}
// HistoryDelete 删除单条短剧观看历史。
// POST /api/app/playlet/history/delete
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete
func HistoryDelete(c *gin.Context, req PlayletHistoryDeleteReq) (*APIEnvelope[PlayletHistoryDeleteResp], error) {
if req.PlayletID <= 0 {
return nil, fmt.Errorf("playlet history delete invalid playletId")
}
var env APIEnvelope[PlayletHistoryDeleteResp]
if _, err := upstream.PostJSON(c, historyDeletePath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-229
View File
@@ -1,229 +0,0 @@
package playletser
import (
"fmt"
"strconv"
"strings"
"time"
)
func historyPageURL(pageNum int) string {
if pageNum <= 1 {
return "/history"
}
return "/history/page/" + strconv.Itoa(pageNum)
}
func HistoryCanonicalPath(q HistoryListQuery) string {
q.Normalize()
return historyPageURL(q.PageNum)
}
// BuildHistoryPagination 观看历史分页。
func BuildHistoryPagination(q HistoryListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return historyPageURL(p)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseHistoryListQuery 从 query 解析分页。
func ParseHistoryListQuery(pageStr string) HistoryListQuery {
q := HistoryListQuery{PageSize: defaultHistoryPageSize}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
func historyFinished(item PlayletHistoryData) bool {
if item.IsFinished {
return true
}
return item.TotalEpisode > 0 && item.UnwatchedCount <= 0 && item.WatchedCount > 0
}
// historyStatus 卡片筛选状态:finished / unfinished。
func historyStatus(item PlayletHistoryData) string {
if historyFinished(item) {
return "finished"
}
return "unfinished"
}
// historyProgressText PC 卡片未看文案,使用接口 unwatchedCount / isFinished。
func historyProgressText(item PlayletHistoryData) string {
if historyFinished(item) {
return "已看完"
}
if item.UnwatchedCount > 0 {
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
}
if item.LastEpisode > 0 {
return fmt.Sprintf("看到第%d集", item.LastEpisode)
}
return "未看完"
}
// historyWatchLine H5 进度行。
func historyWatchLine(item PlayletHistoryData) string {
if historyFinished(item) {
if item.LastEpisode > 0 {
return fmt.Sprintf("第%d集 · 已看完", item.LastEpisode)
}
return "已看完"
}
ep := "未开始"
if item.LastEpisode > 0 {
ep = fmt.Sprintf("第%d集", item.LastEpisode)
}
if item.UnwatchedCount > 0 {
return ep + fmt.Sprintf(" · 还有%d集未看", item.UnwatchedCount)
}
return ep + " · 未看完"
}
// historyTagText 历史卡片标签:仅展示 TagNames 第一项,无标签时不显示。
func historyTagText(item PlayletHistoryData) string {
return firstName(item.TagNames)
}
// historyPlayURL 继续播放:优先最近观看剧集。
func historyPlayURL(item PlayletHistoryData) string {
return chapterPlayURL(item.Slug, item.ID, item.LastChapterID)
}
// historyContinueMeta 首页「继续观看」文案:看至第 N 集 / 剩 M 集。
func historyContinueMeta(item PlayletHistoryData) string {
if item.LastEpisode <= 0 {
return "观看记录"
}
meta := fmt.Sprintf("看至第 %d 集", item.LastEpisode)
if item.UnwatchedCount > 0 {
meta += fmt.Sprintf(" 剩 %d 集", item.UnwatchedCount)
}
return meta
}
// historyProgressPct 观看进度百分比,供卡片 --p 使用。
func historyProgressPct(item PlayletHistoryData) string {
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
return fmt.Sprintf("%d%%", progress)
}
// FormatHistoryAt 最近观看时间展示。
func FormatHistoryAt(raw string) string {
return formatHistoryAt(raw)
}
// FormatHistoryListTime 观看历史列表时间:今天/昨天带时刻,更早仅日期。
func FormatHistoryListTime(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
t, ok := parseHistoryTime(raw)
if !ok {
return raw
}
now := time.Now()
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
hm := t.Format("15:04")
switch {
case dayDiff <= 0:
return "今天 " + hm
case dayDiff == 1:
return "昨天 " + hm
default:
return fmt.Sprintf("%d 月 %d 日", int(t.Month()), t.Day())
}
}
// HistoryDayBucket 列表分组标题:今天 / 昨天 / 更早。
func HistoryDayBucket(raw string) string {
raw = strings.TrimSpace(raw)
t, ok := parseHistoryTime(raw)
if !ok {
return "更早"
}
now := time.Now()
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
switch {
case dayDiff <= 0:
return "今天"
case dayDiff == 1:
return "昨天"
default:
return "更早"
}
}
func formatHistoryAt(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
t, ok := parseHistoryTime(raw)
if !ok {
return raw
}
now := time.Now()
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
hm := t.Format("15:04")
switch {
case dayDiff <= 0:
return "今天 " + hm
case dayDiff == 1:
return "昨天 " + hm
case dayDiff == 2:
return "前天 " + hm
case dayDiff < 7:
return fmt.Sprintf("%d天前 %s", dayDiff, hm)
case dayDiff < 14:
return "一周前 " + hm
default:
return fmt.Sprintf("%d 月 %d 日", int(t.Month()), t.Day())
}
}
func parseHistoryTime(raw string) (time.Time, bool) {
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05.000",
"2006-01-02 15:04:05.999999999",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
return t, true
}
if t, err := time.Parse(layout, raw); err == nil {
return t.In(time.Local), true
}
}
return time.Time{}, false
}
// ParseHistoryTime 解析观看历史时间字符串。
func ParseHistoryTime(raw string) (time.Time, bool) {
return parseHistoryTime(strings.TrimSpace(raw))
}
-35
View File
@@ -1,35 +0,0 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const likeSetPath = "/api/app/playlet/like/set"
// PlayletLikeSetReq 对应 swagger proto.PlayletLikeSetReq
type PlayletLikeSetReq struct {
ID int64 `json:"id"`
IsLike bool `json:"isLike"`
}
// PlayletLikeSetResp 对应 swagger proto.PlayletLikeSetResp
type PlayletLikeSetResp struct {
ID int64 `json:"id"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// LikeSet 设置短剧点赞/取消点赞。
func LikeSet(c *gin.Context, req PlayletLikeSetReq) (*APIEnvelope[PlayletLikeSetResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("playlet like invalid id")
}
var env APIEnvelope[PlayletLikeSetResp]
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-19
View File
@@ -5,7 +5,6 @@ import (
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
@@ -46,19 +45,10 @@ func FetchSearch(c *gin.Context, q SearchQuery) (*PlayletSearchResp, error) {
if q.Keyword == "" {
return nil, fmt.Errorf("playlet search keyword is empty")
}
authser.EnsureGuestOnEnter(c)
env, err := fetchSearchOnce(c, q)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchSearchOnce(c, q)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet search code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
@@ -98,19 +88,10 @@ func FetchSearchSuggest(c *gin.Context, keyword string) (*PlayletSearchSuggestRe
if keyword == "" {
return nil, fmt.Errorf("playlet search suggest keyword is empty")
}
authser.EnsureGuestOnEnter(c)
env, err := fetchSearchSuggestOnce(c, keyword)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchSearchSuggestOnce(c, keyword)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet search suggest code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
+64 -77
View File
@@ -12,83 +12,70 @@ import (
// TemplateFuncs 模板辅助:仅做展示格式化 / URL,不把接口字段映射成另一套名字。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"detailURL": detailURL,
"playURL": playURL,
"chapterPlayURL": chapterPlayURL,
"categoryURL": categoryURL,
"categoryDetailURL": categoryDetailURL,
"themeListURL": themeListURLByKey,
"tagOptionsURL": tagListURLByKey,
"allListURL": AllListURL,
"moreListURL": MoreListURL,
"moreSeriesListURL": MoreSeriesListURL,
"navMegaIcon": NavMegaIcon,
"topicListURL": TopicListURL,
"topicDetailURL": TopicDetailURL,
"topicCoverURLs": TopicCoverURLs,
"tagPageURL": TagListURL,
"hotRankPageURL": HotRankPageURL,
"searchResultsURL": SearchResultsURL,
"coverURL": coverURL,
"formatHeat": formatHeat,
"formatCollect": formatCollectHeat,
"formatLikeCount": formatLikeCount,
"formatMediaTime": formatMediaTime,
"playletDisplayName": playletDisplayName,
"rankNo": RankNo,
"episodeLabel": episodeText,
"displayTag": displayTag,
"firstTagName": firstTagName,
"themeTagText": themeTagText,
"tagListURL": tagListURL,
"cardTag": cardTag,
"cardTags": cardTags,
"namedTags": namedTags,
"namedThemeTags": namedThemeTags,
"firstName": firstName,
"rankClass": rankClass,
"add": addInt,
"mod": modInt,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"footerLinkURL": footerLinkURL,
"footerLinkIsExternal": footerLinkIsExternal,
"externalLinkAttrs": externalLinkAttrs,
"footerContactURL": footerContactURL,
"footerContactName": footerContactName,
"footerContactValue": footerContactValue,
"navDisplayURL": navDisplayURL,
"urlquery": url.QueryEscape,
"pathescape": url.PathEscape,
"metaLine": metaLine,
"adSlice": AdSlice,
"cssCover": cssCover,
"cssCoverPlaceholder": cssCoverPlaceholder,
"updateStatusText": updateStatusText,
"browseText": browseText,
"usableAnchor": usableAnchor,
"playletYearText": playletYearText,
"playletDateText": playletDateText,
"isPlayletAllFree": isPlayletAllFree,
"collectProgressText": collectProgressText,
"collectTagText": collectTagText,
"chapterCollectEpisodeLabel": chapterCollectEpisodeLabel,
"chapterCollectDesc": chapterCollectDesc,
"chapterCollectMeta": chapterCollectMeta,
"historyProgressText": historyProgressText,
"historyWatchLine": historyWatchLine,
"historyTagText": historyTagText,
"historyPlayURL": historyPlayURL,
"historyContinueMeta": historyContinueMeta,
"historyProgressPct": historyProgressPct,
"historyStatus": historyStatus,
"formatHistoryAt": formatHistoryAt,
"formatShortDateTime": formatShortDateTime,
"absInt64": absInt64Tpl,
"absURL": absURLTpl,
"canonicalOr": canonicalOr,
"joinStrings": joinStrings,
"dict": dict,
"detailURL": detailURL,
"playURL": playURL,
"chapterPlayURL": chapterPlayURL,
"categoryURL": categoryURL,
"categoryDetailURL": categoryDetailURL,
"themeListURL": themeListURLByKey,
"tagOptionsURL": tagListURLByKey,
"allListURL": AllListURL,
"moreListURL": MoreListURL,
"moreSeriesListURL": MoreSeriesListURL,
"navMegaIcon": NavMegaIcon,
"topicListURL": TopicListURL,
"topicDetailURL": TopicDetailURL,
"topicCoverURLs": TopicCoverURLs,
"tagPageURL": TagListURL,
"hotRankPageURL": HotRankPageURL,
"searchResultsURL": SearchResultsURL,
"coverURL": coverURL,
"formatHeat": formatHeat,
"formatCollect": formatCollectHeat,
"formatLikeCount": formatLikeCount,
"formatMediaTime": formatMediaTime,
"playletDisplayName": playletDisplayName,
"rankNo": RankNo,
"episodeLabel": episodeText,
"displayTag": displayTag,
"firstTagName": firstTagName,
"themeTagText": themeTagText,
"tagListURL": tagListURL,
"cardTag": cardTag,
"cardTags": cardTags,
"namedTags": namedTags,
"namedThemeTags": namedThemeTags,
"firstName": firstName,
"rankClass": rankClass,
"add": addInt,
"mod": modInt,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"footerLinkURL": footerLinkURL,
"footerLinkIsExternal": footerLinkIsExternal,
"externalLinkAttrs": externalLinkAttrs,
"footerContactURL": footerContactURL,
"footerContactName": footerContactName,
"footerContactValue": footerContactValue,
"navDisplayURL": navDisplayURL,
"urlquery": url.QueryEscape,
"pathescape": url.PathEscape,
"metaLine": metaLine,
"adSlice": AdSlice,
"cssCover": cssCover,
"cssCoverPlaceholder": cssCoverPlaceholder,
"updateStatusText": updateStatusText,
"browseText": browseText,
"usableAnchor": usableAnchor,
"playletYearText": playletYearText,
"playletDateText": playletDateText,
"isPlayletAllFree": isPlayletAllFree,
"formatShortDateTime": formatShortDateTime,
"absInt64": absInt64Tpl,
"absURL": absURLTpl,
"canonicalOr": canonicalOr,
"joinStrings": joinStrings,
"dict": dict,
}
}
-281
View File
@@ -1,281 +0,0 @@
package rechargeser
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
packageListPath = "/api/app/recharge/package/list"
coinBuyPath = "/api/app/coin/buy"
vipPayPath = "/api/app/vip/pay"
vipOrdersPath = "/api/app/vip/orders"
coinOrdersPath = "/api/app/coin/orders"
coinTransPath = "/api/app/coin/trans/list"
coinTransExportPath = "/api/app/coin/trans/export"
)
// FetchPackageList 拉取购买套餐列表(会员 + 金币)。
func FetchPackageList(c *gin.Context) (*RechargePackageListResp, error) {
var env APIEnvelope[RechargePackageListResp]
if err := upstream.GetJSON(c, packageListPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("recharge package list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchPackageListOrEmpty 失败时返回空列表,保证 SSR 可渲染。
func FetchPackageListOrEmpty(c *gin.Context) *RechargePackageListResp {
data, err := FetchPackageList(c)
if err != nil {
log.Error("fetch recharge package list failed", log.E(err))
return &RechargePackageListResp{}
}
if data == nil {
return &RechargePackageListResp{}
}
return data
}
// BuyCoin 购买金币套餐:POST /api/app/coin/buy
func BuyCoin(c *gin.Context, req BuyPayReq) (*APIEnvelope[PayOrderResp], error) {
var env APIEnvelope[PayOrderResp]
if _, err := upstream.PostJSON(c, coinBuyPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// VipPay 购买会员套餐:POST /api/app/vip/pay
func VipPay(c *gin.Context, req BuyPayReq) (*APIEnvelope[PayOrderResp], error) {
var env APIEnvelope[PayOrderResp]
if _, err := upstream.PostJSON(c, vipPayPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// NormalizePayMode 规范化支付方式。
func NormalizePayMode(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
switch v {
case "wx", "weixin":
return "wechat"
case "unionpay":
return "union"
default:
return v
}
}
// FetchVipOrders 查询 VIP 充值记录。
func FetchVipOrders(c *gin.Context, page, pageSize int) ([]VipOrder, error) {
return fetchOrders(c, vipOrdersPath, page, pageSize)
}
// FetchCoinOrders 查询金币购买记录。
func FetchCoinOrders(c *gin.Context, page, pageSize int) ([]VipOrder, error) {
return fetchOrders(c, coinOrdersPath, page, pageSize)
}
// FetchRecentOrdersOrEmpty 合并 VIP + 金币订单,失败返回空。
func FetchRecentOrdersOrEmpty(c *gin.Context, limit int) []VipOrder {
if limit <= 0 {
limit = 20
}
pageSize := limit
vipOrders, err1 := FetchVipOrders(c, 1, pageSize)
coinOrders, err2 := FetchCoinOrders(c, 1, pageSize)
if err1 != nil {
log.Error("fetch vip orders failed", log.E(err1))
}
if err2 != nil {
log.Error("fetch coin orders failed", log.E(err2))
}
out := make([]VipOrder, 0, len(vipOrders)+len(coinOrders))
out = append(out, vipOrders...)
out = append(out, coinOrders...)
return out
}
// FetchCoinTransList 查询金币余额明细:GET /api/app/coin/trans/list
// 第二个返回值为上游业务 code(网络/解码失败时为 0)。
func FetchCoinTransList(c *gin.Context, page, pageSize int) ([]CoinTransListItem, int, error) {
data, code, err := FetchCoinTransPage(c, page, pageSize, 0)
if data == nil {
return nil, code, err
}
return data.List, code, err
}
// FetchCoinTransPage 查询金币余额明细并保留分页信息。
// typ: 0全部、1充值、2消费、3奖励。
func FetchCoinTransPage(c *gin.Context, page, pageSize, typ int) (*CoinTransPage, int, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
}
if typ < 0 || typ > 3 {
typ = 0
}
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("pageSize", strconv.Itoa(pageSize))
query.Set("type", strconv.Itoa(typ))
full := coinTransPath + "?" + query.Encode()
var env APIEnvelopeRaw
if err := upstream.GetJSON(c, full, &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("%s code=%d msg=%s", coinTransPath, env.Code, env.Msg)
}
return decodeCoinTransPage(env.Data, page, pageSize), env.Code, nil
}
// FetchCoinTransExport 导出金币流水 CSV:GET /api/app/coin/trans/export?type=
// typ: 0全部、1充值、2消费、3奖励。
func FetchCoinTransExport(c *gin.Context, typ int) (*upstream.BinaryResult, error) {
if typ < 0 || typ > 3 {
typ = 0
}
query := url.Values{}
query.Set("type", strconv.Itoa(typ))
full := coinTransExportPath + "?" + query.Encode()
result, err := upstream.GetBinary(c, full)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("%s empty response", coinTransExportPath)
}
if result.StatusCode < 200 || result.StatusCode >= 300 {
return result, fmt.Errorf("%s status=%d body=%s", coinTransExportPath, result.StatusCode, truncateBytes(result.Body, 200))
}
// 上游偶发返回 JSON 错误包装
ct := strings.ToLower(result.ContentType)
if strings.Contains(ct, "json") {
var env APIEnvelopeRaw
if json.Unmarshal(result.Body, &env) == nil && env.Code != 0 && env.Code != 200 {
return result, fmt.Errorf("%s code=%d msg=%s", coinTransExportPath, env.Code, env.Msg)
}
}
return result, nil
}
func truncateBytes(b []byte, n int) string {
s := string(b)
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// FetchCoinTransListOrEmpty 失败返回空列表;游客会话失效时会尝试重登后重试一次。
func FetchCoinTransListOrEmpty(c *gin.Context, page, pageSize int) []CoinTransListItem {
list, code, err := FetchCoinTransList(c, page, pageSize)
if err == nil {
return list
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = FetchCoinTransList(c, page, pageSize)
if err == nil {
return list
}
}
log.Error("fetch coin trans list failed", log.E(err))
return nil
}
func decodeCoinTransList(raw json.RawMessage) []CoinTransListItem {
return decodeCoinTransPage(raw, 1, 10).List
}
func decodeCoinTransPage(raw json.RawMessage, page, pageSize int) *CoinTransPage {
empty := &CoinTransPage{PageNum: page, PageSize: pageSize}
if len(raw) == 0 || string(raw) == "null" {
return empty
}
var arr []CoinTransListItem
if err := json.Unmarshal(raw, &arr); err == nil {
empty.List = arr
empty.TotalCount = int64(len(arr))
return empty
}
var obj CoinTransPage
if err := json.Unmarshal(raw, &obj); err != nil {
return empty
}
if obj.PageNum <= 0 {
obj.PageNum = page
}
if obj.PageSize <= 0 {
obj.PageSize = pageSize
}
return &obj
}
func fetchOrders(c *gin.Context, path string, page, pageSize int) ([]VipOrder, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
full := fmt.Sprintf("%s?page=%d&pageSize=%d", path, page, pageSize)
var env APIEnvelopeRaw
if err := upstream.GetJSON(c, full, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("%s code=%d msg=%s", path, env.Code, env.Msg)
}
return decodeOrderList(env.Data), nil
}
func decodeOrderList(raw json.RawMessage) []VipOrder {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var arr []VipOrder
if err := json.Unmarshal(raw, &arr); err == nil {
return arr
}
var obj struct {
List []VipOrder `json:"list"`
Orders []VipOrder `json:"orders"`
Records []VipOrder `json:"records"`
Items []VipOrder `json:"items"`
}
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
switch {
case len(obj.List) > 0:
return obj.List
case len(obj.Orders) > 0:
return obj.Orders
case len(obj.Records) > 0:
return obj.Records
case len(obj.Items) > 0:
return obj.Items
default:
return nil
}
}
-125
View File
@@ -1,125 +0,0 @@
package rechargeser
import "encoding/json"
// PackageType 对应 rechargepackagemod.PackageType
const (
PackageTypeVip = 1 // 会员畅享
PackageTypeCoin = 2 // 金币充值
)
// PayMethodInfo 对应 swagger proto.PayMethodInfo
type PayMethodInfo struct {
Type string `json:"type"` // alipay / wechat / union
TypeName string `json:"typeName"` // 支付宝 / 微信 / 银联
}
// RechargePackageItem 对应 swagger proto.RechargePackageItem
type RechargePackageItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
SubTitle string `json:"subTitle"`
PackageType int `json:"packageType"`
Price int64 `json:"price"` // 售价,分
OriginalPrice int64 `json:"originalPrice"` // 原价,分
BaseCoin int64 `json:"baseCoin"`
GiftCoin int64 `json:"giftCoin"`
TotalCoin int64 `json:"totalCoin"`
DurationDays int `json:"durationDays"`
DurationHours int `json:"durationHours"`
Sort int `json:"sort"`
PayMethods []PayMethodInfo `json:"payMethods"`
}
// RechargePackageListResp 对应 swagger proto.RechargePackageListResp
// GET /api/app/recharge/package/list
type RechargePackageListResp struct {
VipList []RechargePackageItem `json:"vipList"`
CoinList []RechargePackageItem `json:"coinList"`
}
// BuyPayReq 对应 coinapi.buyCoinReq / rechargeapi.submitOrderReq
type BuyPayReq struct {
PackageID int64 `json:"packageId"`
PayMode string `json:"payMode"` // alipay / wechat / union ...
}
// PayOrderResp 对应 coinser.BuyCoinResp / rechargeser.SubmitOrderResp
type PayOrderResp struct {
IsOpenNewBrowser bool `json:"isOpenNewBrowser"`
PayURL string `json:"payUrl"`
PayURLMode string `json:"payUrlMode"`
TradeNo string `json:"tradeNo"`
}
// VipOrder 对应 swagger vipordermod.VipOrder(会员/金币订单共用结构)
type VipOrder struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
OrderType int `json:"orderType"` // 1会员 2金币
Status int `json:"status"` // 1待支付 2成功 3失败
Amount int64 `json:"amount"` // 分
Coin int64 `json:"coin"`
CardID int64 `json:"cardId"`
CardName string `json:"cardName"`
CoinCfgID int64 `json:"coinCfgId"`
PayMode string `json:"payMode"`
TradeNo string `json:"tradeNo"`
CreatedAt string `json:"createdAt"`
FinishedAt string `json:"finishedAt"`
}
// CoinTransListItem 对应 swagger proto.CoinTransListItemResp
// GET /api/app/coin/trans/list
type CoinTransListItem struct {
ID int64 `json:"id"`
Title string `json:"title"`
Remark string `json:"remark"`
TranType int `json:"tranType"`
TranTypeName string `json:"tranTypeName"`
FlowDir int `json:"flowDir"` // 1收入 -1支出
FlowDirName string `json:"flowDirName"`
Amount int64 `json:"amount"`
ChangeAmount int64 `json:"changeAmount"`
BalanceBefore int64 `json:"balanceBefore"`
BalanceAfter int64 `json:"balanceAfter"`
OrderNo string `json:"orderNo"`
RelatedID int64 `json:"relatedId"`
CreatedAt string `json:"createdAt"`
}
// CoinTransPage 对应 response.BasePageResp 与金币流水列表组合。
type CoinTransPage struct {
List []CoinTransListItem `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
const (
OrderTypeVip = 1
OrderTypeCoin = 2
OrderStatusPending = 1
OrderStatusSuccess = 2
OrderStatusFailed = 3
FlowDirIn = 1
FlowDirOut = -1
)
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
// APIEnvelopeRaw Data 形态不固定时使用。
type APIEnvelopeRaw struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
-496
View File
@@ -1,496 +0,0 @@
package rechargeser
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// PayMethodVM 支付方式展示。
type PayMethodVM struct {
Type string
TypeName string
Active bool
}
// PackageCardVM 充值页套餐卡片(字段来自 package/list)。
type PackageCardVM struct {
ID int64
Name string
Hint string // 优先 subTitle
BaseCoinText string
GiftCoinText string
TotalCoinText string
PriceText string // 售价(元),不含 ¥
OriginalPriceText string // 原价(元),仅当原价>售价时有值
PriceFen int64
Active bool
PayMethods []PayMethodVM
PayMethodsJSON string // 供 data-pay-methods 使用
}
// DepositPageVM 充值页模板数据。
type DepositPageVM struct {
VipList []PackageCardVM
CoinList []PackageCardVM
PayMethods []PayMethodVM // 当前选中套餐的支付方式(接口 payMethods)
// TxList 交易明细:直接透传 /api/app/coin/trans/list 返回的字段
//(不再做 Name/TimeText/AmountText 的自定义字段映射)。
TxList []CoinTransListItem
}
// TxItemVM 交易明细行(历史遗留:旧逻辑会把 CoinTransListItem 映射到此结构)。
// 当前“交易明细调试”需求:DepositPageVM 直接透传 CoinTransListItem,
// 但旧函数可能仍被其它页面复用,因此保留结构体定义。
type TxItemVM struct {
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
Category string `json:"category"`
TimeText string `json:"time"`
MobileTime string `json:"mobileTime"`
AmountText string `json:"amount"`
AmountClass string `json:"amountClass"` // deposit-tx-amount--plus / --minus
Credit bool `json:"credit"`
StatusText string `json:"status"`
}
var defaultPayMethods = []PayMethodInfo{
{Type: "alipay", TypeName: "支付宝"},
{Type: "wechat", TypeName: "微信"},
{Type: "union", TypeName: "银联"},
}
// BuildDepositPageVM 按接口 vipList / coinList 组装展示数据(支付方式不造假)。
// preferTab: "coin" 优先选中金币套餐,其它/空则优先会员。
func BuildDepositPageVM(raw *RechargePackageListResp, preferTab string) DepositPageVM {
if raw == nil {
return DepositPageVM{}
}
vip := buildCards(raw.VipList, true)
coin := buildCards(raw.CoinList, false)
activatePreferredPackage(vip, coin, preferTab)
var activePays []PayMethodVM
for _, item := range coin {
if item.Active {
activePays = item.PayMethods
break
}
}
if activePays == nil {
for _, item := range vip {
if item.Active {
activePays = item.PayMethods
break
}
}
}
if len(activePays) > 0 {
activePays[0].Active = true
}
return DepositPageVM{
VipList: vip,
CoinList: coin,
PayMethods: activePays,
}
}
// activatePreferredPackage 默认选中:优先有支付方式的套餐,否则选第一项。
// preferTab=coin 时优先金币,否则优先会员。
func activatePreferredPackage(vip, coin []PackageCardVM, preferTab string) {
pick := func(list []PackageCardVM) bool {
for i := range list {
if len(list[i].PayMethods) > 0 {
list[i].Active = true
return true
}
}
if len(list) > 0 {
list[0].Active = true
return true
}
return false
}
preferCoin := strings.EqualFold(strings.TrimSpace(preferTab), "coin")
if preferCoin {
if pick(coin) {
return
}
pick(vip)
return
}
if pick(vip) {
return
}
pick(coin)
}
// AttachTxList 附加交易明细(金币流水)。
func (vm *DepositPageVM) AttachTxList(list []CoinTransListItem) {
if vm == nil {
return
}
vm.TxList = list
}
// BuildTxListFromCoinTrans 将金币流水转为展示列表。
func BuildTxListFromCoinTrans(list []CoinTransListItem, limit int) []TxItemVM {
items := append([]CoinTransListItem(nil), list...)
if limit > 0 && len(items) > limit {
items = items[:limit]
}
out := make([]TxItemVM, 0, len(items))
for _, item := range items {
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.TranTypeName)
}
if name == "" {
name = strings.TrimSpace(item.Remark)
}
if name == "" {
name = "金币变动"
}
change := item.ChangeAmount
if change == 0 {
change = item.Amount
if item.FlowDir == FlowDirOut {
change = -change
}
}
amountClass := "deposit-tx-amount--plus"
amountText := "+" + formatCount(absInt64(change)) + " 金币"
if change < 0 || item.FlowDir == FlowDirOut {
amountClass = "deposit-tx-amount--minus"
amountText = "-" + formatCount(absInt64(change)) + " 金币"
}
out = append(out, TxItemVM{
Name: name,
Kind: transactionKind(item),
Description: name,
Category: transactionCategory(item),
TimeText: formatTransTime(item.CreatedAt),
MobileTime: formatMobileTransTime(item.CreatedAt),
AmountText: amountText,
AmountClass: amountClass,
Credit: change >= 0 && item.FlowDir != FlowDirOut,
StatusText: strings.TrimSpace(item.FlowDirName),
})
}
return out
}
func transactionKind(item CoinTransListItem) string {
if name := strings.TrimSpace(item.TranTypeName); name != "" {
return name
}
if item.FlowDir == FlowDirOut {
return "消费"
}
return "收入"
}
func transactionCategory(item CoinTransListItem) string {
switch item.TranType {
case 1:
return "topup"
case 2:
return "reward"
case 4:
return "spend"
default:
if item.FlowDir == FlowDirOut {
return "spend"
}
return "reward"
}
}
func formatMobileTransTime(raw string) string {
formatted := formatTransTime(raw)
if len(formatted) >= 16 {
return formatted[5:16]
}
return formatted
}
func absInt64(n int64) int64 {
if n < 0 {
return -n
}
return n
}
func formatTransTime(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "0001") {
return ""
}
raw = strings.ReplaceAll(raw, "T", " ")
if i := strings.Index(raw, "."); i > 0 {
raw = raw[:i]
}
if i := strings.IndexAny(raw, "Z+"); i > 10 {
raw = strings.TrimSpace(raw[:i])
}
if len(raw) > 19 {
raw = raw[:19]
}
return raw
}
// BuildTxList 将订单转为展示列表(按时间倒序)。保留供订单场景复用。
func BuildTxList(orders []VipOrder, limit int) []TxItemVM {
items := append([]VipOrder(nil), orders...)
sort.SliceStable(items, func(i, j int) bool {
return orderTimeKey(items[i]) > orderTimeKey(items[j])
})
if limit > 0 && len(items) > limit {
items = items[:limit]
}
out := make([]TxItemVM, 0, len(items))
for _, o := range items {
name := strings.TrimSpace(o.CardName)
if name == "" {
if o.OrderType == OrderTypeCoin || o.CoinCfgID > 0 {
name = "金币充值"
} else {
name = "会员充值"
}
}
status := orderStatusText(o.Status)
if status != "" {
name = name + " · " + status
}
amountClass := "deposit-tx-amount--minus"
amountText := "-¥" + FormatYuan(o.Amount)
if o.OrderType == OrderTypeCoin || o.Coin > 0 {
if o.Status == OrderStatusSuccess && o.Coin > 0 {
amountClass = "deposit-tx-amount--plus"
amountText = "+" + formatCount(o.Coin) + " 金币"
}
}
out = append(out, TxItemVM{
Name: name,
TimeText: formatOrderTime(o),
AmountText: amountText,
AmountClass: amountClass,
StatusText: status,
})
}
return out
}
func orderTimeKey(o VipOrder) string {
if t := strings.TrimSpace(o.FinishedAt); t != "" && !strings.HasPrefix(t, "0001") {
return t
}
return strings.TrimSpace(o.CreatedAt)
}
func formatOrderTime(o VipOrder) string {
raw := orderTimeKey(o)
if raw == "" || strings.HasPrefix(raw, "0001") {
return ""
}
// 常见 RFC3339 / 带 T 的时间,截成可读
raw = strings.ReplaceAll(raw, "T", " ")
if i := strings.Index(raw, "."); i > 0 {
raw = raw[:i]
}
if i := strings.IndexAny(raw, "Z+"); i > 10 {
raw = strings.TrimSpace(raw[:i])
}
if len(raw) > 19 {
raw = raw[:19]
}
return raw
}
func orderStatusText(status int) string {
switch status {
case OrderStatusPending:
return "待支付"
case OrderStatusSuccess:
return "已完成"
case OrderStatusFailed:
return "失败"
default:
return ""
}
}
func buildCards(list []RechargePackageItem, isVIP bool) []PackageCardVM {
items := append([]RechargePackageItem(nil), list...)
// 仅按接口 sort 排序;sort 相同保持接口原顺序
sort.SliceStable(items, func(i, j int) bool {
return items[i].Sort < items[j].Sort
})
out := make([]PackageCardVM, 0, len(items))
for _, item := range items {
name := strings.TrimSpace(item.Name)
if name == "" {
if isVIP {
name = "会员套餐"
} else {
name = "金币套餐"
}
}
pays := buildPayMethodVMs(item.PayMethods, false)
card := PackageCardVM{
ID: item.ID,
Name: name,
Hint: packageHint(item, isVIP),
BaseCoinText: formatCount(item.BaseCoin),
GiftCoinText: formatCount(item.GiftCoin),
TotalCoinText: formatCount(packageTotalCoin(item)),
PriceText: FormatYuan(item.Price),
PriceFen: item.Price,
PayMethods: pays,
PayMethodsJSON: marshalPayMethodsJSON(pays),
}
if item.OriginalPrice > item.Price && item.OriginalPrice > 0 {
card.OriginalPriceText = FormatYuan(item.OriginalPrice)
}
out = append(out, card)
}
return out
}
func packageTotalCoin(item RechargePackageItem) int64 {
if item.TotalCoin > 0 {
return item.TotalCoin
}
return item.BaseCoin + item.GiftCoin
}
func buildPayMethodVMs(list []PayMethodInfo, withFallback bool) []PayMethodVM {
src := list
if len(src) == 0 && withFallback {
src = defaultPayMethods
}
out := make([]PayMethodVM, 0, len(src))
seen := map[string]struct{}{}
for _, item := range src {
typ := strings.ToLower(strings.TrimSpace(item.Type))
name := strings.TrimSpace(item.TypeName)
if typ == "" && name == "" {
continue
}
if typ == "" {
typ = strings.ToLower(name)
}
if name == "" {
name = defaultPayTypeName(typ)
}
if _, ok := seen[typ]; ok {
continue
}
seen[typ] = struct{}{}
out = append(out, PayMethodVM{Type: typ, TypeName: name})
}
if len(out) == 0 && withFallback {
return buildPayMethodVMs(defaultPayMethods, false)
}
return out
}
func defaultPayTypeName(typ string) string {
switch typ {
case "alipay":
return "支付宝"
case "wechat", "wx", "weixin":
return "微信"
case "union", "unionpay":
return "银联"
case "dcep", "ecny":
return "数字人民币"
case "usdt":
return "USDT"
default:
if typ == "" {
return "支付"
}
return typ
}
}
func marshalPayMethodsJSON(list []PayMethodVM) string {
type row struct {
Type string `json:"type"`
TypeName string `json:"typeName"`
}
rows := make([]row, 0, len(list))
for _, item := range list {
rows = append(rows, row{Type: item.Type, TypeName: item.TypeName})
}
b, err := json.Marshal(rows)
if err != nil || len(b) == 0 {
return "[]"
}
return string(b)
}
func packageHint(item RechargePackageItem, isVIP bool) string {
// 优先接口 subTitle
if s := strings.TrimSpace(item.SubTitle); s != "" {
return s
}
if isVIP {
if item.DurationDays > 0 {
return fmt.Sprintf("%d天会员", item.DurationDays)
}
if item.DurationHours > 0 {
return fmt.Sprintf("%d小时会员", item.DurationHours)
}
return ""
}
base := item.BaseCoin
gift := item.GiftCoin
total := item.TotalCoin
if total <= 0 {
total = base + gift
}
if gift > 0 && base > 0 {
return fmt.Sprintf("基础 %s + 赠送 %s", formatCount(base), formatCount(gift))
}
if total > 0 {
return formatCount(total) + " 金币"
}
return ""
}
// FormatYuan 分转元展示(整数不带小数)。
func FormatYuan(fen int64) string {
if fen < 0 {
fen = 0
}
if fen%100 == 0 {
return strconv.FormatInt(fen/100, 10)
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(fen)/100), "0"), ".")
}
func formatCount(n int64) string {
if n < 0 {
n = 0
}
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
return s
}
var b strings.Builder
rem := len(s) % 3
if rem == 0 {
rem = 3
}
b.WriteString(s[:rem])
for i := rem; i < len(s); i += 3 {
b.WriteByte(',')
b.WriteString(s[i : i+3])
}
return b.String()
}
+13
View File
@@ -2,6 +2,7 @@ package translationser
import (
"fmt"
"strings"
"sync"
"time"
"videoweb/app/service/upstream"
@@ -67,11 +68,23 @@ func filterHeaderCategories(info *AppTranslationInfo) []TranslationCategory {
if cat.Name == "" && cat.URLValue == "" {
continue
}
// 网页版已移除账号体系,后台若仍配置个人中心/已购入口则不展示。
if isAccountNavPath(cat.URLValue) {
continue
}
out = append(out, cat)
}
return out
}
func isAccountNavPath(raw string) bool {
switch strings.TrimRight(strings.TrimSpace(raw), "/") {
case "/user-center", "user-center", "/profile", "profile", "/purchased-series", "purchased-series", "/purchased", "purchased":
return true
}
return false
}
// DefaultCategoryList 接口失败时的兜底导航(字段与接口一致)。
func DefaultCategoryList() []TranslationCategory {
return []TranslationCategory{
-6
View File
@@ -290,10 +290,6 @@ func rewriteNavPath(path string) string {
return "/popular-recommend"
case "/tag", "/tag-list":
return "/tags"
case "/user-center":
return "/profile"
case "/purchased-series":
return "/purchased"
case "/search-results":
return "/search"
case "/more":
@@ -455,8 +451,6 @@ func navActiveKey(href, name string, download bool) string {
return "aiSeries"
case "/home-road":
return "homeRoad"
case "/profile":
return "userCenter"
case "/download-app":
return "downloadApp"
default:
-42
View File
@@ -1,42 +0,0 @@
package usercenterser
import (
"encoding/json"
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const centerDataPath = "/api/app/user/center/data"
// Data 对应 Swagger proto.UserCenterDataResp。
type Data struct {
WatchCount int64 `json:"watchCount"`
CollectCount int64 `json:"collectCount"`
ChasingCount int64 `json:"chasingCount"`
BoughtChapterCount int64 `json:"boughtChapterCount"`
}
// Fetch 获取当前用户的个人中心统计。code 为上游业务码,供会话失效重试。
func Fetch(c *gin.Context) (*Data, int, error) {
var env struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if err := upstream.GetJSON(c, centerDataPath, &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("user center data code=%d msg=%s", env.Code, env.Msg)
}
var data Data
if len(env.Data) == 0 || string(env.Data) == "null" {
return nil, env.Code, fmt.Errorf("user center data is empty")
}
if err := json.Unmarshal(env.Data, &data); err != nil {
return nil, env.Code, fmt.Errorf("decode user center data: %w", err)
}
return &data, env.Code, nil
}
-130
View File
@@ -1,130 +0,0 @@
package videoser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
collectSetPath = "/api/app/video/collect/set"
collectListPath = "/api/app/video/collect/list"
defaultCollectPageSize = 40
)
// VideoCollectSetReq 对应 swagger proto.VideoCollectSetReq。
// POST /api/app/video/collect/set
type VideoCollectSetReq struct {
ID int64 `json:"id"`
IsCollect bool `json:"isCollect"`
}
// VideoCollectSetResp 对应 swagger proto.VideoCollectSetResp。
type VideoCollectSetResp struct {
ID int64 `json:"id"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"` // 1-已收藏 2-未收藏
}
// VideoCollectData 对应 swagger proto.VideoCollectData。
// GET /api/app/video/collect/list
type VideoCollectData struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
CoverImg string `json:"coverImg"`
Keyworld []string `json:"keyworld"`
Tags []int64 `json:"tags"`
VideoTime int `json:"videoTime"`
WatchTimes int64 `json:"watchTimes"`
CountLike int64 `json:"countLike"`
CountCollect int64 `json:"countCollect"`
CountComment int64 `json:"countComment"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
}
// VideoCollectListResp 对应 swagger proto.VideoCollectListResp。
type VideoCollectListResp struct {
List []VideoCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// CollectListQuery 视频收藏列表分页。
type CollectListQuery struct {
PageNum int
PageSize int
}
// Normalize 校正分页默认值。
func (q *CollectListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCollectPageSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
}
// CollectSet 设置视频收藏/取消收藏。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set
func CollectSet(c *gin.Context, req VideoCollectSetReq) (*APIEnvelope[VideoCollectSetResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("video collect invalid id")
}
var env APIEnvelope[VideoCollectSetResp]
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchCollectList 拉取视频收藏列表:GET /api/app/video/collect/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list
func FetchCollectList(c *gin.Context, q CollectListQuery) (*VideoCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[VideoCollectListResp]
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("video collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchCollectListOrEmpty 失败返回空列表;游客会话失效时尝试重登后重试一次。
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *VideoCollectListResp {
data, code, err := FetchCollectList(c, q)
if err == nil {
if data == nil {
return &VideoCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchCollectList(c, q)
if err == nil {
if data == nil {
return &VideoCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch video collect list failed", log.E(err))
return &VideoCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
-141
View File
@@ -1,141 +0,0 @@
package videoser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
videoHistoryListPath = "/api/app/video/history/list"
videoHistoryClearPath = "/api/app/video/history/clear"
videoHistoryDeletePath = "/api/app/video/history/delete"
defaultHistoryPageSize = 40
)
// VideoHistoryData 对应 swagger proto.VideoHistoryData。
// GET /api/app/video/history/list
type VideoHistoryData struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
CoverImg string `json:"coverImg"`
Keyworld []string `json:"keyworld"`
Tags []int64 `json:"tags"`
VideoTime int `json:"videoTime"`
WatchTimes int64 `json:"watchTimes"`
CountLike int64 `json:"countLike"`
CountCollect int64 `json:"countCollect"`
CountComment int64 `json:"countComment"`
IsCollect int `json:"isCollect"`
HistoryAt string `json:"historyAt"`
}
// VideoHistoryListResp 对应 swagger proto.VideoHistoryListResp。
type VideoHistoryListResp struct {
List []VideoHistoryData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// VideoHistoryClearResp 对应 swagger proto.VideoHistoryClearResp。
type VideoHistoryClearResp struct {
DeletedCount int `json:"deletedCount"`
}
// VideoHistoryDeleteReq 对应 swagger proto.VideoHistoryDeleteReq。
type VideoHistoryDeleteReq struct {
VideoID int64 `json:"videoId"`
}
// VideoHistoryDeleteResp 对应 swagger proto.VideoHistoryDeleteResp。
type VideoHistoryDeleteResp struct {
DeletedCount int `json:"deletedCount"`
}
// HistoryListQuery 视频观看历史分页。
type HistoryListQuery struct {
PageNum int
PageSize int
}
// Normalize 校正分页默认值。
func (q *HistoryListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultHistoryPageSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
}
// FetchHistoryList 分页查询视频观看历史:GET /api/app/video/history/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list
func FetchHistoryList(c *gin.Context, q HistoryListQuery) (*VideoHistoryListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[VideoHistoryListResp]
if err := upstream.GetJSON(c, videoHistoryListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("video history list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchHistoryListOrEmpty 失败返回空列表;游客会话失效时尝试重登后重试一次。
func FetchHistoryListOrEmpty(c *gin.Context, q HistoryListQuery) *VideoHistoryListResp {
data, code, err := FetchHistoryList(c, q)
if err == nil {
if data == nil {
return &VideoHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchHistoryList(c, q)
if err == nil {
if data == nil {
return &VideoHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch video history list failed", log.E(err))
return &VideoHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
// HistoryClear 清空视频观看历史:POST /api/app/video/history/clear。
func HistoryClear(c *gin.Context) (*APIEnvelope[VideoHistoryClearResp], error) {
var env APIEnvelope[VideoHistoryClearResp]
if _, err := upstream.PostJSON(c, videoHistoryClearPath, nil, &env); err != nil {
return nil, err
}
return &env, nil
}
// HistoryDelete 删除单条视频观看历史:POST /api/app/video/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete
func HistoryDelete(c *gin.Context, req VideoHistoryDeleteReq) (*APIEnvelope[VideoHistoryDeleteResp], error) {
if req.VideoID <= 0 {
return nil, fmt.Errorf("video history delete invalid videoId")
}
var env APIEnvelope[VideoHistoryDeleteResp]
if _, err := upstream.PostJSON(c, videoHistoryDeletePath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-37
View File
@@ -1,37 +0,0 @@
package videoser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const likeSetPath = "/api/app/video/like/set"
// VideoLikeSetReq 对应 swagger proto.VideoLikeSetReq。
// POST /api/app/video/like/set
type VideoLikeSetReq struct {
ID int64 `json:"id"`
IsLike bool `json:"isLike"`
}
// VideoLikeSetResp 对应 swagger proto.VideoLikeSetResp。
type VideoLikeSetResp struct {
ID int64 `json:"id"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"` // 1-已点赞 2-未点赞
}
// LikeSet 设置视频点赞/取消点赞。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set
func LikeSet(c *gin.Context, req VideoLikeSetReq) (*APIEnvelope[VideoLikeSetResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("video like invalid id")
}
var env APIEnvelope[VideoLikeSetResp]
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
File diff suppressed because one or more lines are too long
-72
View File
@@ -1,72 +0,0 @@
/* 与「我的收藏」一致的账户列表空状态。 */
/* 空态时仍保持与个人中心同宽的主栏,避免筛选条贴到视口左缘。 */
.account-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
padding: 96px 16px;
font-size: 14px;
line-height: 22px;
text-align: center;
}
.account-empty[hidden] { display: none !important; }
.account-empty__icon {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
flex: 0 0 96px;
border: 1px solid var(--line);
border-radius: 50%;
background: #1b1b1f;
}
.account-empty__icon img { width: 40px; height: 40px; }
.account-empty__title {
margin: 0;
color: var(--text);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.account-empty__description {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.account-empty__description-mobile { display: none; }
.account-empty__action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 147px;
height: 48px;
border-radius: 999px;
background: linear-gradient(105deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
}
.account-empty__action img { width: 20px; height: 20px; }
.account-empty__action:focus-visible { outline: 2px solid var(--accent-hot); outline-offset: 3px; }
@media (max-width: 768px) {
.account-empty { padding: 48px 16px; }
.account-empty__description-desktop { display: none; }
.account-empty__description-mobile { display: inline; }
}
+32
View File
@@ -770,3 +770,35 @@ img {
white-space: nowrap !important;
border: 0 !important;
}
/* 卡片信息行:集数/状态前置为金色小标,标签随后(首页、列表、排行榜共用) */
.card__meta--ep {
display: flex;
align-items: center;
gap: 6px;
}
.card__meta-ep {
flex-shrink: 0;
padding: 0 6px;
border-radius: 4px;
color: var(--gold);
font-size: 12px;
line-height: 18px;
background: rgba(233, 201, 119, 0.12);
}
.card__meta-tags {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.card__meta-ep {
padding: 0 4px;
font-size: 10px;
line-height: 16px;
}
}
-266
View File
@@ -1,266 +0,0 @@
/* 我的收藏 H5:以 Figma 375px 画板为基准。 */
@media (max-width: 768px) {
.favorites-main {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: none;
min-width: 0;
margin: 0;
padding: 12px var(--profile-gutter, 16px) 20px;
}
.favorites-main.is-empty {
width: 100%;
gap: 0;
padding: 12px 0 20px;
}
.favorites-main.is-empty .favorites-toolbar {
width: 100%;
padding-inline: var(--profile-gutter, 16px);
}
.favorites-main.is-empty .favorites-recommendations {
width: auto;
margin-top: 8px;
padding: 0 var(--profile-gutter, 16px) 24px;
}
.favorites-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 30px;
}
.favorites-filters {
display: flex;
flex: 1 1 auto;
flex-wrap: nowrap;
align-items: center;
gap: clamp(6px, 2.666667vw, 10px);
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.favorites-filters::-webkit-scrollbar {
display: none;
}
.favorites-filter {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 clamp(11px, 4.266667vw, 16px);
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 999px;
background: transparent;
color: #a1a1aa;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.favorites-filter.is-active {
border-color: rgba(236, 35, 127, .3);
background: rgba(236, 35, 127, .1);
color: #ff4a7b;
}
.favorites-manage {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 30px;
padding: 0;
border: 0;
background: none;
color: #a1a1aa;
font-size: 12px;
font-weight: 400;
line-height: 18px;
white-space: nowrap;
}
.favorites-manage svg,
.favorites-manage img {
display: none;
}
.favorites-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px var(--profile-card-gap, 8px);
width: 100%;
min-width: 0;
}
.favorites-card {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.favorites-card .profile-card__cover {
width: 100%;
aspect-ratio: 109 / 145;
border-radius: 8px;
}
.favorites-card .profile-card__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorites-card .profile-card__meta {
overflow: hidden;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorites-card__desktop-meta {
display: none;
}
.favorites-card__mobile-meta {
display: inline;
}
.favorites-card__remove {
display: none;
}
.favorites-page.is-managing .favorites-card__remove {
position: absolute;
top: 6px;
left: 6px;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid rgba(255, 255, 255, .5);
border-radius: 50%;
background: rgba(11, 11, 13, .8);
color: #f2f2f4;
}
.favorites-card .profile-card__episodes {
bottom: 10px;
left: 10px;
font-size: 11px;
line-height: 14px;
}
.favorites-card .profile-card__badge {
top: 4px;
right: 4px;
min-width: 30px;
height: 20px;
padding: 0 5px;
}
.favorites-load-more {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 40px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, .24);
border-radius: 999px;
background: transparent;
color: #f2f2f4;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.favorites-pagination {
display: none;
}
.favorites-empty {
gap: 16px;
padding: 48px 16px;
}
.favorites-empty__description-desktop,
.favorites-recommendations__more-desktop {
display: none;
}
.favorites-empty__description-mobile,
.favorites-recommendations__more-mobile {
display: inline;
}
.favorites-recommendations {
width: 100%;
padding: 0 var(--profile-gutter, 16px) 20px;
}
.favorites-recommendations__head {
height: 26px;
margin-bottom: 14px;
}
.favorites-recommendations__head h2 {
font-size: 18px;
line-height: 26px;
}
.favorites-recommendations__head a {
font-size: 12px;
line-height: 18px;
}
.favorites-recommendations__head a img {
width: 14px;
height: 14px;
}
.favorites-recommendations__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px var(--profile-card-gap, 8px);
}
.favorites-card[hidden],
.favorites-empty[hidden],
.favorites-load-more[hidden] {
display: none !important;
}
}
@media (max-width: 359px) {
.favorites-filters {
flex-wrap: nowrap;
gap: 6px;
}
.favorites-filter {
padding-inline: 8px;
font-size: 12px;
}
}
-352
View File
@@ -1,352 +0,0 @@
/* 我的收藏:1440px 桌面稿,公共个人页头与封面卡复用 profile.css。 */
.favorites-page [hidden] {
display: none !important;
}
.favorites-main {
display: flex;
flex-direction: column;
gap: 24px;
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
padding: 24px 0 40px;
}
.favorites-main.is-empty {
width: 100%;
max-width: none;
gap: 24px;
margin: 0;
padding: 24px 0 40px;
}
.favorites-main.is-empty .favorites-toolbar {
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
}
.favorites-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
}
.favorites-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
}
.favorites-filter,
.favorites-manage {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 36px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.favorites-filter {
padding-inline: 16px;
color: var(--text-muted);
}
.favorites-filter.is-active {
border-color: rgba(236, 35, 127, .3);
background: rgba(236, 35, 127, .1);
color: var(--accent-hot);
}
.favorites-manage {
gap: 6px;
padding-inline: 12px;
border-color: rgba(255, 255, 255, .24);
color: var(--text);
cursor: pointer;
}
.favorites-manage img,
.favorites-manage svg {
display: block;
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.favorites-filter:hover,
.favorites-manage:hover {
border-color: rgba(255, 255, 255, .36);
color: var(--text);
}
.favorites-filter:focus-visible,
.favorites-manage:focus-visible,
.favorites-pagination button:focus-visible {
outline: 2px solid var(--accent-hot);
outline-offset: 3px;
}
.favorites-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 32px 24px;
min-width: 0;
}
.favorites-grid .favorites-card {
min-width: 0;
}
.favorites-card__mobile-meta,
.favorites-card__remove {
display: none;
}
.favorites-card__media {
position: relative;
min-width: 0;
}
.favorites-page.is-managing .favorites-card__remove {
position: absolute;
top: 6px;
left: 6px;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid rgba(255, 255, 255, .4);
border-radius: 50%;
background: rgba(11, 11, 13, .78);
color: #fff;
font-size: 18px;
line-height: 1;
}
.favorites-card__remove img {
display: block;
width: 14px;
height: 14px;
}
.favorites-page.is-managing .favorites-manage {
border-color: rgba(236, 35, 127, .3);
background: rgba(236, 35, 127, .1);
color: var(--accent-hot);
}
.favorites-load-more {
display: none;
}
.favorites-pagination,
.favorites-pagination__pages {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.favorites-pagination {
min-height: 36px;
}
.favorites-pagination button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 36px;
width: 36px;
height: 36px;
border: 1px solid var(--line-soft);
border-radius: 8px;
background: var(--bg-elevated);
color: var(--text-muted);
font-family: Inter, var(--font);
font-size: 16px;
font-weight: 600;
line-height: 20px;
}
.favorites-pagination button.is-active {
border-color: transparent;
background: linear-gradient(135deg, #ff4a7b 15%, #ec237f 51%, #93226f 85%);
color: #fff;
}
.favorites-pagination button:not(:disabled):not(.is-active):hover {
border-color: rgba(255, 255, 255, .24);
color: var(--text);
}
.favorites-pagination button:disabled {
cursor: default;
opacity: .45;
}
.favorites-pagination button img,
.favorites-pagination button svg {
display: block;
width: 16px;
height: 16px;
}
.favorites-pagination > .favorites-pagination__button:first-child img {
transform: rotate(180deg);
}
.favorites-pagination__total {
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.favorites-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
padding: 96px 16px;
font-size: 14px;
line-height: 22px;
text-align: center;
}
.favorites-main.is-empty:has(.favorites-recommendations) .favorites-empty {
padding-bottom: 40px;
}
.favorites-main.is-empty .favorites-recommendations {
padding-top: 8px;
}
.favorites-filter-empty {
margin: 0;
padding: 56px 16px;
color: var(--text-dim);
font-size: 14px;
line-height: 22px;
text-align: center;
}
.favorites-empty__icon {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
flex: 0 0 96px;
border: 1px solid var(--line);
border-radius: 50%;
background: #1b1b1f;
}
.favorites-empty__icon img {
width: 40px;
height: 40px;
}
.favorites-empty__title {
margin: 0;
color: var(--text);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.favorites-empty__description {
margin: 0;
color: var(--text-muted);
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
.favorites-empty__description-mobile,
.favorites-recommendations__more-mobile {
display: none;
}
.favorites-empty__action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 147px;
height: 48px;
border-radius: 999px;
background: linear-gradient(105deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
}
.favorites-empty__action img {
width: 20px;
height: 20px;
}
.favorites-empty__action:focus-visible,
.favorites-recommendations__head a:focus-visible {
outline: 2px solid var(--accent-hot);
outline-offset: 3px;
}
.favorites-recommendations {
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
padding-bottom: 40px;
}
.favorites-recommendations__head {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
margin-bottom: 18px;
}
.favorites-recommendations__head h2 {
margin: 0;
color: var(--text);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.favorites-recommendations__head a {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.favorites-recommendations__head a img {
width: 16px;
height: 16px;
}
.favorites-recommendations__grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 24px;
}
+96
View File
@@ -255,3 +255,99 @@
text-align: center;
}
}
/* 访问地址:横向地址卡片,窄屏改为纵向堆叠 */
.footer-addrs {
display: flex;
flex-direction: column;
gap: 14px;
}
.footer-addrs__head {
display: flex;
align-items: baseline;
gap: 12px;
}
.footer-addrs__head h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.footer-addrs__head p {
margin: 0;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
}
.footer-addrs__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
}
.footer-addr {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px 14px;
border: 1px solid var(--line-soft);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--bg-panel);
text-decoration: none;
}
.footer-addr:hover {
border-color: var(--line);
border-left-color: var(--accent-hot);
background: rgba(236, 35, 127, 0.08);
}
.footer-addr__label {
flex-shrink: 0;
color: var(--text-muted);
font-size: 12px;
line-height: 18px;
}
.footer-addr__url {
flex: 1;
min-width: 0;
color: var(--text);
font-size: 13px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-addr__go {
flex-shrink: 0;
opacity: 0.6;
}
@media (max-width: 768px) {
.footer-addrs {
width: 100%;
gap: 10px;
text-align: left;
}
.footer-addrs__head {
justify-content: space-between;
}
.footer-addrs__list {
grid-template-columns: 1fr;
gap: 8px;
}
.footer-addr {
padding: 9px 12px;
}
}
+10
View File
@@ -1194,3 +1194,13 @@
body.drawer-open {
overflow: hidden;
}
/* 手机页头:商务合作 Telegram 入口,与搜索按钮同尺寸,带品牌色底 */
.site-header__tg {
background: rgba(42, 171, 238, 0.14);
}
.site-header__tg .icon {
width: 18px;
height: 18px;
}
-256
View File
@@ -1,256 +0,0 @@
/* 观看历史 H5:对齐 Figma 375px 画板。 */
@media (max-width: 768px) {
.history-main {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: none;
margin: 0;
padding: 12px 16px 20px;
}
.history-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
min-height: 30px;
}
.history-filters {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 10px;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.history-filters::-webkit-scrollbar { display: none; }
.history-filter {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 999px;
background: transparent;
color: #a1a1aa;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
cursor: pointer;
}
.history-filter.is-active {
border-color: rgba(236, 35, 127, .3);
background: rgba(236, 35, 127, .1);
color: #ff4a7b;
}
.history-clear {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: auto;
padding: 0;
border: 0;
background: transparent;
color: #a1a1aa;
font-size: 12px;
font-weight: 400;
line-height: 18px;
white-space: nowrap;
cursor: pointer;
}
.history-clear img,
.history-clear__desktop { display: none; }
.history-clear__mobile { display: inline; }
.history-group,
.history-list {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.history-group__title {
margin: 0;
color: #7c7c86;
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
.history-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, .07);
}
.history-item__cover {
display: block;
flex: 0 0 48px;
width: 48px;
height: 64px;
overflow: hidden;
border-radius: 6px;
background: #141417;
}
.history-item__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.history-item__body {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: flex-start;
gap: 5px;
min-width: 0;
}
.history-item__title {
display: block;
align-self: stretch;
overflow: hidden;
margin: 0;
color: #f2f2f4;
font-size: 15px;
font-weight: 600;
line-height: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__title a {
color: inherit;
text-decoration: none;
}
.history-item__details {
display: block;
align-self: stretch;
overflow: hidden;
color: #7c7c86;
font-size: 11px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__episodes,
.history-item__mobile-time {
display: inline;
}
.history-item__mobile-time::before {
content: " ";
white-space: pre;
}
.history-item__genre,
.history-item__time,
.history-item__resume-desktop {
display: none;
}
.history-item__progress {
display: block;
width: 140px;
max-width: 100%;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, .12);
}
.history-item__progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.history-item__resume {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 8px;
width: auto;
min-width: 0;
height: 32px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, .24);
border-radius: 999px;
background: transparent;
color: #f2f2f4;
font-size: 14px;
font-weight: 500;
line-height: 20px;
text-decoration: none;
white-space: nowrap;
}
.history-item__resume svg,
.history-item__resume img {
flex: 0 0 16px;
width: 16px;
height: 16px;
}
.history-item__resume-mobile {
display: inline;
}
.history-item__remove {
display: inline-flex;
flex: 0 0 14px;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: 0;
background: transparent;
color: #7c7c86;
cursor: pointer;
}
.history-item__remove svg,
.history-item__remove img {
display: block;
width: 14px;
height: 14px;
}
.history-main .account-pagination {
margin-top: 4px;
}
.history-empty,
.history-main .account-empty {
padding: 48px 0;
}
}
-256
View File
@@ -1,256 +0,0 @@
/* 观看历史:1440px Figma 桌面画板,沿用个人中心公共页头与页签。 */
.history-page [hidden] { display: none !important; }
.history-main {
display: flex;
flex-direction: column;
gap: 24px;
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
padding: 24px 0 40px;
}
.history-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
}
.history-filters {
display: flex;
align-items: center;
gap: 10px;
}
.history-filter,
.history-clear,
.history-item__resume {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 36px;
padding: 0;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
cursor: pointer;
}
.history-filter {
padding-inline: 16px;
}
.history-filter.is-active {
border-color: rgba(236, 35, 127, .3);
background: rgba(236, 35, 127, .1);
color: var(--accent-hot);
}
.history-clear {
gap: 6px;
padding-inline: 12px;
border-color: rgba(255, 255, 255, .24);
color: var(--text);
}
.history-clear__mobile { display: none; }
.history-clear svg,
.history-clear img,
.history-item__resume svg,
.history-item__resume img,
.history-item__remove svg,
.history-item__remove img {
display: block;
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.history-filter:hover,
.history-clear:hover,
.history-item__resume:hover,
.history-item__remove:hover {
border-color: rgba(255, 255, 255, .36);
color: var(--text);
}
.history-filter:focus-visible,
.history-clear:focus-visible,
.history-item__resume:focus-visible,
.history-item__remove:focus-visible {
outline: 2px solid var(--accent-hot);
outline-offset: 3px;
}
.history-group {
min-width: 0;
}
.history-group__title {
margin: 0;
color: var(--text-dim);
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
.history-list {
margin: 0;
padding: 0;
list-style: none;
}
.history-item {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 120px 106px 16px;
align-items: center;
gap: 16px;
min-width: 0;
min-height: 104px;
padding-block: 14px;
border-bottom: 1px solid var(--line-soft);
}
.history-item__cover {
display: block;
width: 56px;
height: 75px;
overflow: hidden;
border-radius: 6px;
background: var(--bg-panel);
}
.history-item__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.history-item__body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.history-item__title {
display: block;
max-width: 100%;
margin: 0;
overflow: hidden;
color: var(--text);
font-size: 16px;
font-weight: 600;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__title a {
color: inherit;
text-decoration: none;
}
.history-item__title a:hover {
color: var(--accent-hot);
}
.history-item__details {
display: flex;
align-items: center;
gap: 8px;
max-width: 100%;
overflow: hidden;
color: #a1a1aa;
font-size: 13px;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
}
.history-item__episodes,
.history-item__genre {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item__episodes {
flex: 0 0 auto;
}
.history-item__progress {
display: block;
width: 240px;
max-width: 100%;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: var(--line);
}
.history-item__progress-fill {
display: block;
width: var(--progress, 0%);
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.history-item__time {
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.history-item__mobile-time,
.history-item__resume-mobile {
display: none;
}
.history-item__resume {
gap: 8px;
width: 106px;
padding-inline: 12px;
border-color: rgba(255, 255, 255, .24);
color: var(--text);
text-decoration: none;
}
.history-item__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
}
.history-group + .history-group {
margin-top: 0;
}
.history-main.is-empty {
gap: 24px;
padding: 24px 0 40px;
}
.history-main.is-empty .account-empty {
padding-block: 72px;
}
+75
View File
@@ -1364,3 +1364,78 @@
width: 100%;
}
}
/* 公告跑马灯:官方访问地址以金色描边胶囊展示 */
.announce__addrs {
display: inline-flex;
align-items: center;
gap: 8px;
}
.announce__addrs > span {
color: var(--gold);
}
.announce__addr {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border: 1px solid rgba(233, 201, 119, 0.45);
border-radius: 999px;
color: var(--text);
font-size: 12px;
line-height: 20px;
text-decoration: none;
background: rgba(233, 201, 119, 0.08);
}
.announce__addr:hover {
border-color: var(--gold);
background: rgba(233, 201, 119, 0.16);
}
@media (max-width: 768px) {
.announce__addr {
height: 20px;
padding: 0 8px;
font-size: 11px;
line-height: 18px;
}
}
/* Banner 可读性(桌面端):背景海报保持原样,只在左侧文字区域加一道渐变 */
@media (min-width: 769px) {
.hero__shade--side {
background:
linear-gradient(90deg, #0b0b0d 0%, rgba(11, 11, 13, 0.78) 16%, rgba(11, 11, 13, 0.45) 36%, rgba(11, 11, 13, 0) 52%),
linear-gradient(270deg, #0b0b0d 0%, rgba(11, 11, 13, 0.72) 8%, rgba(11, 11, 13, 0) 24%);
}
.hero__title,
.hero__desc {
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
}
.hero__attrs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 12px;
}
@media (max-width: 768px) {
/* 手机端背景即主画面,不模糊;拉高底部渐变保证文字可读 */
.hero__shade--bottom {
background: linear-gradient(180deg, rgba(11, 11, 13, 0) 0%, rgba(11, 11, 13, 0.25) 28%, rgba(11, 11, 13, 0.82) 56%, #0b0b0d 78%);
}
.hero__desc {
-webkit-line-clamp: 2;
}
.hero__attrs {
gap: 4px 10px;
}
}
-283
View File
@@ -1,283 +0,0 @@
/* H5 登录页 */
body.page-login {
min-height: 100vh;
background: var(--bg);
}
.page-login .page-subheader {
display: flex;
border-bottom: 0;
}
.login-page {
min-height: 100vh;
padding: 56px 0 32px;
box-sizing: border-box;
}
.login-page__inner {
width: 100%;
max-width: 440px;
margin: 0 auto;
padding: 24px var(--h5-gutter);
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
box-sizing: border-box;
}
.login-page__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.login-page__logo {
width: 64px;
height: 64px;
border-radius: 12px;
object-fit: cover;
box-shadow: 0 8px 24px rgba(236, 35, 127, 0.4);
}
.login-page__content {
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 18px;
}
.login-page__intro {
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.login-page__title {
margin: 0;
color: var(--text);
font-size: 28px;
font-weight: 600;
line-height: 38px;
}
.login-page__desc {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.login-page__form {
align-self: stretch;
display: flex;
flex-direction: column;
gap: 16px;
}
.login-page__field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.login-page__label {
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.login-page__control {
align-self: stretch;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid transparent;
border-radius: 12px;
outline: 1px solid transparent;
outline-offset: -1px;
}
.login-page__control:focus-within {
outline-color: rgba(236, 35, 127, 0.45);
}
.login-page__field.is-error .login-page__control {
outline-color: #ff5a5f;
}
.login-page__error {
display: inline-flex;
align-items: center;
gap: 6px;
color: #ff5a5f;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.login-page__error[hidden] {
display: none !important;
}
.login-page__error-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.login-page__control > .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.login-page__input {
flex: 1 1 0;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: 15px;
line-height: 24px;
}
.login-page__input::placeholder {
color: var(--text-dim);
}
.login-page__toggle {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.login-page__toggle .icon {
width: 18px;
height: 18px;
}
.login-page__meta {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-page__remember {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.login-page__checkbox {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.login-page__check {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--line);
}
.login-page__checkbox:checked + .login-page__check {
border-color: transparent;
background: linear-gradient(135deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.login-page__checkbox:checked + .login-page__check::after {
content: "";
width: 10px;
height: 6px;
border-left: 1.5px solid #fff;
border-bottom: 1.5px solid #fff;
transform: translateY(-1px) rotate(-45deg);
}
.login-page__forgot {
color: #ff4a7b;
font-size: 13px;
line-height: 20px;
}
.login-page__submit {
align-self: stretch;
height: 52px;
padding: 0 28px;
font-size: 16px;
line-height: 24px;
}
.login-page__switch {
margin: 0;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.login-page__switch a {
color: #ff4a7b;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.login-page__rule {
align-self: stretch;
height: 1px;
background: var(--line-soft);
}
.login-page__legal {
margin: 0;
align-self: stretch;
text-align: center;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
}
.login-page__legal a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
-308
View File
@@ -1,308 +0,0 @@
/* PC 登录弹窗 */
.login-pop {
position: fixed;
inset: 0;
z-index: 290;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-pop.is-open {
display: flex;
}
.login-pop__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
}
.login-pop__panel {
position: relative;
z-index: 1;
width: 440px;
max-width: 100%;
padding: 32px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
background: #141417;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.login-pop__close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
}
.login-pop__close .icon {
width: 18px;
height: 18px;
}
.login-pop__intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding-right: 40px;
}
.login-pop__title {
margin: 0;
color: var(--text);
font-size: 28px;
font-weight: 600;
line-height: 38px;
}
.login-pop__desc {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.login-pop__form {
align-self: stretch;
display: flex;
flex-direction: column;
gap: 16px;
}
.login-pop__field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.login-pop__label {
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.login-pop__control {
align-self: stretch;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid transparent;
border-radius: 12px;
outline: 1px solid transparent;
outline-offset: -1px;
}
.login-pop__control:focus-within {
outline-color: rgba(236, 35, 127, 0.45);
}
.login-pop__field.is-error .login-pop__control {
outline-color: #ff5a5f;
}
.login-pop__error {
display: inline-flex;
align-items: center;
gap: 6px;
color: #ff5a5f;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.login-pop__error[hidden] {
display: none !important;
}
.login-pop__error-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.login-pop__control > .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-dim);
}
.login-pop__input {
flex: 1 1 0;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: 15px;
line-height: 24px;
}
.login-pop__input::placeholder {
color: var(--text-dim);
}
.login-pop__toggle {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.login-pop__toggle .icon {
width: 18px;
height: 18px;
}
.login-pop__meta {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-pop__remember {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.login-pop__checkbox {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.login-pop__check {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--line);
}
.login-pop__checkbox:checked + .login-pop__check {
border-color: transparent;
background: linear-gradient(135deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.login-pop__checkbox:checked + .login-pop__check::after {
content: "";
width: 10px;
height: 6px;
border-left: 1.5px solid #fff;
border-bottom: 1.5px solid #fff;
transform: translateY(-1px) rotate(-45deg);
}
.login-pop__submit {
align-self: stretch;
height: 52px;
padding: 0 28px;
font-size: 16px;
line-height: 24px;
}
.login-pop__submit.is-loading,
.login-pop__submit:disabled {
opacity: 0.72;
cursor: wait;
}
.login-pop__tip {
margin: 0;
align-self: stretch;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 20px;
background: rgba(255, 74, 123, 0.1);
color: #ff4a7b;
}
.login-pop__tip.is-success {
background: rgba(30, 120, 70, 0.12);
color: #1e7846;
}
.login-pop__tip[hidden] {
display: none !important;
}
.login-pop__rule {
align-self: stretch;
height: 1px;
background: var(--line-soft);
}
.login-pop__legal {
margin: 0;
align-self: stretch;
text-align: center;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
}
.login-pop__legal a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
@media (max-width: 768px) {
.login-pop {
padding: 16px;
}
.login-pop__panel {
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
padding: 24px 20px;
border-radius: 18px;
overflow-y: auto;
}
.login-pop__title {
font-size: 24px;
line-height: 32px;
}
}
+44 -4
View File
@@ -980,10 +980,8 @@ a.play-meta__tag:focus-visible {
width: 12px;
height: 12px;
border-radius: 999px;
background:
url("/static/images/coin.png") center / 8px 8px no-repeat,
linear-gradient(135deg, #e9c977 0%, #d9a94a 100%);
border: 1px solid #e9c977;
background: url("/static/images/lock.svg") center / 8px 8px no-repeat, var(--accent-deep);
border: 1px solid var(--accent);
box-sizing: border-box;
}
@@ -1817,3 +1815,45 @@ a.play-meta__tag:focus-visible {
line-height: 18px;
}
}
/* 简介下方标签行 */
.play-synopsis__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.play-synopsis__tag {
padding: 3px 10px;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
line-height: 18px;
text-decoration: none;
background: rgba(255, 255, 255, 0.06);
}
.play-synopsis__tag::before {
content: "#";
margin-right: 2px;
color: var(--accent);
}
a.play-synopsis__tag:hover {
color: var(--text);
background: rgba(236, 35, 127, 0.12);
}
@media (max-width: 768px) {
.play-synopsis__tags {
gap: 6px;
margin-top: 10px;
}
.play-synopsis__tag {
padding: 2px 8px;
font-size: 11px;
line-height: 16px;
}
}
-590
View File
@@ -1,590 +0,0 @@
/* 个人中心 H5:以 Figma 375px 画板为基准。 */
@media (max-width: 768px) {
.profile-page {
--profile-gutter: clamp(14px, 4.266667vw, 18px);
--profile-card-gap: clamp(7px, 2.133333vw, 9px);
width: 100%;
min-width: 0;
padding-top: 0;
background: #0b0b0d;
}
.profile-hero {
width: 100%;
padding-top: 56px; /* 公共导航固定在视口顶部 */
background: linear-gradient(180deg, #2a0d22 0%, #150a17 60%, #0b0b0d 100%);
}
.profile-hero::before {
top: -90px;
left: -60px;
width: 360px;
height: 220px;
filter: blur(55px);
opacity: 0.22;
}
.profile-hero__inner {
display: block;
grid-template-columns: none;
width: 100%;
max-width: none;
margin: 0;
padding: 0;
}
.profile-identity {
display: flex;
align-items: center;
gap: clamp(12px, 3.733333vw, 16px);
min-width: 0;
margin: 0;
padding: 12px var(--profile-gutter) 16px;
}
.profile-identity__avatar {
display: block;
flex: 0 0 clamp(55px, 17.066667vw, 73px);
width: clamp(55px, 17.066667vw, 73px);
height: clamp(55px, 17.066667vw, 73px);
border: 2px solid rgba(255, 255, 255, 0.24);
border-radius: 50%;
object-fit: cover;
}
.profile-identity__copy {
display: grid;
flex: 1 1 0;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: 26px 16px;
column-gap: 12px;
row-gap: 6px;
min-width: 0;
}
.profile-identity__name {
grid-column: 1;
grid-row: 1;
overflow: hidden;
color: #f2f2f4;
font-size: 18px;
font-weight: 600;
line-height: 26px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-identity__meta {
grid-column: 1;
grid-row: 2;
overflow: hidden;
color: #a1a1aa;
font-size: 11px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-identity__edit {
display: inline-flex;
grid-column: 2;
grid-row: 1 / 3;
align-self: center;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 8px;
height: 32px;
padding: 0 8px;
color: #f2f2f4;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
font-size: 0;
font-weight: 500;
line-height: 0;
white-space: nowrap;
}
.profile-identity__edit::after {
content: "编辑";
font-size: 14px;
line-height: 20px;
}
.profile-identity__edit .icon,
.profile-identity__edit img,
.profile-identity__edit svg {
width: 16px;
height: 16px;
}
.profile-wallet {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 101px;
margin: 0 var(--profile-gutter) 16px;
padding: 14px;
background: #141417;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
}
.profile-wallet__label {
display: flex;
align-items: center;
gap: 6px;
color: #f2f2f4;
font-size: 15px;
font-weight: 600;
line-height: 21px;
}
.profile-wallet__amount {
display: block;
margin-top: 4px;
color: #e9c977;
font-family: Inter, var(--font);
font-size: 20px;
font-weight: 600;
line-height: 26px;
}
.profile-wallet__hint {
margin: 4px 0 0;
color: #7c7c86;
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}
.profile-wallet__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
}
.profile-wallet__recharge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
color: #fff;
background: linear-gradient(135deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
border: 0;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.profile-wallet__details {
display: none;
}
.profile-stats {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0;
min-height: 64px;
padding: 12px var(--profile-gutter) 14px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.profile-stat {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
text-align: center;
}
.profile-stat__value {
color: #f2f2f4;
font-family: Inter, var(--font);
font-size: 16px;
font-weight: 600;
line-height: 20px;
}
.profile-stat__value span { display: none; }
.profile-stat__label {
color: #7c7c86;
font-size: 11px;
font-weight: 400;
line-height: 16px;
white-space: nowrap;
}
.profile-tabs {
width: 100%;
margin: 0;
padding-top: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.profile-tabs__inner {
display: flex;
align-items: flex-end;
gap: 24px;
width: 100%;
max-width: none;
min-height: 38px;
height: 38px;
margin: 0;
padding: 6px var(--profile-gutter) 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.profile-tabs__inner::-webkit-scrollbar { display: none; }
.profile-tabs__link {
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: center;
min-height: 31px;
padding: 0 0 10px;
color: #a1a1aa;
font-size: 15px;
font-weight: 600;
line-height: 21px;
white-space: nowrap;
}
.profile-tabs__link.is-active { color: #f2f2f4; }
.profile-tabs__link.is-active::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
width: 20px;
height: 2px;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
transform: translateX(-50%);
}
.profile-main {
display: block;
width: 100%;
max-width: none;
min-width: 0;
margin: 0;
padding: 0;
}
.profile-section {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: none;
margin: 0;
padding-right: var(--profile-gutter);
padding-left: var(--profile-gutter);
}
.profile-section--recent { padding-top: 16px; padding-bottom: 8px; }
.profile-section--favorites { padding-top: 12px; padding-bottom: 8px; }
.profile-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 26px;
}
.profile-section__heading {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.profile-section__title {
margin: 0;
color: #f2f2f4;
font-size: 18px;
font-weight: 600;
line-height: 26px;
white-space: nowrap;
}
.profile-section__subtitle {
color: #7c7c86;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.profile-section--recent .profile-section__subtitle { display: none; }
.profile-section__more {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
color: #a1a1aa;
font-size: 13px;
line-height: 20px;
white-space: nowrap;
}
.profile-section__more .icon,
.profile-section__more img,
.profile-section__more svg { width: 14px; height: 14px; }
.profile-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px var(--profile-card-gap);
width: 100%;
min-width: 0;
}
.profile-section--recent .profile-card:nth-child(n + 4) { display: none; }
.profile-card {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.profile-card__cover {
position: relative;
display: block;
width: 100%;
aspect-ratio: 109 / 145;
overflow: hidden;
background: #1b1b1f;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
}
.profile-card__cover::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(180deg, transparent 58%, rgba(0, 0, 0, 0.7) 100%);
pointer-events: none;
}
.profile-card__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-card__progress {
position: absolute;
right: 10px;
bottom: 6px;
left: 10px;
z-index: 2;
height: 3px;
overflow: hidden;
background: rgba(255, 255, 255, 0.12);
border-radius: 999px;
}
.profile-card__progress-fill {
display: block;
width: 60%;
height: 100%;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.profile-section--recent .profile-card:nth-child(1) .profile-card__progress-fill { width: 59.6%; }
.profile-section--recent .profile-card:nth-child(2) .profile-card__progress-fill { width: 24.7%; }
.profile-section--recent .profile-card:nth-child(3) .profile-card__progress-fill { width: 79.8%; }
.profile-card__episodes {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 2;
color: #f2f2f4;
font-size: 11px;
font-weight: 500;
line-height: 14px;
white-space: nowrap;
}
.profile-card__badge {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 20px;
padding: 0 5px;
color: #141414;
background: linear-gradient(135deg, #fadc44 15%, #f9c24e 85%);
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 14px;
white-space: nowrap;
}
.profile-card__badge:not(.profile-card__badge--hot) {
color: #141414;
background: linear-gradient(135deg, #fadc44 15%, #f9c24e 85%);
}
.profile-card__badge--hot,
.profile-card__badge[data-badge="hot"] {
color: #fff;
background: linear-gradient(135deg, #f04050 15%, #d01030 85%);
}
.profile-card__title {
display: block;
overflow: hidden;
color: #f2f2f4;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-card__meta {
display: block;
overflow: hidden;
color: #a1a1aa;
font-size: 11px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-card__remaining { display: none; }
.profile-promo {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: none;
margin: 0;
padding: 8px var(--profile-gutter);
}
.profile-ad {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
aspect-ratio: 343 / 98;
min-height: 0;
background: #232328;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
text-align: center;
}
.profile-ad__icon { width: 20px; height: 20px; object-fit: contain; }
.profile-ad__title {
color: #a1a1aa;
font-size: 15px;
font-weight: 600;
line-height: 21px;
}
.profile-ad__mobile-size { display: inline; }
.profile-ad__desktop-size { display: none; }
.profile-ad__meta {
color: #7c7c86;
font-size: 11px;
line-height: 16px;
}
.profile-ad__badge {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 6px;
color: #f2f2f4;
background: rgba(0, 0, 0, 0.6);
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
/* The shared footer's compact profile arrangement in the mobile design. */
.profile-page .site-footer__brand { gap: 18px; }
.profile-page .site-footer__links {
flex-wrap: wrap;
gap: 12px 16px;
}
.profile-page .footer-col { flex-wrap: nowrap; }
.profile-page .footer-col:first-child,
.profile-page .footer-col:nth-child(2) a:last-child,
.profile-page .footer-col:nth-child(3) a:last-child { display: none; }
.profile-page .footer-download {
display: flex;
flex-basis: 100%;
justify-content: center;
padding: 0;
border: 0;
background: none;
}
.profile-page .footer-download .qr-box,
.profile-page .footer-download__copy strong,
.profile-page .footer-download__copy p,
.profile-page .footer-download__copy .icon { display: none; }
.profile-page .footer-download .btn {
height: 20px;
padding: 0;
color: var(--text-muted);
background: none;
font-size: 13px;
line-height: 20px;
}
.profile-page .site-footer__rule { display: none; }
.profile-page .site-footer__legal p:first-child {
order: 2;
width: 100%;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid var(--line-soft);
}
.profile-page .site-footer__legal p:last-child { order: 1; }
.profile-page .site-footer__adult { display: none; }
.profile-page .site-footer__rights { display: none; }
.profile-page .site-footer__mobile-adult { display: inline; }
}
-557
View File
@@ -1,557 +0,0 @@
/* Personal center: desktop layout based on the 1440px design frame. */
.profile-page {
min-height: 100vh;
padding-top: var(--site-header-height, 72px);
background: var(--bg);
}
.profile-hero {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, #2a0d22 0%, #150a17 60%, #0b0b0d 100%);
}
.profile-hero::before {
position: absolute;
top: -140px;
left: -120px;
width: 620px;
height: 360px;
border-radius: 50%;
background: var(--accent);
filter: blur(80px);
opacity: .2;
content: "";
pointer-events: none;
}
.profile-hero__inner,
.profile-stats,
.profile-tabs,
.profile-main,
.profile-promo {
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
}
.profile-hero__inner {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
padding-block: 36px 28px;
}
.profile-identity {
display: flex;
align-items: center;
gap: 24px;
min-width: 0;
}
.profile-identity__avatar {
width: 96px;
height: 96px;
flex: 0 0 96px;
border: 3px solid rgba(255, 255, 255, .24);
border-radius: 50%;
object-fit: cover;
}
.profile-identity__copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.profile-identity__name {
max-width: 100%;
margin: 0;
overflow: hidden;
color: var(--text);
font-size: 32px;
font-weight: 600;
line-height: 40px;
letter-spacing: -.01em;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-identity__meta {
margin: 0;
color: var(--text-muted);
font-size: 13px;
line-height: 18px;
letter-spacing: .06em;
}
.profile-identity__edit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
padding-inline: 12px;
border: 1px solid rgba(255, 255, 255, .24);
border-radius: 999px;
color: var(--text);
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.profile-identity__edit img,
.profile-wallet__label img {
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.profile-wallet {
display: flex;
align-items: center;
gap: 28px;
min-height: 120px;
padding: 18px 24px;
border: 1px solid var(--line-soft);
border-radius: 16px;
background: var(--bg-elevated);
}
.profile-wallet__label {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--text);
font-size: 15px;
font-weight: 600;
line-height: 21px;
white-space: nowrap;
}
.profile-wallet__amount {
display: block;
margin: 4px 0 0;
color: var(--gold);
font-family: Inter, var(--font);
font-size: 28px;
font-weight: 600;
line-height: 34px;
}
.profile-wallet__hint {
margin: 2px 0 0;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.profile-wallet__actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.profile-wallet__recharge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 40px;
padding-inline: 16px;
border-radius: 999px;
background: linear-gradient(135deg, #ff4a7b 15%, #ec237f 51%, #93226f 85%);
color: #fff;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.profile-wallet__details {
color: var(--text-muted);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
text-decoration: none;
}
.profile-wallet__details:hover {
color: var(--text);
}
.profile-stats {
position: relative;
display: flex;
align-items: center;
gap: 40px;
padding: 18px 0 22px;
border-top: 1px solid var(--line-soft);
}
.profile-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.profile-stat__value {
color: var(--text);
font-family: Inter, var(--font);
font-size: 20px;
font-weight: 600;
line-height: 26px;
white-space: nowrap;
}
.profile-stat__label {
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
}
.profile-tabs {
padding-top: 4px;
border-bottom: 1px solid var(--line-soft);
}
.profile-tabs__inner {
display: flex;
align-items: flex-start;
gap: 32px;
height: 38px;
}
.profile-tabs__link {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
height: 100%;
padding: 4px 0 0;
color: var(--text-muted);
font-size: 16px;
font-weight: 600;
line-height: 22px;
white-space: nowrap;
}
.profile-tabs__link::after {
position: absolute;
bottom: 0;
left: 50%;
width: 24px;
height: 2px;
transform: translateX(-50%);
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
content: "";
opacity: 0;
}
.profile-tabs__link.is-active {
color: var(--text);
}
.profile-tabs__link.is-active::after {
opacity: 1;
}
.profile-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.profile-section--recent {
padding-block: 28px 20px;
}
.profile-section--favorites {
padding-block: 20px;
}
.profile-section__head,
.profile-section__heading {
display: flex;
align-items: center;
}
.profile-section__head {
justify-content: space-between;
gap: 20px;
}
.profile-section__heading {
gap: 14px;
min-width: 0;
}
.profile-section__title {
margin: 0;
color: var(--text);
font-size: 22px;
font-weight: 600;
line-height: 30px;
white-space: nowrap;
}
.profile-section__subtitle {
color: var(--text-dim);
font-size: 14px;
line-height: 22px;
white-space: nowrap;
}
.profile-section__more {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
white-space: nowrap;
}
.profile-section__more img {
width: 16px;
height: 16px;
}
.profile-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 32px 24px;
}
.profile-section__empty {
grid-column: 1 / -1;
margin: 8px 0 32px;
color: var(--text-muted);
}
.profile-card {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.profile-card__cover {
position: relative;
display: block;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
border: 1px solid var(--line-soft);
border-radius: 12px;
background: var(--bg-panel);
}
.profile-card__cover::after {
position: absolute;
inset: 58% 0 0;
z-index: 1;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .7));
content: "";
pointer-events: none;
}
.profile-card__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-card__episodes {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 2;
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 16px;
text-shadow: 0 1px 4px #000;
}
.profile-card__progress {
position: absolute;
right: 10px;
bottom: 6px;
left: 10px;
z-index: 2;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, .12);
}
.profile-card__progress-fill {
display: block;
width: var(--progress, 0%);
height: 100%;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.profile-card__badge {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 20px;
padding-inline: 8px;
border-radius: 6px;
background: linear-gradient(135deg, #fadc44 15%, #f9c24e 85%);
color: #141414;
font-size: 11px;
font-weight: 500;
line-height: 14px;
white-space: nowrap;
}
.profile-card__badge--hot {
background: linear-gradient(135deg, #f04050 15%, #d01030 85%);
color: #fff;
}
.profile-card__title {
margin: 0;
overflow: hidden;
color: var(--text);
font-size: 16px;
font-weight: 600;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-card__title a {
color: inherit;
text-decoration: none;
}
.profile-card__title a:hover {
color: var(--accent);
}
.profile-card__meta {
margin: 0;
overflow: hidden;
color: var(--text-muted);
font-size: 13px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-promo {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-inline: 0;
padding-block: 8px 40px;
}
.profile-ad {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
aspect-ratio: 592 / 169;
border: 1px solid var(--line-soft);
border-radius: 12px;
background: #232328;
text-align: center;
}
.profile-ad__icon {
width: 24px;
height: 24px;
color: var(--text-muted);
}
.profile-ad__title {
margin: 0;
color: var(--text-muted);
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.profile-ad__mobile-size {
display: none;
}
.profile-ad__meta {
margin: 0;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
}
.profile-ad__badge {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding-inline: 6px;
border-radius: 999px;
background: rgba(0, 0, 0, .6);
color: var(--text);
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
@media (min-width: 901px) {
.profile-page .site-footer__inner {
gap: 32px;
padding-bottom: 31px;
}
.profile-page .site-footer__legal {
flex-direction: row;
justify-content: space-between;
gap: 24px;
}
.profile-page .site-footer__legal p:last-child {
text-align: right;
}
}
/* 账户列表使用上游总数进行服务端分页。 */
.account-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin: 24px 0;
color: var(--text-muted);
font-size: 13px;
}
.account-pagination a {
color: var(--text);
border: 1px solid var(--line);
border-radius: 20px;
padding: 7px 14px;
}
-152
View File
@@ -1,152 +0,0 @@
/* 已购剧集 H5:以 Figma 375px 画板为基准。 */
@media (max-width: 768px) {
.purchased-main {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: none;
min-width: 0;
margin: 0;
padding: 12px var(--profile-gutter, 16px) 20px;
}
.purchased-note {
margin: 0;
color: #7c7c86;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.purchased-list {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.purchased-item {
display: flex;
align-items: center;
gap: clamp(8px, 3.2vw, 14px);
width: 100%;
min-width: 0;
min-height: 89px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, .07);
}
.purchased-item__cover {
display: block;
flex: 0 0 clamp(41px, 12.8vw, 56px);
width: clamp(41px, 12.8vw, 56px);
height: clamp(55px, 17.066667vw, 75px);
overflow: hidden;
border-radius: 6px;
background: #141417;
}
.purchased-item__cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.purchased-item__body {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: flex-start;
gap: 5px;
min-width: 0;
}
.purchased-item__title {
display: block;
max-width: 100%;
overflow: hidden;
margin: 0;
color: #f2f2f4;
font-size: clamp(13px, 4vw, 15px);
font-weight: 600;
line-height: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.purchased-item__info {
display: block;
max-width: 100%;
color: #7c7c86;
font-size: clamp(10px, 2.933333vw, 11px);
font-weight: 400;
line-height: 16px;
white-space: normal;
}
.purchased-item__mobile-date {
display: inline;
white-space: nowrap;
}
.purchased-item__date {
display: none;
}
.purchased-item__progress {
display: block;
width: min(140px, 100%);
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, .12);
}
.purchased-item__progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.purchased-item__resume {
display: inline-flex;
flex: 0 0 clamp(67px, 20.8vw, 88px);
align-items: center;
justify-content: center;
gap: 6px;
width: clamp(67px, 20.8vw, 88px);
height: 32px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, .24);
border-radius: 999px;
background: transparent;
color: #f2f2f4;
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.purchased-item__resume svg,
.purchased-item__resume img {
display: block;
flex: 0 0 16px;
width: 16px;
height: 16px;
}
.purchased-item__resume-desktop { display: none; }
.purchased-item__resume-mobile { display: inline; }
}
@media (max-width: 359px) {
.purchased-page .profile-tabs__inner {
gap: 16px;
}
}
-148
View File
@@ -1,148 +0,0 @@
/* 已购剧集:1440px 桌面稿,沿用个人中心公共页头与页签。 */
.purchased-main {
display: flex;
flex-direction: column;
gap: 16px;
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
padding: 24px 0 40px;
}
.purchased-note {
margin: 0;
color: var(--text-dim);
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.purchased-list {
min-width: 0;
}
.purchased-item {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 120px 106px;
align-items: center;
gap: 16px;
min-width: 0;
min-height: 104px;
padding-block: 14px;
border-bottom: 1px solid var(--line-soft);
}
.purchased-item__cover {
display: block;
width: 56px;
height: 75px;
overflow: hidden;
border-radius: 6px;
background: var(--bg-panel);
}
.purchased-item__cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.purchased-item__body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.purchased-item__title {
display: block;
max-width: 100%;
margin: 0;
overflow: hidden;
color: var(--text);
font-size: 16px;
font-weight: 600;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.purchased-item__info {
display: block;
max-width: 100%;
margin: 0;
overflow: hidden;
color: var(--text-muted);
font-size: 13px;
font-weight: 400;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.purchased-item__progress {
display: block;
width: 240px;
max-width: 100%;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: var(--line);
}
.purchased-item__progress > span {
display: block;
width: var(--progress, 100%);
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.purchased-item__date {
color: var(--text-dim);
font-size: 12px;
font-weight: 400;
line-height: 18px;
white-space: nowrap;
}
.purchased-item__mobile-date,
.purchased-item__resume-mobile {
display: none;
}
.purchased-item__resume {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 106px;
height: 36px;
padding-inline: 12px;
border: 1px solid rgba(255, 255, 255, .24);
border-radius: 999px;
color: var(--text);
font-size: 14px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.purchased-item__resume img,
.purchased-item__resume svg {
display: block;
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.purchased-item__resume:hover {
border-color: rgba(255, 255, 255, .36);
color: var(--text);
}
.purchased-item__resume:focus-visible {
outline: 2px solid var(--accent-hot);
outline-offset: 3px;
}
-117
View File
@@ -1,117 +0,0 @@
@media (max-width: 768px) {
.recharge-page {
--recharge-gutter: clamp(14px, 4.266667vw, 18px);
padding-top: 0;
}
.recharge-page .site-header { display: none; }
.subpage-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
padding: 0 var(--recharge-gutter);
border-bottom: 1px solid var(--line-soft);
background: var(--bg);
}
.subpage-header__button { display: flex; align-items: center; justify-content: center; width: 28px; height: 36px; }
.subpage-header__button img { width: 22px; height: 22px; }
.subpage-header__title { color: var(--text); font-size: 16px; font-weight: 600; line-height: 24px; }
.recharge-main { width: 100%; margin: 0; padding: 16px 0 0; }
.recharge-breadcrumb,
.recharge-intro,
.recharge-balance__guide { display: none; }
.recharge-balance {
width: auto;
height: 110px;
margin: 0 var(--recharge-gutter);
padding: 18px;
border-radius: 16px;
}
.recharge-balance__main { gap: 14px; }
.recharge-balance__coin { width: 72px; height: 72px; flex-basis: 72px; }
.recharge-balance__label { font-size: 11px; line-height: 16px; }
.recharge-balance__copy strong { margin-top: 2px; font-size: 28px; line-height: 34px; }
.recharge-balance__copy small { font-size: 13px; line-height: 20px; }
.recharge-balance__copy p { margin-top: 0; font-size: 11px; line-height: 16px; }
.recharge-packages { margin-top: 20px; padding: 0 var(--recharge-gutter); }
.recharge-section-heading { gap: 10px; height: 22px; min-height: 22px; }
.recharge-section-heading h2,
.recharge-payment > h2 { font-size: 16px; line-height: 22px; }
.recharge-section-heading span { font-size: 12px; line-height: 18px; white-space: nowrap; }
.recharge-packages__grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-top: 12px; }
.recharge-package { gap: 3px; height: 99px; padding: 12px; border-radius: 12px; }
.recharge-package__title { gap: 5px; font-size: 15px; line-height: 21px; white-space: nowrap; }
.recharge-package__title img { width: 14px; height: 14px; }
.recharge-package__hint { font-size: 11px; line-height: 16px; }
.recharge-package__price { font-size: 20px; line-height: 26px; }
.recharge-package__selected { top: 12px; right: 12px; width: 16px; height: 16px; }
.recharge-package__badge { top: -2px; right: 9px; height: 21px; padding-inline: 7px; font-size: 10px; }
.recharge-packages__note { display: none; }
.recharge-payment { margin-top: 16px; padding: 0 var(--recharge-gutter); }
.recharge-payment__grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 12px; }
.recharge-method { gap: 10px; padding: 0 12px; }
.recharge-payment__checkout { flex-direction: column; gap: 11px; margin-top: 11px; }
.recharge-payment__checkout button { width: 100%; height: 48px; font-size: 16px; }
.recharge-payment__checkout p { font-size: 11px; line-height: 18px; text-align: center; }
.recharge-payment__status { text-align: center; }
.recharge-history { margin-top: 22px; padding: 0 var(--recharge-gutter) 22px; }
.recharge-history__head { height: 26px; }
.recharge-history__head h2 { font-size: 18px; line-height: 26px; }
.recharge-history__filters,
.recharge-history__export { display: none; }
.recharge-history__more { display: flex; align-items: center; gap: 2px; margin-left: auto; padding: 0; border: 0; background: none; color: var(--text-muted); font-size: 12px; line-height: 18px; }
.recharge-history__more img { width: 14px; height: 14px; }
.recharge-history__table { height: auto; margin-top: 8px; padding: 0; border: 0; border-radius: 0; background: none; }
.recharge-history__columns { display: none; }
.recharge-history__row { display: grid; grid-template-columns: minmax(0,1fr) auto; grid-template-rows: 20px 16px; align-content: center; height: 64px; border-bottom: 1px solid var(--line-soft); font-size: 14px; line-height: 20px; }
.recharge-history__kind { grid-column: 1; grid-row: 1; }
.recharge-history__detail-line { display: flex; align-items: center; gap: 8px; grid-column: 1; grid-row: 2; min-width: 0; }
.recharge-history__description,
.recharge-history__time { color: var(--text-dim); font-size: 11px; line-height: 16px; white-space: nowrap; }
.recharge-history__description { overflow: hidden; text-overflow: ellipsis; }
.recharge-history__time { flex: 0 0 auto; }
.recharge-history__amount { align-self: center; grid-column: 2; grid-row: 1 / 3; font-size: 16px; line-height: 20px; }
.only-desktop { display: none; }
.only-mobile { display: inline; }
.recharge-pagination { display: none; }
.recharge-page .site-footer { background: #141417; }
.recharge-page .site-footer__inner { padding: 32px 16px 27px; gap: 18px; }
.recharge-page .site-footer__brand { gap: 18px; }
.recharge-page .site-footer__links { flex-wrap: wrap; gap: 12px 16px; }
.recharge-page .footer-col { flex-wrap: nowrap; }
.recharge-page .footer-col:first-child,
.recharge-page .footer-col:nth-child(2) a:last-child,
.recharge-page .footer-col:nth-child(3) a:last-child { display: none; }
.recharge-page .footer-download { display: flex; flex-basis: 100%; justify-content: center; padding: 0; border: 0; background: none; }
.recharge-page .footer-download .qr-box,
.recharge-page .footer-download__copy strong,
.recharge-page .footer-download__copy p,
.recharge-page .footer-download__copy .icon { display: none; }
.recharge-page .footer-download .btn { height: 20px; padding: 0; color: var(--text-muted); background: none; font-size: 13px; line-height: 20px; }
.recharge-page .site-footer__rule { display: none; }
.recharge-page .site-footer__legal { flex-direction: column; align-items: center; gap: 0; }
.recharge-page .site-footer__legal p:first-child { order: 2; width: 100%; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--line-soft); }
.recharge-page .site-footer__legal p:last-child { order: 1; }
.recharge-page .site-footer__adult,
.recharge-page .site-footer__rights { display: none; }
.recharge-page .site-footer__mobile-adult { display: inline; }
}
@media (max-width: 359px) {
.recharge-balance { height: 110px; padding: 14px; }
.recharge-balance__main { gap: 10px; }
.recharge-balance__coin { width: 64px; height: 64px; flex-basis: 64px; }
.recharge-balance__copy p { line-height: 14px; }
.recharge-balance__note-full { display: none; }
.recharge-balance__note-short { display: inline; }
.recharge-package__title { font-size: 14px; }
.recharge-package__badge { top: -4px; height: 18px; padding-inline: 5px; font-size: 9px; }
}
-516
View File
@@ -1,516 +0,0 @@
.recharge-modal[hidden] {
display: none !important;
}
.recharge-modal {
position: fixed;
inset: 0;
z-index: 320;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.recharge-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
}
.recharge-modal__panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
width: 640px;
max-width: 100%;
max-height: calc(100dvh - 48px);
padding: 32px;
overflow-y: auto;
background: #141417;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 20px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
scrollbar-width: thin;
}
.recharge-modal__handle {
display: none;
}
.recharge-modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 54px;
}
.recharge-modal__heading {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recharge-modal__heading h2,
.recharge-modal__subtitle {
margin: 0;
}
.recharge-modal__heading h2 {
color: #f2f2f4;
font-size: 22px;
font-weight: 600;
line-height: 30px;
}
.recharge-modal__subtitle {
color: #a1a1aa;
font-size: 13px;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
}
.recharge-modal__subtitle--mobile {
display: none;
}
.recharge-modal__balance {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 6px 12px;
color: #e9c977;
background: rgba(217, 169, 74, 0.14);
border-radius: 999px;
}
.recharge-modal__balance strong {
font-family: Inter, var(--font);
font-size: 16px;
font-weight: 600;
line-height: 20px;
}
.recharge-modal__coin {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 14px;
width: 14px;
height: 14px;
background: linear-gradient(135deg, #e9c977 15%, #d9a94a 85%);
border: 1px solid #e9c977;
border-radius: 50%;
}
.recharge-modal__coin::before {
content: "";
position: absolute;
inset: 2px;
border: 1px solid rgba(58, 42, 10, 0.45);
border-radius: 50%;
}
.recharge-modal__coin img {
position: relative;
width: 8px;
height: 8px;
}
.recharge-modal__coin--balance {
flex-basis: 16px;
width: 16px;
height: 16px;
}
.recharge-modal__close {
position: absolute;
top: 16px;
right: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.04);
border-radius: 50%;
}
.recharge-modal__close img {
width: 18px;
height: 18px;
}
.recharge-modal__packages {
display: grid;
grid-template-columns: repeat(3, 181px);
gap: 12px;
}
.recharge-modal__package {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 181px;
height: 99px;
padding: 12px;
text-align: left;
background: #141417;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
}
.recharge-modal__package.is-selected {
background: rgba(217, 169, 74, 0.14);
border: 1.5px solid #d9a94a;
}
.recharge-modal__package-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
min-height: 21px;
}
.recharge-modal__package-name {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
color: #f2f2f4;
font-size: 15px;
font-weight: 600;
line-height: 21px;
}
.recharge-modal__package.is-selected .recharge-modal__package-name,
.recharge-modal__package.is-selected .recharge-modal__package-price {
color: #e9c977;
}
.recharge-modal__selected-icon {
display: none;
flex: none;
width: 14px;
height: 14px;
}
.recharge-modal__package.is-selected .recharge-modal__selected-icon {
display: block;
}
.recharge-modal__package-detail {
color: #7c7c86;
font-size: 12px;
font-weight: 400;
line-height: 18px;
white-space: nowrap;
}
.recharge-modal__package-price {
color: #f2f2f4;
font-family: Inter, var(--font);
font-size: 20px;
font-weight: 600;
line-height: 26px;
}
.recharge-modal__package-badge {
position: absolute;
top: -1px;
right: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 8px;
color: #141414;
background: linear-gradient(135deg, #e9c977 15%, #d9a94a 85%);
border-radius: 6px;
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
.recharge-modal__methods {
display: grid;
grid-template-columns: repeat(2, 278px);
gap: 12px;
}
.recharge-modal__method {
display: flex;
align-items: center;
gap: 10px;
width: 278px;
height: 52px;
padding: 0 16px;
text-align: left;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
}
.recharge-modal__method.is-selected {
background: rgba(236, 35, 127, 0.1);
border-color: rgba(236, 35, 127, 0.3);
}
.recharge-modal__method-check {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 18px;
height: 18px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 50%;
}
.recharge-modal__method-check img {
display: none;
width: 12px;
height: 12px;
}
.recharge-modal__method.is-selected .recharge-modal__method-check {
border: 0;
background: linear-gradient(135deg, #ff4a7b 15%, #ec237f 51%, #93226f 85%);
}
.recharge-modal__method.is-selected .recharge-modal__method-check img {
display: block;
}
.recharge-modal__method-copy {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
color: #f2f2f4;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.recharge-modal__method-copy small {
color: #7c7c86;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.recharge-modal__pay {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 52px;
padding: 0 28px;
color: #fff;
background: linear-gradient(135deg, #ff4a7b 15%, #ec237f 51%, #93226f 85%);
border-radius: 999px;
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
.recharge-modal__status {
margin: -8px 0 0;
color: #a1a1aa;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.recharge-modal__status.is-ok {
color: #6ee7a8;
}
.recharge-modal__empty {
margin: 0;
grid-column: 1 / -1;
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
line-height: 20px;
}
.recharge-modal__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #7c7c86;
font-size: 12px;
font-weight: 400;
line-height: 18px;
white-space: nowrap;
}
.recharge-modal__foot a {
display: inline-flex;
align-items: center;
gap: 5px;
color: #a1a1aa;
}
.recharge-modal__foot img {
width: 12px;
height: 12px;
}
.recharge-modal__close:focus-visible,
.recharge-modal__package:focus-visible,
.recharge-modal__method:focus-visible,
.recharge-modal__pay:focus-visible,
.recharge-modal__foot a:focus-visible {
outline: 2px solid #ff4a7b;
outline-offset: 2px;
}
@media (max-width: 768px) {
.recharge-modal {
align-items: flex-end;
justify-content: stretch;
padding: 0;
}
.recharge-modal__panel {
width: 100%;
max-width: none;
max-height: 100dvh;
padding: 12px 20px 24px;
gap: 14px;
border: 0;
border-radius: 20px 20px 0 0;
box-shadow: none;
}
.recharge-modal__handle {
display: flex;
justify-content: center;
align-items: center;
flex: none;
height: 4px;
}
.recharge-modal__handle::before {
content: "";
width: 36px;
height: 4px;
background: rgba(255, 255, 255, 0.24);
border-radius: 2px;
}
.recharge-modal__head {
min-height: 46px;
}
.recharge-modal__heading {
gap: 2px;
}
.recharge-modal__heading h2 {
font-size: 18px;
line-height: 26px;
}
.recharge-modal__subtitle {
font-size: 12px;
line-height: 18px;
}
.recharge-modal__subtitle--desktop,
.recharge-modal__balance {
display: none;
}
.recharge-modal__subtitle--mobile {
display: block;
}
.recharge-modal__close {
position: static;
flex: none;
}
.recharge-modal__packages {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.recharge-modal__package {
width: 100%;
}
.recharge-modal__package-detail {
font-size: clamp(10px, 3.2vw, 12px);
}
.recharge-modal__methods {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.recharge-modal__method {
width: 100%;
padding: 0 12px;
}
.recharge-modal__method-copy small {
font-size: 11px;
line-height: 16px;
}
.recharge-modal__pay {
height: 48px;
padding: 0 22px;
font-size: 15px;
line-height: 22px;
}
.recharge-modal__foot {
font-size: 11px;
line-height: 16px;
}
.recharge-modal__foot img {
width: 11px;
height: 11px;
}
}
@media (max-width: 340px) {
.recharge-modal__panel {
padding-inline: 16px;
}
.recharge-modal__package {
padding-inline: 9px;
}
.recharge-modal__package-name {
font-size: 13px;
}
.recharge-modal__package-badge {
top: -8px;
right: 4px;
}
.recharge-modal__method {
gap: 7px;
padding-inline: 9px;
}
}
-271
View File
@@ -1,271 +0,0 @@
.recharge-page {
min-height: 100vh;
padding-top: 72px;
background: var(--bg);
}
.recharge-page [hidden] { display: none !important; }
.subpage-header { display: none; }
.recharge-main {
width: min(calc(100% - 48px), var(--page-width));
margin-inline: auto;
padding: 24px 0 41px;
}
.recharge-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
height: 20px;
color: var(--text-dim);
font-size: 13px;
line-height: 20px;
}
.recharge-breadcrumb span:last-child { color: var(--text-muted); }
.recharge-breadcrumb a:hover { color: var(--text); }
.recharge-intro {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 16px;
}
.recharge-intro__eyebrow {
color: var(--accent-hot);
font-size: 13px;
font-weight: 600;
line-height: 18px;
letter-spacing: .14em;
}
.recharge-intro__eyebrow::before {
display: inline-block;
width: 18px;
height: 2px;
margin: 0 10px 4px 0;
background: var(--accent);
content: "";
}
.recharge-intro h1 {
margin: 12px 0 0;
color: var(--text);
font-size: 28px;
font-weight: 600;
line-height: 36px;
}
.recharge-intro p {
margin: 12px 0 0;
color: var(--text-muted);
font-size: 15px;
line-height: 24px;
}
.recharge-balance {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
width: 100%;
height: 216px;
margin-top: 24px;
padding: 24px 32px;
border: 1px solid var(--line-soft);
border-radius: 20px;
background: linear-gradient(90deg, #2a0d22 0%, #150a17 55%, #141417 100%);
}
.recharge-balance__main {
display: flex;
align-items: center;
gap: 24px;
min-width: 0;
}
.recharge-balance__coin {
width: 120px;
height: 120px;
flex: 0 0 120px;
object-fit: contain;
}
.recharge-balance__copy { min-width: 0; }
.recharge-balance__label,
.recharge-balance__guide h2 {
color: var(--text-dim);
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.recharge-balance__copy strong {
display: block;
margin-top: 4px;
color: var(--gold);
font-family: Inter, var(--font);
font-size: 40px;
font-weight: 600;
line-height: 46px;
}
.recharge-balance__copy small {
color: var(--text);
font-family: var(--font);
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.recharge-balance__copy p {
margin: 3px 0 0;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.recharge-balance__note-short { display: none; }
.recharge-balance__guide {
display: flex;
flex: 0 0 360px;
flex-direction: column;
gap: 9px;
}
.recharge-balance__guide h2 { margin: 0; }
.recharge-balance__tip { display: flex; align-items: flex-start; gap: 8px; }
.recharge-balance__tip > img { width: 16px; height: 16px; flex: 0 0 16px; }
.recharge-balance__tip > div { display: flex; flex-direction: column; gap: 2px; }
.recharge-balance__tip strong { color: var(--text); font-size: 13px; font-weight: 600; line-height: 18px; }
.recharge-balance__tip span { color: var(--text-dim); font-size: 12px; line-height: 18px; }
.recharge-packages { margin-top: 32px; }
.recharge-section-heading { display: flex; align-items: center; gap: 14px; min-height: 30px; }
.recharge-section-heading h2,
.recharge-payment > h2,
.recharge-history__head h2 {
margin: 0;
color: var(--text);
font-size: 20px;
font-weight: 600;
line-height: 26px;
}
.recharge-section-heading span { color: var(--text-dim); font-size: 13px; line-height: 20px; }
.recharge-packages__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
.recharge-package {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
min-width: 0;
height: 124px;
padding: 18px 20px;
border: 1px solid var(--line-soft);
border-radius: 16px;
background: var(--bg-elevated);
color: var(--text);
text-align: left;
cursor: pointer;
}
.recharge-package.is-selected {
border: 1.5px solid #d9a94a;
background: rgba(217, 169, 74, .14);
}
.recharge-package__title { display: flex; align-items: center; gap: 6px; font-size: 18px; font-weight: 600; line-height: 26px; }
.recharge-package__title img { width: 16px; height: 16px; }
.recharge-package__hint { color: var(--text-dim); font-size: 12px; line-height: 18px; white-space: nowrap; }
.recharge-package__price { margin-top: auto; color: var(--text); font-family: Inter, var(--font); font-size: 24px; font-weight: 600; line-height: 30px; }
.recharge-package.is-selected .recharge-package__title,
.recharge-package.is-selected .recharge-package__price { color: var(--gold); }
.recharge-package__selected { position: absolute; top: 18px; right: 20px; width: 18px; height: 18px; display: none; }
.recharge-package.is-selected .recharge-package__selected { display: block; }
.recharge-package__badge { position: absolute; top: -2px; right: 10px; display: flex; align-items: center; justify-content: center; height: 21px; padding: 0 8px; border-radius: 5px; background: linear-gradient(135deg, #ffe7a0, #e9c977); color: #382506; font-size: 11px; font-weight: 600; line-height: 16px; }
.recharge-packages__note { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-top: 20px; }
.recharge-packages__note span { display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border: 1px solid rgba(236,35,127,.34); border-radius: 999px; background: rgba(236,35,127,.08); color: var(--accent-hot); font-size: 12px; font-weight: 500; white-space: nowrap; }
.recharge-packages__note p { margin: 0; color: var(--text-dim); font-size: 12px; line-height: 18px; }
.recharge-data-empty { margin: 16px 0 0; color: var(--text-dim); font-size: 13px; line-height: 20px; }
.recharge-payment { margin-top: 28px; }
.recharge-payment__grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin-top: 20px; }
.recharge-method { display: flex; align-items: center; gap: 10px; height: 52px; min-width: 0; padding: 0 14px; border: 1px solid var(--line); border-radius: 12px; background: #242427; color: var(--text); text-align: left; cursor: pointer; }
.recharge-method.is-selected { border-color: rgba(236,35,127,.45); background: rgba(236,35,127,.12); }
.recharge-method__radio { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex: 0 0 18px; border: 1px solid #68686d; border-radius: 50%; }
.recharge-method__radio img { display: none; width: 12px; height: 12px; }
.recharge-method.is-selected .recharge-method__radio { border-color: transparent; background: linear-gradient(135deg,#ff4a7b,#ec237f); }
.recharge-method.is-selected .recharge-method__radio img { display: block; }
.recharge-method > span:last-child { display: flex; flex-direction: column; }
.recharge-method strong { font-size: 14px; font-weight: 500; line-height: 20px; }
.recharge-method small { color: var(--text-dim); font-size: 12px; line-height: 18px; }
.recharge-payment__checkout { display: flex; align-items: center; gap: 16px; margin-top: 28px; }
.recharge-payment__checkout button { display: flex; align-items: center; justify-content: center; width: 320px; height: 56px; border: 0; border-radius: 999px; background: linear-gradient(105deg,#ff4a7b 0%,#ec237f 52%,#93226f 100%); color: #fff; font-size: 16px; font-weight: 600; line-height: 24px; cursor: pointer; }
.recharge-payment__checkout button:disabled { cursor: not-allowed; opacity: .5; }
.recharge-payment__checkout p { margin: 0; color: var(--text-dim); font-size: 12px; line-height: 18px; }
.recharge-payment__status { margin: 8px 0 0; color: var(--text-muted); font-size: 12px; line-height: 18px; }
.recharge-payment__status.is-success { color: #39c98a; }
.recharge-history { margin-top: 40px; scroll-margin-top: calc(var(--site-header-height, 72px) + 16px); }
.recharge-history__head { display: flex; align-items: center; gap: 14px; height: 30px; }
.recharge-history__filters { display: flex; align-items: center; gap: 18px; align-self: stretch; }
.recharge-history__filters button { position: relative; padding: 0; border: 0; background: none; color: var(--text-dim); font-size: 13px; font-weight: 500; cursor: pointer; }
.recharge-history__filters button.is-active { color: var(--text); }
.recharge-history__filters button.is-active::after { position: absolute; right: 0; bottom: -7px; left: 0; height: 2px; background: var(--accent); content: ""; }
.recharge-history__export { display: flex; align-items: center; gap: 3px; margin-left: auto; padding: 0; border: 0; background: none; color: var(--text-muted); font-size: 13px; cursor: pointer; }
.recharge-history__export img { width: 16px; height: 16px; }
.recharge-history__more { display: none; }
.recharge-history__table { height: 310px; margin-top: 16px; padding: 4px 24px; border: 1px solid var(--line-soft); border-radius: 16px; background: var(--bg-elevated); }
.recharge-history__columns,
.recharge-history__row { display: grid; grid-template-columns: 200px minmax(0,1fr) 200px 120px; align-items: center; }
.recharge-history__columns { height: 45px; border-bottom: 1px solid var(--line-soft); color: var(--text-dim); font-size: 12px; line-height: 18px; }
.recharge-history__columns span:last-child,
.recharge-history__amount { text-align: right; }
.recharge-history__row { height: 51px; border-bottom: 1px solid var(--line-soft); color: var(--text-muted); font-size: 13px; line-height: 20px; }
.recharge-history__row:last-child { border-bottom: 0; }
.recharge-history__kind { color: var(--text); }
.recharge-history__detail-line { display: contents; }
.recharge-history__time { color: var(--text-dim); }
.recharge-history__amount { color: #f35b69; font-family: Inter, var(--font); font-size: 15px; font-weight: 600; }
.recharge-history__amount.is-credit { color: #39c98a; }
.recharge-history__empty { margin: 0; padding: 36px 0; color: var(--text-dim); font-size: 13px; text-align: center; }
.only-mobile { display: none; }
.recharge-pagination,
.recharge-pagination [data-recharge-pages] { display: flex; align-items: center; justify-content: center; gap: 8px; }
.recharge-pagination { margin-top: 16px; }
.recharge-pagination button { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-elevated); color: var(--text-muted); font-family: Inter, var(--font); font-size: 16px; font-weight: 600; cursor: pointer; }
.recharge-pagination button.is-active { border-color: transparent; background: linear-gradient(135deg,#ff4a7b,#ec237f 52%,#93226f); color: #fff; }
.recharge-pagination button:disabled { opacity: .45; cursor: default; }
.recharge-pagination button img { width: 16px; height: 16px; }
.recharge-pagination > button:first-child img { transform: rotate(180deg); }
.recharge-pagination__total { color: var(--text-dim); font-size: 12px; line-height: 18px; }
.recharge-page button:focus-visible,
.recharge-page a:focus-visible { outline: 2px solid var(--accent-hot); outline-offset: 3px; }
@media (max-width: 1080px) and (min-width: 769px) {
.recharge-balance__guide { flex-basis: 300px; }
.recharge-history__columns,
.recharge-history__row { grid-template-columns: 140px minmax(0,1fr) 175px 90px; }
}
@media (min-width: 901px) {
.recharge-page .site-footer__inner { gap: 32px; padding-bottom: 32px; }
.recharge-page .site-footer__legal { flex-direction: row; justify-content: space-between; gap: 24px; }
.recharge-page .site-footer__legal p:last-child { text-align: right; }
}
-255
View File
@@ -1,255 +0,0 @@
/* H5 注册页 */
body.page-register {
min-height: 100vh;
background: var(--bg);
}
.page-register .page-subheader {
display: flex;
border-bottom: 0;
}
.register-page {
min-height: 100vh;
padding: 56px 0 32px;
box-sizing: border-box;
}
.register-page__inner {
width: 100%;
max-width: 440px;
margin: 0 auto;
padding: 24px var(--h5-gutter);
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
box-sizing: border-box;
}
.register-page__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.register-page__logo {
width: 64px;
height: 64px;
border-radius: 12px;
object-fit: cover;
box-shadow: 0 8px 24px rgba(236, 35, 127, 0.4);
}
.register-page__content {
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 18px;
}
.register-page__intro {
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.register-page__title {
margin: 0;
color: var(--text);
font-size: 28px;
font-weight: 600;
line-height: 38px;
}
.register-page__desc {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.register-page__form {
align-self: stretch;
display: flex;
flex-direction: column;
gap: 16px;
}
.register-page__field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.register-page__label {
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.register-page__control {
align-self: stretch;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid transparent;
border-radius: 12px;
outline: 1px solid transparent;
outline-offset: -1px;
}
.register-page__control:focus-within {
outline-color: rgba(236, 35, 127, 0.45);
}
.register-page__field.is-error .register-page__control {
outline-color: #ff5a5f;
}
.register-page__error {
display: inline-flex;
align-items: center;
gap: 6px;
color: #ff5a5f;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.register-page__error[hidden] {
display: none !important;
}
.register-page__error-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.register-page__control > .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.register-page__input {
flex: 1 1 0;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: 15px;
line-height: 24px;
}
.register-page__input::placeholder {
color: var(--text-dim);
}
.register-page__toggle {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.register-page__toggle .icon {
width: 18px;
height: 18px;
}
.register-page__agree {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.register-page__agree a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.register-page__checkbox {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.register-page__check {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 1px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.24);
}
.register-page__checkbox:checked + .register-page__check {
border-color: transparent;
background: linear-gradient(135deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.register-page__checkbox:checked + .register-page__check::after {
content: "";
width: 10px;
height: 6px;
border-left: 1.5px solid #fff;
border-bottom: 1.5px solid #fff;
transform: translateY(-1px) rotate(-45deg);
}
.register-page__submit {
align-self: stretch;
height: 52px;
padding: 0 28px;
font-size: 16px;
line-height: 24px;
}
.register-page__switch {
margin: 0;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.register-page__switch a {
color: #ff4a7b;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
-304
View File
@@ -1,304 +0,0 @@
/* PC 注册弹窗 */
.register-pop {
position: fixed;
inset: 0;
z-index: 290;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
}
.register-pop.is-open {
display: flex;
}
.register-pop__mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
}
.register-pop__panel {
position: relative;
z-index: 1;
width: 440px;
max-width: 100%;
padding: 32px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
background: #141417;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.register-pop__close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
}
.register-pop__close .icon {
width: 18px;
height: 18px;
}
.register-pop__intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding-right: 40px;
}
.register-pop__title {
margin: 0;
color: var(--text);
font-size: 28px;
font-weight: 600;
line-height: 38px;
}
.register-pop__desc {
margin: 0;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.register-pop__form {
align-self: stretch;
display: flex;
flex-direction: column;
gap: 16px;
}
.register-pop__field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.register-pop__label {
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.register-pop__control {
align-self: stretch;
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid transparent;
border-radius: 12px;
outline: 1px solid transparent;
outline-offset: -1px;
}
.register-pop__control:focus-within {
outline-color: rgba(236, 35, 127, 0.45);
}
.register-pop__field.is-error .register-pop__control {
outline-color: #ff5a5f;
}
.register-pop__error {
display: inline-flex;
align-items: center;
gap: 6px;
color: #ff5a5f;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.register-pop__error[hidden] {
display: none !important;
}
.register-pop__error-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.register-pop__control > .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.register-pop__input {
flex: 1 1 0;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: 15px;
line-height: 24px;
}
.register-pop__input::placeholder {
color: var(--text-dim);
}
.register-pop__toggle {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.register-pop__toggle .icon {
width: 18px;
height: 18px;
}
.register-pop__agree {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.register-pop__agree a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.register-pop__checkbox {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.register-pop__check {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 1px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.24);
}
.register-pop__checkbox:checked + .register-pop__check {
border-color: transparent;
background: linear-gradient(135deg, #ff4a7b 0%, #ec237f 52%, #93226f 100%);
}
.register-pop__checkbox:checked + .register-pop__check::after {
content: "";
width: 10px;
height: 6px;
border-left: 1.5px solid #fff;
border-bottom: 1.5px solid #fff;
transform: translateY(-1px) rotate(-45deg);
}
.register-pop__submit {
align-self: stretch;
height: 52px;
padding: 0 28px;
font-size: 16px;
line-height: 24px;
}
.register-pop__submit.is-loading,
.register-pop__submit:disabled {
opacity: 0.72;
cursor: wait;
}
.register-pop__tip {
margin: 0;
align-self: stretch;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 20px;
background: rgba(255, 74, 123, 0.1);
color: #ff4a7b;
}
.register-pop__tip.is-success {
background: rgba(30, 120, 70, 0.12);
color: #1e7846;
}
.register-pop__tip[hidden] {
display: none !important;
}
.register-pop__switch {
margin: 0;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-muted);
font-size: 14px;
line-height: 22px;
}
.register-pop__switch a {
color: #ff4a7b;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
@media (max-width: 768px) {
.register-pop {
padding: 16px;
}
.register-pop__panel {
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
padding: 24px 20px;
border-radius: 18px;
overflow-y: auto;
}
.register-pop__title {
font-size: 24px;
line-height: 32px;
}
}
-65
View File
@@ -1,65 +0,0 @@
.setting-main { width: min(calc(100% - 48px), var(--page-width)); margin: 0 auto; padding: 24px 0 40px; }
.setting-layout { max-width: 900px; margin: 0 auto; }
.setting-card { display: flex; flex-direction: column; gap: 24px; min-width: 0; padding: 32px; border: 1px solid var(--line-soft); border-radius: 16px; background: #141417; }
.setting-section { display: flex; flex-direction: column; gap: 24px; scroll-margin-top: 90px; }
.setting-section__title { margin: 0; color: var(--text); font-size: 18px; font-weight: 600; line-height: 26px; }
.setting-avatar { display: flex; align-items: center; gap: 16px; }
.setting-avatar__image { flex: 0 0 72px; width: 72px; height: 72px; border-radius: 50%; object-fit: cover; }
.setting-avatar__button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; padding: 0 12px; border: 1px solid rgba(255,255,255,.24); border-radius: 999px; color: var(--text); font-size: 14px; cursor: pointer; }
.setting-avatar__button img { width: 16px; height: 16px; }
.setting-avatar__file { position: absolute; width: 1px; height: 1px; opacity: 0; }
.setting-avatar__hint { margin: 8px 0 0; color: var(--text-dim); font-size: 12px; line-height: 18px; }
.setting-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 24px; }
.setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; color: var(--text-muted); font-size: 14px; font-weight: 500; line-height: 20px; }
.setting-field input, .setting-field textarea, .setting-field__action { width: 100%; min-width: 0; min-height: 48px; padding: 12px 16px; border: 1px solid rgba(255,255,255,.12); border-radius: 12px; outline: none; background: rgba(255,255,255,.06); color: var(--text); font: 400 15px/24px var(--font); }
.setting-field textarea { min-height: 96px; resize: vertical; }
.setting-field input:focus, .setting-field textarea:focus { border-color: #ec237f; }
.setting-field input[readonly] { color: var(--text-muted); cursor: default; }
.setting-field small { color: var(--text-dim); font-size: 12px; font-weight: 400; line-height: 18px; }
.setting-field__action { color: #ff4a7b; text-align: right; cursor: pointer; }
.setting-field__value-action { position: relative; min-width: 0; }
.setting-field__value-action input { padding-right: 72px; }
.setting-field__inline-action { position: absolute; top: 50%; right: 16px; color: #ff4a7b; font-size: 14px; font-weight: 500; line-height: 20px; transform: translateY(-50%); cursor: pointer; }
.setting-field--intro { max-width: 100%; }
.setting-actions { display: flex; align-items: center; gap: 12px; padding-top: 24px; border-top: 1px solid var(--line-soft); }
.setting-actions button { min-height: 48px; padding: 0 22px; border-radius: 999px; color: #fff; font-size: 15px; font-weight: 500; cursor: pointer; }
.setting-actions__save { border: 0; background: linear-gradient(135deg, #ff4a7b 15%, #ec237f 51%, #93226f 85%); }
.setting-actions__logout { border: 1px solid rgba(255,255,255,.24); background: transparent; }
.setting-actions__unavailable { margin-left: auto; color: var(--text-dim); font-size: 13px; }
.setting-status { margin: -8px 0 0; min-height: 20px; color: #ff858b; font-size: 13px; line-height: 20px; }
.setting-status.is-success { color: #84d9a1; }
.setting-login-state { padding: 80px 16px; text-align: center; color: var(--text-muted); }
.setting-login-state h2 { color: var(--text); }
.setting-login-state a { color: #ff4a7b; }
.setting-password-pop { position: fixed; inset: 0; z-index: 295; display: flex; align-items: center; justify-content: center; padding: 24px; }
.setting-password-pop[hidden] { display: none !important; }
.setting-password-pop__mask { position: absolute; inset: 0; width: 100%; height: 100%; background: rgba(0,0,0,.72); cursor: default; }
.setting-password-pop__panel { position: relative; z-index: 1; width: 440px; max-width: 100%; padding: 32px; border: 1px solid var(--line); border-radius: var(--radius-xl); background: #141417; box-shadow: 0 24px 64px rgba(0,0,0,.55); }
.setting-password-pop__close { position: absolute; top: 16px; right: 16px; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,.04); }
.setting-password-pop__close .icon { width: 18px; height: 18px; }
.setting-password-pop__intro { padding-right: 40px; }
.setting-password-pop__intro h2 { margin: 0; color: var(--text); font-size: 28px; font-weight: 600; line-height: 38px; }
.setting-password-pop__intro p { margin: 6px 0 0; color: var(--text-muted); font-size: 14px; line-height: 22px; }
.setting-password-pop__form { display: flex; flex-direction: column; gap: 16px; margin-top: 20px; }
.setting-password-pop__status { min-height: 18px; margin: -4px 0 0; color: #ff858b; font-size: 12px; line-height: 18px; }
.setting-password-pop__status.is-success { color: #84d9a1; }
.setting-password-pop__submit { width: 100%; min-height: 52px; border: 0; border-radius: 999px; color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; }
.setting-password-pop__submit:disabled { opacity: .72; cursor: wait; }
@media (max-width: 768px) {
.setting-main { width: 100%; padding: 0 16px 24px; }
.setting-layout { max-width: none; }
.setting-card { gap: 16px; padding: 16px 0 0; border: 0; border-radius: 0; background: transparent; }
.setting-section { gap: 16px; }
.setting-section__title { display: none; }
.setting-avatar__image { flex-basis: 56px; width: 56px; height: 56px; }
.setting-fields { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.setting-actions { flex-direction: column; align-items: stretch; gap: 16px; padding-top: 16px; }
.setting-actions button { width: 100%; }
.setting-actions__unavailable { margin: 0; text-align: center; }
.setting-status { text-align: center; }
.setting-password-pop { padding: 16px; }
.setting-password-pop__panel { max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 24px 20px; border-radius: 18px; overflow-y: auto; }
.setting-password-pop__intro h2 { font-size: 24px; line-height: 32px; }
}
-3
View File
@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="17" height="17" rx="8.5" stroke="white" stroke-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 196 B

-4
View File
@@ -1,4 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0725 13.0725C11.9313 13.9425 10.515 14.4375 9 14.4375C3.75 14.4375 0.75 9 0.75 9C1.6125 7.4025 2.835 6.075 4.275 5.115M7.4325 3.27C7.9425 3.1425 8.4675 3.075 9 3.075C14.25 3.075 17.25 9 17.25 9C16.7925 9.8475 16.2375 10.635 15.6075 11.34M0.75 0.75L17.25 17.25" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.6125 7.61255C7.24773 7.97732 7.04276 8.47206 7.04276 8.98755C7.04276 9.50303 7.24773 9.99777 7.6125 10.3625C7.97727 10.7273 8.47201 10.9323 8.9875 10.9323C9.50298 10.9323 9.99772 10.7273 10.3625 10.3625" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

-4
View File
@@ -1,4 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.75 9C0.75 9 3.75 3 9 3C14.25 3 17.25 9 17.25 9C17.25 9 14.25 15 9 15C3.75 15 0.75 9 0.75 9Z" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 11.25C10.2426 11.25 11.25 10.2426 11.25 9C11.25 7.75736 10.2426 6.75 9 6.75C7.75736 6.75 6.75 7.75736 6.75 9C6.75 10.2426 7.75736 11.25 9 11.25Z" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 536 B

@@ -1,4 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.16669 10H15.8334" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 4.16602L15.8333 9.99935L10 15.8327" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 356 B

@@ -1,3 +0,0 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.6666 35L20 26.6667L8.33331 35V8.33333C8.33331 7.44928 8.6845 6.60143 9.30962 5.97631C9.93474 5.35119 10.7826 5 11.6666 5H28.3333C29.2174 5 30.0652 5.35119 30.6903 5.97631C31.3155 6.60143 31.6666 7.44928 31.6666 8.33333V35Z" stroke="#7C7C86" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

-11
View File
@@ -1,11 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.6665 14.0007V9.33398" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.6665 6.66667V2" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 14V8" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5.33333V2" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.3335 13.9993V10.666" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.3335 8V2" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.666504 9.33398H4.6665" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 5.33398H10" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3335 10.666H15.3335" stroke="#F2F2F4" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.25 9V14.25C14.25 14.6478 14.092 15.0294 13.8107 15.3107C13.5294 15.592 13.1478 15.75 12.75 15.75H5.25C4.85218 15.75 4.47064 15.592 4.18934 15.3107C3.90804 15.0294 3.75 14.6478 3.75 14.25V9" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 4.5H3C2.58579 4.5 2.25 4.83579 2.25 5.25V6.75C2.25 7.16421 2.58579 7.5 3 7.5H15C15.4142 7.5 15.75 7.16421 15.75 6.75V5.25C15.75 4.83579 15.4142 4.5 15 4.5Z" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 15.75V4.5" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4.5H6.375C5.87772 4.5 5.40081 4.30246 5.04917 3.95083C4.69754 3.59919 4.5 3.12228 4.5 2.625C4.5 2.12772 4.69754 1.65081 5.04917 1.29917C5.40081 0.947544 5.87772 0.75 6.375 0.75C8.25 0.75 9 4.5 9 4.5Z" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4.5H11.625C12.1223 4.5 12.5992 4.30246 12.9508 3.95083C13.3025 3.59919 13.5 3.12228 13.5 2.625C13.5 2.12772 13.3025 1.65081 12.9508 1.29917C12.5992 0.947544 12.1223 0.75 11.625 0.75C9.75 0.75 9 4.5 9 4.5Z" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 9.75V14.25C13.5 14.6478 13.342 15.0294 13.0607 15.3107C12.7794 15.592 12.3978 15.75 12 15.75H3.75C3.35218 15.75 2.97064 15.592 2.68934 15.3107C2.40804 15.0294 2.25 14.6478 2.25 14.25V6C2.25 5.60218 2.40804 5.22064 2.68934 4.93934C2.97064 4.65804 3.35218 4.5 3.75 4.5H8.25" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.25 2.25H15.75V6.75" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 10.5L15.75 2.25" stroke="#7C7C86" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 710 B

-4
View File
@@ -1,4 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9C1.5 13.1421 4.85786 16.5 9 16.5Z" stroke="#A1A1AA" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4.5V9L12 10.5" stroke="#A1A1AA" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 445 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="5.25" r="3" stroke="#A1A1AA" stroke-width="1.5"/>
<path d="M3 15.75C3 12.85 5.69 10.5 9 10.5C12.31 10.5 15 12.85 15 15.75" stroke="#A1A1AA" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 310 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.75 1.5L2.25 10.5H9L8.25 16.5L15.75 7.5H9L9.75 1.5Z" stroke="#A1A1AA" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

Some files were not shown because too many files have changed in this diff Show More