chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
// Copyright 2024 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/thanhpk/randstr"
|
||||
"github.com/wuhan005/gadget"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
)
|
||||
|
||||
// MaxUploadBytes 是单次图片上传的内存读取上限(8MB)。
|
||||
// 调用方虽在 HTTP 层对 fileHeader.Size 做了校验,但该值可被客户端伪造,
|
||||
// 因此在读取前用 io.LimitReader 兜底,避免超大文件将内存撑爆(DoS)。
|
||||
const MaxUploadBytes = 8 * 1024 * 1024
|
||||
|
||||
// aspect 约束一次上传的宽高比(wantW:wantH,容差 DefaultAspectTolerance)。
|
||||
// 头像固定 1:1、页面背景固定 16:9:客户端裁剪后即为精确比例,
|
||||
// 服务端复校验用于拦截绕过前端直接 POST 的越界图片。
|
||||
type aspect struct {
|
||||
W, H float64
|
||||
}
|
||||
|
||||
func (a *aspect) check(data []byte) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return imagecheck.CheckAspectRatio(bytes.NewReader(data), a.W, a.H, imagecheck.DefaultAspectTolerance)
|
||||
}
|
||||
|
||||
// UploadAvatarToS3 上传头像:除通用校验/转换外强制 1:1 宽高比。
|
||||
func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1})
|
||||
}
|
||||
|
||||
// UploadBackgroundToS3 上传页面背景:除通用校验/转换外强制 16:9 宽高比。
|
||||
func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9})
|
||||
}
|
||||
|
||||
// UploadPictureToS3 上传通用图片到 Cloudflare R2,自动转换为 WebP(动画 GIF 可选转换)
|
||||
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
|
||||
return uploadPictureToS3(file, fileHeader, nil)
|
||||
}
|
||||
|
||||
// uploadPictureToS3 是上传管线实现:读入 → 魔数 → (可选)比例校验 → 像素总量校验
|
||||
// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验复用同一管线,
|
||||
// 仅通过 aspect 参数区分,避免为一种用途复制一套上传代码。
|
||||
func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect) (string, error) {
|
||||
// 1. 限制读取大小,防止超大上传撑爆内存(DoS)。
|
||||
// 先用 LimitReader 截断到 MaxUploadBytes+1 字节;若实际读到的字节数超过上限则直接拒绝。
|
||||
limited := io.LimitReader(file, MaxUploadBytes+1)
|
||||
fileData, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "read file data")
|
||||
}
|
||||
if int64(len(fileData)) > MaxUploadBytes {
|
||||
return "", errors.New("uploaded file is too large")
|
||||
}
|
||||
|
||||
// 2. 校验文件魔数,仅允许 JPEG / PNG / GIF / WebP
|
||||
if !isSupportedImage(fileData) {
|
||||
return "", errors.New("unsupported file type: not a valid image")
|
||||
}
|
||||
|
||||
// 2.5 用途宽高比校验(头像 1:1 / 背景 16:9),绕过前端裁剪的直传在此被拦截
|
||||
if err := asp.check(fileData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fileReader := bytes.NewReader(fileData)
|
||||
|
||||
// 3. 解码前先校验像素总量(防解压炸弹),再解码图片获取格式;解码失败则直接返回错误。
|
||||
if err := imagecheck.CheckPixelLimit(bytes.NewReader(fileData)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, format, err := image.Decode(fileReader)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "image decode failed, file may be corrupted")
|
||||
}
|
||||
|
||||
var uploadData []byte
|
||||
var contentType string
|
||||
|
||||
// 重置 reader,以便后续可能的 resize 操作
|
||||
if _, err := fileReader.Seek(0, io.SeekStart); err != nil {
|
||||
return "", errors.Wrap(err, "reset reader")
|
||||
}
|
||||
|
||||
// 4. 根据格式进行转换或直接使用原图
|
||||
switch format {
|
||||
case "gif":
|
||||
webpData, err := convertGifToAnimatedWebP(fileData)
|
||||
if err != nil {
|
||||
// 转换失败则回退到原始 GIF
|
||||
uploadData = fileData
|
||||
contentType = "image/gif"
|
||||
} else {
|
||||
uploadData = webpData
|
||||
contentType = "image/webp"
|
||||
}
|
||||
default:
|
||||
// 静态图片(JPEG / PNG / WebP 等)转换为静态 WebP
|
||||
webpData, err := ConvertStaticImageToWebP(fileReader)
|
||||
if err != nil {
|
||||
// 转换失败时回退到原始图片
|
||||
uploadData = fileData
|
||||
contentType = mimeTypeFromFormat(format)
|
||||
} else {
|
||||
uploadData = webpData
|
||||
contentType = "image/webp"
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成存储路径(无后缀名)
|
||||
now := time.Now()
|
||||
year := now.Year()
|
||||
month := int(now.Month())
|
||||
day := now.Day()
|
||||
key := fmt.Sprintf("%s%d/%02d/%02d/%s", PictureKeyPrefix, year, month, day, randstr.Hex(15))
|
||||
|
||||
// 6. 获取共享 S3 客户端
|
||||
client, err := GetS3Client()
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "get S3 client")
|
||||
}
|
||||
|
||||
// 7. 上传到 R2
|
||||
if err := gadget.Retry(5, func() error {
|
||||
_, err := client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(conf.Upload.ImageBucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(uploadData),
|
||||
ContentLength: aws.Int64(int64(len(uploadData))),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return "", errors.Wrap(err, "put object after retry")
|
||||
}
|
||||
|
||||
// 8. 返回 CDN URL
|
||||
return fmt.Sprintf("https://%s/%s", conf.Upload.ImageBucketCDNHost, key), nil
|
||||
}
|
||||
|
||||
// ConvertStaticImageToWebP 将静态图片(JPEG/PNG等)转换为 WebP 格式
|
||||
// 限制最大宽度为 1920px,保持比例
|
||||
func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) {
|
||||
// 先整体读入并校验像素总量(防解压炸弹),再完整解码。
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "read image")
|
||||
}
|
||||
if err := imagecheck.CheckPixelLimit(bytes.NewReader(data)); err != nil {
|
||||
return nil, errors.Wrap(err, "check image pixel limit")
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "decode image")
|
||||
}
|
||||
|
||||
maxWidth := 1920
|
||||
if img.Bounds().Dx() > maxWidth {
|
||||
img = imaging.Resize(img, maxWidth, 0, imaging.Lanczos)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
|
||||
return nil, errors.Wrap(err, "webp encode")
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// convertGifToAnimatedWebP 将 GIF 文件数据转换为动画 WebP(需要系统安装 cwebp 工具)
|
||||
// 如果 cwebp 不可用,返回错误,调用方应回退到原始 GIF
|
||||
func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
|
||||
_, err := exec.LookPath("cwebp")
|
||||
if err != nil {
|
||||
return nil, errors.New("cwebp not found in PATH")
|
||||
}
|
||||
|
||||
tmpGif, err := os.CreateTemp("", "input_*.gif")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create temp gif")
|
||||
}
|
||||
defer os.Remove(tmpGif.Name())
|
||||
if _, err := tmpGif.Write(gifData); err != nil {
|
||||
return nil, errors.Wrap(err, "write temp gif")
|
||||
}
|
||||
tmpGif.Close()
|
||||
|
||||
tmpWebp, err := os.CreateTemp("", "output_*.webp")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create temp webp")
|
||||
}
|
||||
defer os.Remove(tmpWebp.Name())
|
||||
tmpWebp.Close()
|
||||
|
||||
cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.Wrap(err, "run cwebp")
|
||||
}
|
||||
|
||||
webpData, err := os.ReadFile(tmpWebp.Name())
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "read webp output")
|
||||
}
|
||||
return webpData, nil
|
||||
}
|
||||
|
||||
// mimeTypeFromFormat 根据图片格式返回 MIME 类型
|
||||
func mimeTypeFromFormat(format string) string {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// isSupportedImage 通过文件头魔数判断是否为支持的图片格式
|
||||
func isSupportedImage(data []byte) bool {
|
||||
if len(data) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return true
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47
|
||||
if bytes.Equal(data[:4], []byte{0x89, 0x50, 0x4E, 0x47}) {
|
||||
return true
|
||||
}
|
||||
|
||||
// GIF: GIF87a / GIF89a
|
||||
if len(data) >= 6 {
|
||||
if bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// WebP: RIFF ... WEBP
|
||||
if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.LangCode}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="description" content="{{ if .MetaDescription }}{{ .MetaDescription }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}">
|
||||
<meta name="keywords" content="{{ t .LangCode "site.keywords" }}">
|
||||
<!-- 预连接:UIkit 走公共 CDN(cdnjs.snrat.com),其余自托管库仍在本站 R2,均需预连接 -->
|
||||
<link rel="preconnect" href="https://r2.337845818.best" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.snrat.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cn.vercount.one">
|
||||
<!-- UIkit 3.3.3 切到公共 CDN(cdnjs.snrat.com,国内实测全量下载 ~0.25s,原 R2 自建 ~1.4-2.0s) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"/>
|
||||
<!-- Waline 评论区样式:非阻塞加载,不阻塞首屏渲染 -->
|
||||
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/waline/client/v2/waline-no-commits.css" media="print" onload="this.media='all'"/>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"></script>
|
||||
<!-- UIkit 补丁:复刻原自建版 "modify-black-font"(官方版与自建版仅 3 处颜色差异——把有色容器内默认白字/白标题改为黑字)。
|
||||
模板当前只用到 .uk-light 容器(用户背景封面,question/list.html、question-not-found.html;
|
||||
其中 h3/p 已由 #usernamecolor/#introcolor 内联 style 覆盖),故只需补该容器的 3 处投影即可保持渲染一致。
|
||||
若日后新增 .uk-card-primary/.uk-card-secondary/.uk-section-primary/.uk-tile-*/.uk-overlay-primary 等容器且需黑字,须同步扩展。 -->
|
||||
<style>
|
||||
.uk-light { color: rgba(0,0,0,.7); }
|
||||
.uk-light .uk-h1, .uk-light .uk-h2, .uk-light .uk-h3, .uk-light .uk-h4, .uk-light .uk-h5, .uk-light .uk-h6,
|
||||
.uk-light .uk-heading-2xlarge, .uk-light .uk-heading-large, .uk-light .uk-heading-medium, .uk-light .uk-heading-small, .uk-light .uk-heading-xlarge,
|
||||
.uk-light h1, .uk-light h2, .uk-light h3, .uk-light h4, .uk-light h5, .uk-light h6 { color: #000; }
|
||||
.uk-light .uk-table th { color: rgba(0,0,0,.7); }
|
||||
</style>
|
||||
<!-- dayjs/alpinejs/vconsole/waline.js 已迁 cdnjs.snrat.com(sha256 与官方一致,国内实测快 2-7x) -->
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/dayjs/1.10.8/dayjs.min.js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/alpinejs/3.9.0/cdn.min.js"></script>
|
||||
{{ if .VConsole }}
|
||||
<script src="https://cdnjs.snrat.com/ajax/libs/vConsole/3.12.1/vconsole.min.js" type="application/javascript"></script>
|
||||
<script>new window.VConsole()</script>
|
||||
{{ end }}
|
||||
<!-- jQuery 已改为按需加载:仅 easy.qrcode 二维码页(list/question-not-found/profile)自行引入 -->
|
||||
<script defer src="https://r2.337845818.best/tamabox/cdn/time.box.js"></script>
|
||||
<!--<script src="https://r2.337845818.best/tamabox/cdn/busuanzi.pure.mini.js"/></script>-->
|
||||
<script defer src="https://cn.vercount.one/js"></script>
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/waline/2.15.8/waline.js"></script>
|
||||
{{ if eq .CaptchaType "math" }}
|
||||
<!-- 算数验证码:弹窗模式,不影响原布局 -->
|
||||
<style>
|
||||
.captcha-equation-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f0f2f5;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
gap: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.captcha-equation-box:focus-within {
|
||||
border-color: #1e87f0;
|
||||
box-shadow: 0 0 0 3px rgba(30,135,240,0.12);
|
||||
}
|
||||
.math-captcha-modal-question {
|
||||
font-size: 1.4rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#captcha-modal-answer {
|
||||
width: 80px;
|
||||
padding: 6px 10px;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
background: #fff;
|
||||
}
|
||||
#captcha-modal-answer:focus {
|
||||
border-color: #1e87f0;
|
||||
box-shadow: 0 0 0 2px rgba(30,135,240,0.2);
|
||||
}
|
||||
/* 隐藏数字输入框的上下箭头 */
|
||||
#captcha-modal-answer::-webkit-inner-spin-button,
|
||||
#captcha-modal-answer::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
#captcha-modal-answer {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
{{ else if .RecaptchaTurnstileStyle }}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha" async defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.g-recaptcha').forEach(item => {
|
||||
item.disabled = true;
|
||||
});
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
document.querySelectorAll('.g-recaptcha').forEach(item => {
|
||||
item.disabled = false;
|
||||
})
|
||||
}</script>
|
||||
{{ else }}
|
||||
<script src="{{.RecaptchaDomain}}/recaptcha/api.js" async defer></script>
|
||||
<script>function onSubmit() {document.getElementById('form').submit();}</script>
|
||||
{{ end }}
|
||||
{{ if ne SentryDSN "" }}
|
||||
<script
|
||||
src="https://browser.sentry-cdn.com/7.37.2/bundle.tracing.replay.min.js"
|
||||
integrity="sha384-o0mU2P+zsvXua0Y6f/J55PKWZt3sZCv3qDqJe6J5lZklyIQkpgjBVqNnPWYWqGhf"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script>
|
||||
Sentry.init({
|
||||
dsn: "{{ SentryDSN }}",
|
||||
integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
<style>
|
||||
.grecaptcha-badge {visibility: hidden;}
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
width: 20px; /* 设置图标的宽度 */
|
||||
height: auto; /* 保持图标的高度自动调整 */
|
||||
}
|
||||
.emoji-placeholder {
|
||||
display: inline-block;
|
||||
animation: jump 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
/* 防止 UIkit Dropbar 初始闪烁 */
|
||||
.uk-dropbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.uk-dropbar.uk-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
a.uk-button.uk-button-link {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* 移动端优化:缩小字体和间距,避免右侧空白 */
|
||||
@media (max-width: 640px) {
|
||||
.uk-logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.78rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.emoji-placeholder {
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* 移动端用户名限宽省略,避免长英文用户名撑爆顶栏 */
|
||||
.tb-nav-username {
|
||||
max-width: 64px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 深色模式:html[data-theme="dark"] 时覆盖 UIkit 3.3.3 编译主题的关键颜色 -->
|
||||
<style>
|
||||
html[data-theme="dark"] {
|
||||
--tb-bg: #1a1d23;
|
||||
--tb-surface: #242830;
|
||||
--tb-surface-2: #2d323c;
|
||||
--tb-border: #3a404c;
|
||||
--tb-text: #d8dce4;
|
||||
--tb-text-muted: #8b93a1;
|
||||
--tb-primary: #5c8dff;
|
||||
}
|
||||
/* 根元素与 body 一起覆盖,确保整个视口(含内容不足一屏时的空白区)都是深色背景 */
|
||||
html[data-theme="dark"],
|
||||
html[data-theme="dark"] body {
|
||||
background-color: var(--tb-bg) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
/* 通用表面容器 */
|
||||
html[data-theme="dark"] .uk-card,
|
||||
html[data-theme="dark"] .uk-card-default,
|
||||
html[data-theme="dark"] .uk-card-secondary,
|
||||
html[data-theme="dark"] .uk-dropbar,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown,
|
||||
html[data-theme="dark"] .uk-modal-dialog,
|
||||
html[data-theme="dark"] .uk-dropdown,
|
||||
html[data-theme="dark"] .uk-offcanvas-bar,
|
||||
html[data-theme="dark"] .uk-section,
|
||||
html[data-theme="dark"] .uk-tile,
|
||||
html[data-theme="dark"] .uk-panel,
|
||||
html[data-theme="dark"] .uk-background-default,
|
||||
html[data-theme="dark"] .uk-search-default .uk-search-input {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-container {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-nav > li > a,
|
||||
html[data-theme="dark"] .uk-card a,
|
||||
html[data-theme="dark"] .uk-link,
|
||||
html[data-theme="dark"] a.uk-button-link,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a,
|
||||
html[data-theme="dark"] .uk-dropdown-nav > li > a,
|
||||
html[data-theme="dark"] .uk-list a {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-navbar-nav > li > a:hover,
|
||||
html[data-theme="dark"] .uk-card a:hover,
|
||||
html[data-theme="dark"] .uk-navbar-dropdown-nav > li > a:hover,
|
||||
html[data-theme="dark"] .uk-dropdown-nav > li > a:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-input,
|
||||
html[data-theme="dark"] .uk-textarea,
|
||||
html[data-theme="dark"] .uk-select {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-input:focus,
|
||||
html[data-theme="dark"] .uk-textarea:focus,
|
||||
html[data-theme="dark"] .uk-select:focus {
|
||||
border-color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-text-muted,
|
||||
html[data-theme="dark"] .uk-text-meta,
|
||||
html[data-theme="dark"] .uk-text-secondary,
|
||||
html[data-theme="dark"] .uk-text-muted a,
|
||||
html[data-theme="dark"] .uk-text-small.uk-text-muted {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-divider-icon::after,
|
||||
html[data-theme="dark"] hr,
|
||||
html[data-theme="dark"] .uk-table th,
|
||||
html[data-theme="dark"] .uk-table td,
|
||||
html[data-theme="dark"] .uk-list-divider > li:nth-child(n+2) {
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-button-default {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-modal-title,
|
||||
html[data-theme="dark"] h1, html[data-theme="dark"] h2, html[data-theme="dark"] h3,
|
||||
html[data-theme="dark"] h4, html[data-theme="dark"] h5, html[data-theme="dark"] h6,
|
||||
html[data-theme="dark"] .uk-card-title {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-alert-warning,
|
||||
html[data-theme="dark"] .uk-alert-danger,
|
||||
html[data-theme="dark"] .uk-alert-success,
|
||||
html[data-theme="dark"] .uk-alert-primary {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .uk-modal-dialog {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .captcha-equation-box {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .math-captcha-modal-question {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer {
|
||||
background-color: var(--tb-surface) !important;
|
||||
color: var(--tb-text) !important;
|
||||
border-color: var(--tb-border) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-answer::placeholder {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal .uk-text-meta,
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
html[data-theme="dark"] #math-captcha-modal #captcha-modal-refresh:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .uk-label {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* 自定义组件(探索页推荐卡片等)深色覆盖 */
|
||||
html[data-theme="dark"] .recommend-card,
|
||||
html[data-theme="dark"] .recommend-card:hover {
|
||||
background-color: var(--tb-surface-2) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-name a {
|
||||
color: var(--tb-text) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-name a:hover {
|
||||
color: var(--tb-primary) !important;
|
||||
}
|
||||
html[data-theme="dark"] .explore-intro {
|
||||
color: var(--tb-text-muted) !important;
|
||||
}
|
||||
/* 切换按钮图标切换 */
|
||||
#theme-toggle .tb-icon-dark, #theme-toggle-mobile .tb-icon-dark { display: none; }
|
||||
html[data-theme="dark"] #theme-toggle .tb-icon-light,
|
||||
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-light { display: none; }
|
||||
html[data-theme="dark"] #theme-toggle .tb-icon-dark,
|
||||
html[data-theme="dark"] #theme-toggle-mobile .tb-icon-dark { display: inline; }
|
||||
</style>
|
||||
|
||||
<!-- 顶栏自适应:导航全部直接显示,窄屏时分级缩小字号/间距,保证英文/日文等长文字不溢出 -->
|
||||
<style>
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.95rem;
|
||||
padding: 0 10px;
|
||||
}
|
||||
/* 未登录移动端占位:桌面端隐藏,仅 ≤640px 显示(见下方 media 查询)。
|
||||
桌面端布局与改动前完全一致,不受影响。 */
|
||||
.tb-mobile-balancer { display: none; }
|
||||
/* 桌面端保持 UIkit 原生居中三列布局(无需额外 CSS) */
|
||||
.tb-navbar .uk-logo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.88rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.82rem;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.uk-logo {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
/* 登录用户名限宽,过长省略号截断 */
|
||||
.tb-nav-username {
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.uk-navbar-nav a {
|
||||
font-size: 0.78rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.uk-logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.tb-nav-username {
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
/* 手机端(≤640px):沿用 UIkit 桌面端 navbar 的原生三列定位。
|
||||
不再重写 .uk-navbar-center / center-left / center-right 的定位,
|
||||
避免 logo 被挤到下一行;底部语言与深色模式按钮仍由下方独立栏承载。 */
|
||||
@media (max-width: 640px) {
|
||||
.tb-navbar {
|
||||
position: relative;
|
||||
min-height: 56px;
|
||||
overflow: visible;
|
||||
}
|
||||
.tb-navbar > .uk-navbar-center {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-center-left,
|
||||
.tb-navbar .uk-navbar-center-right {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-center-left > div,
|
||||
.tb-navbar .uk-navbar-center-right > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-navbar .uk-navbar-nav {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tb-navbar .uk-navbar-nav > li > a,
|
||||
.tb-navbar .uk-logo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 未登录:移动端显示与「我的」等宽的隐藏占位,使左栏「探索+占位」与
|
||||
右栏「注册/登录」两项等宽,logo 真正居中,左右视觉平衡 */
|
||||
.tb-mobile-balancer { display: block; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 标签样式的按钮(管理页置顶等):
|
||||
尺寸完全继承 .uk-label(font-size/padding/line-height/text-transform 等),
|
||||
仅覆盖 button 元素特有的默认样式(边框/字体族/光标等),保证与相邻 span 标签完全一致。 -->
|
||||
<style>
|
||||
.tb-label-btn {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
outline: none;
|
||||
background-image: none;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.tb-label-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* 公开页置顶小图标:仅一个图钉,不占行高、不影响原布局 */
|
||||
.tb-pin-flag {
|
||||
font-size: 0.9em;
|
||||
margin-right: 4px;
|
||||
vertical-align: -1px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 页脚统计加载动画:替代 fontawesome 的 fa-spinner(纯 CSS,不引字体库) -->
|
||||
<style>
|
||||
.tb-spinner {
|
||||
display: inline-block;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
border: 2px solid rgba(128, 128, 128, 0.25);
|
||||
border-top-color: #666;
|
||||
border-radius: 50%;
|
||||
vertical-align: -2px;
|
||||
animation: tb-spin 0.8s linear infinite;
|
||||
}
|
||||
html[data-theme="dark"] .tb-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #bbb;
|
||||
}
|
||||
@keyframes tb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Markdown 渲染排版(问题/回答/追问正文,左对齐以保留排版格式) -->
|
||||
<style>
|
||||
/* 纯文本列表:保留换行与连续空格(pre-wrap),不渲染 Markdown 大字号/彩色 */
|
||||
.tb-preline {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 纯文本列表:超过 5 行截断并显示省略号(不足 5 行则完整显示) */
|
||||
.tb-clamp-5 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-body {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
.md-body p { margin: 0.5em 0; }
|
||||
.md-body h1, .md-body h2, .md-body h3,
|
||||
.md-body h4, .md-body h5, .md-body h6 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.md-body ul, .md-body ol { padding-left: 1.6em; margin: 0.5em 0; }
|
||||
.md-body li { margin: 0.2em 0; }
|
||||
.md-body blockquote {
|
||||
border-left: 3px solid #d3d1c7;
|
||||
margin: 0.6em 0;
|
||||
padding: 0.2em 1em;
|
||||
color: #5f5e5a;
|
||||
}
|
||||
.md-body pre {
|
||||
background: #f5f5f5;
|
||||
padding: 0.8em 1em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
.md-body code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.md-body pre code { background: none; padding: 0; }
|
||||
.md-body img { max-width: 100%; height: auto; }
|
||||
.md-body table { border-collapse: collapse; margin: 0.6em 0; }
|
||||
.md-body th, .md-body td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4em 0.8em;
|
||||
}
|
||||
.md-body hr { margin: 1em 0; }
|
||||
html[data-theme="dark"] .md-body blockquote {
|
||||
border-color: #3a404c;
|
||||
color: #8b93a1;
|
||||
}
|
||||
html[data-theme="dark"] .md-body pre,
|
||||
html[data-theme="dark"] .md-body code { background: #2d323c; }
|
||||
html[data-theme="dark"] .md-body th,
|
||||
html[data-theme="dark"] .md-body td { border-color: #3a404c; }
|
||||
</style>
|
||||
|
||||
<!-- 投票结果占比条:UIkit 3 已无 .uk-progressbar 类(那是 UIkit 2 的),
|
||||
模板用 <div class="uk-progress"><div class="uk-progressbar"> 结构,需补一套填充样式,
|
||||
否则内层填充无高度/背景,所有占比条都只剩外层灰色轨道,看起来一模一样。 -->
|
||||
<style>
|
||||
.uk-progress {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #e5e5e5;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
.uk-progressbar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: #1e87f0;
|
||||
border-radius: 6px 0 0 6px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
html[data-theme="dark"] .uk-progress {
|
||||
background-color: var(--tb-border);
|
||||
}
|
||||
html[data-theme="dark"] .uk-progressbar {
|
||||
background-color: var(--tb-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 移动端底部操作栏:固定底栏承载语言切换与深色模式,桌面端隐藏 -->
|
||||
<style>
|
||||
#tb-mobile-utils {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* 顶栏不再容纳语言/深色按钮,恢复原有导航空间 */
|
||||
.tb-nav-utils {
|
||||
display: none !important;
|
||||
}
|
||||
/* 手机端隐藏登录用户名(左边仅保留"探索"),保证 logo 居中、
|
||||
两侧各不超过 2 个短按钮,避免用户名宽度挤压布局 */
|
||||
.tb-nav-username {
|
||||
display: none !important;
|
||||
}
|
||||
/* 固定底部操作栏 */
|
||||
#tb-mobile-utils {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
#tb-mobile-utils .uk-select {
|
||||
width: 110px;
|
||||
}
|
||||
html[data-theme="dark"] #tb-mobile-utils {
|
||||
background: rgba(36, 40, 48, 0.92);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* 给页面底部预留空间,避免页脚内容被固定底栏遮挡 */
|
||||
body {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Commit 6d0a3ad -->
|
||||
<meta property="og:site_name" content="{{ t .LangCode "site.title" }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.title" }}{{ else }}{{ t .LangCode "site.title" }}{{ end }}" />
|
||||
<meta property="og:url" content="{{ .ExternalURL }}{{ .CurrentURI }}" />
|
||||
<meta property="og:description" content="{{ if .IsUserPage }}{{ .PageUser.Name }} - {{ t .LangCode "site.description" }}{{ else }}{{ t .LangCode "site.description" }}{{ end }}" />
|
||||
{{ if .NoIndex }}<meta name="robots" content="noindex">{{ end }}
|
||||
{{ if .IsUserPage }}
|
||||
<meta property="og:image" content="{{ .PageUser.Avatar }}?og-image=1" />
|
||||
<meta property="og:image:alt" content="{{ .PageUser.Name }}" />
|
||||
<meta itemprop="image" content="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<meta name="msapplication-TileImage" content="{{ .PageUser.Avatar }}"/>
|
||||
<link rel="shortcut icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<link rel="apple-touch-icon" href="{{ .PageUser.Avatar }}?og-image=1"/>
|
||||
<link rel="icon" sizes="192x192" href="{{ .PageUser.Avatar }}?og-image=1">
|
||||
<meta name="twitter:image:src" content="{{ .PageUser.Avatar }}?og-image=1" />
|
||||
{{ else }}
|
||||
<meta itemprop="image" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<meta name="msapplication-TileImage" content="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<link rel="shortcut icon" href="https://r2.337845818.best/tamabox/tamabox_low.png" />
|
||||
<link rel="apple-touch-icon" href="https://r2.337845818.best/tamabox/tamabox_low.png"/>
|
||||
<link rel="icon" sizes="192x192" href="https://r2.337845818.best/tamabox/tamabox_low.png">
|
||||
<meta name="twitter:image:src" content="https://r2.337845818.best/tamabox/tamabox_low.png" />
|
||||
{{ end }}
|
||||
|
||||
<!-- Google and Bing -->
|
||||
<meta name="google-site-verification" content="fEPwRRJfiGsgwc5SINBQwZhPK8F0cwRaOvRx0YRurI4" />
|
||||
<meta name="msvalidate.01" content="2F185E8E63C93635F50B3574F3B31E42" />
|
||||
<script defer src="https://mami.tama.guru/tamami.js" data-website-id="c42ebe8b-9281-4da0-83a4-8f18b3e710fe"></script>
|
||||
</head>
|
||||
<nav class="uk-navbar-container uk-margin tb-navbar" uk-navbar>
|
||||
<div class="uk-navbar-center">
|
||||
<div class="uk-navbar-center-left">
|
||||
<div>
|
||||
<ul class="uk-navbar-nav">
|
||||
<!-- 探索链接,始终显示 -->
|
||||
<li><a href="/explore">{{t .LangCode "nav.explore"}}</a></li>
|
||||
{{ if .IsLogged }}
|
||||
<!-- 我的:进入自己的提问箱主页(与右侧「提问/设置」形成左右均衡) -->
|
||||
<li><a href="/_/{{ .LoggedUser.Domain }}">{{t .LangCode "nav.mine"}}</a></li>
|
||||
{{ else }}
|
||||
<!-- 移动端占位:未登录时左栏仅「探索」一项,与右栏「注册/登录」两项不等宽,
|
||||
导致 logo 整体被拉向左(偏左)。移动端(≤640px)显示一个与「我的」等宽的隐藏占位,
|
||||
使左右视觉平衡;桌面端由 .tb-mobile-balancer 隐藏,保持原布局完全不变。 -->
|
||||
<li class="tb-mobile-balancer" aria-hidden="true"><a tabindex="-1" style="visibility:hidden">{{t .LangCode "nav.mine"}}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="uk-navbar-item uk-logo" href="/"><span class="emoji-placeholder" aria-label="random animal">🐺</span>{{t .LangCode "site.logoSuffix"}}</a>
|
||||
|
||||
<div class="uk-navbar-center-right">
|
||||
<div>
|
||||
{{ if .IsLogged }}
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/user/questions">{{t .LangCode "nav.ask"}}</a></li>
|
||||
</ul>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/user/profile">{{t .LangCode "nav.settings"}}</a></li>
|
||||
</ul>
|
||||
{{ else}}
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/register">{{t .LangCode "nav.register"}}</a></li>
|
||||
</ul>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="/login">{{t .LangCode "nav.login"}}</a></li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{ if not .IsUserPage }}
|
||||
<!-- 语言切换(紧凑图标,点击展开;移动端隐藏,见底部操作栏) -->
|
||||
<ul class="uk-navbar-nav tb-nav-utils">
|
||||
<li>
|
||||
<a href="#" aria-label="{{t .LangCode "common.lang"}}" title="{{t .LangCode "common.lang"}}">🌐</a>
|
||||
<div class="uk-navbar-dropdown" uk-drop="mode: click; pos: bottom-right">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
{{ range .SupportedLangs }}
|
||||
<li class="{{ if eq .Code $.LangCode }}uk-active{{ end }}">
|
||||
<a href="/i18n/{{ .Code }}?to={{ urlquery $.CurrentURI }}">{{ .Name }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{{ end }}
|
||||
<!-- 深色模式切换(紧凑图标;移动端隐藏,见底部操作栏) -->
|
||||
<ul class="uk-navbar-nav tb-nav-utils">
|
||||
<li>
|
||||
<a href="#" id="theme-toggle" title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
|
||||
<span class="tb-icon-light">🌙</span><span class="tb-icon-dark">☀️</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const emojis = ["🐺","🐗","🐻","🐨","🐼","🐹","🐭","🐰","🦊","🐮","🐷","🐽","🐸","🐒","🦍","🐔","🐧","🐦","🐤","🐦⬛","🦅","🦉","🦇","🐴","🦄","🐝","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🦧","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐕🦺","🐈","🐋","🐳","🐬","🐉","🐲"];
|
||||
const placeholder = document.querySelector('.emoji-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.textContent = emojis[Math.floor(Math.random() * emojis.length)];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 移动端固定底部操作栏:语言切换 + 深色模式(桌面端隐藏) -->
|
||||
<div id="tb-mobile-utils">
|
||||
{{ if not .IsUserPage }}
|
||||
<select id="mobile-lang-select" class="uk-select uk-form-small" aria-label="{{t .LangCode "common.lang"}}">
|
||||
{{ range .SupportedLangs }}
|
||||
<option value="{{ .Code }}" {{ if eq .Code $.LangCode }}selected{{ end }}>{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{ end }}
|
||||
<button type="button" id="theme-toggle-mobile" class="uk-button uk-button-default uk-button-small"
|
||||
title="{{t .LangCode "common.theme"}}" aria-label="{{t .LangCode "common.theme"}}">
|
||||
<span class="tb-icon-light">🌙</span><span class="tb-icon-dark">☀️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// 深色模式:读取 cookie 应用主题,按钮切换并持久化
|
||||
var THEME_COOKIE = 'theme';
|
||||
function getTheme() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
function toggleTheme() {
|
||||
var next = getTheme() === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
var expires = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
|
||||
document.cookie = THEME_COOKIE + '=' + next + '; path=/; max-age=' + (365 * 24 * 3600) + '; SameSite=Lax';
|
||||
}
|
||||
// 首屏前应用,避免闪烁
|
||||
applyTheme(getTheme());
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 桌面顶栏按钮与移动端底部按钮都绑定
|
||||
var buttons = document.querySelectorAll('#theme-toggle, #theme-toggle-mobile');
|
||||
buttons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
});
|
||||
});
|
||||
|
||||
// 移动端底部语言切换:onchange 跳转到 /i18n/{code},保留当前页面位置
|
||||
var langSel = document.getElementById('mobile-lang-select');
|
||||
if (langSel) {
|
||||
langSel.addEventListener('change', function() {
|
||||
if (!this.value) return;
|
||||
var to = window.location.pathname + window.location.search;
|
||||
window.location.href = '/i18n/' + encodeURIComponent(this.value) + '?to=' + encodeURIComponent(to);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{ if .IsPixel }}
|
||||
<div>
|
||||
{{ else }}
|
||||
<div class="uk-container uk-container-xsmall">
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .CaptchaType "math" }}
|
||||
<!-- 算数验证码弹窗 --><div id="math-captcha-modal" uk-modal>
|
||||
<div class="uk-modal-dialog uk-modal-body uk-text-center">
|
||||
<h3 class="uk-modal-title">{{t .LangCode "captcha.title"}}</h3>
|
||||
<p class="uk-text-meta">{{t .LangCode "captcha.hint"}}</p>
|
||||
<div class="captcha-equation-box">
|
||||
<span id="captcha-modal-question" class="math-captcha-modal-question">{{t .LangCode "captcha.loading"}}</span>
|
||||
<input type="number" id="captcha-modal-answer" placeholder="?" autocomplete="off" inputmode="numeric">
|
||||
</div>
|
||||
<div style="margin-top:6px;">
|
||||
<button type="button" class="uk-button uk-button-text uk-text-small" id="captcha-modal-refresh">{{t .LangCode "captcha.refresh"}}</button>
|
||||
</div>
|
||||
<div id="captcha-modal-error" class="uk-text-danger uk-text-small" style="display:none;min-height:20px;"></div>
|
||||
<div class="uk-margin-small-top">
|
||||
<button type="button" class="uk-button uk-button-primary" id="captcha-modal-submit">{{t .LangCode "captcha.submit"}}</button>
|
||||
<button type="button" class="uk-button uk-button-default uk-modal-close">{{t .LangCode "common.cancel"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CAPTCHA_I18N = {
|
||||
loading: "{{t .LangCode "captcha.loading"}}",
|
||||
loadFailed: "{{t .LangCode "captcha.loadFailed"}}",
|
||||
loadRetry: "{{t .LangCode "captcha.loadRetry"}}",
|
||||
inputAnswer: "{{t .LangCode "captcha.inputAnswer"}}",
|
||||
wrongAnswer: "{{t .LangCode "captcha.wrongAnswer"}}",
|
||||
verifyFailed: "{{t .LangCode "captcha.verifyFailed"}}",
|
||||
needPassword: "{{t .LangCode "ask.needPassword"}}",
|
||||
needEmail: "{{t .LangCode "ask.pleaseEmail"}}"
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var captchaId = '';
|
||||
var pendingForm = null;
|
||||
var pendingNeedPassword = false;
|
||||
var verifying = false;
|
||||
|
||||
function fetchCaptcha() {
|
||||
var qEl = document.getElementById('captcha-modal-question');
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
var ansEl = document.getElementById('captcha-modal-answer');
|
||||
qEl.textContent = CAPTCHA_I18N.loading;
|
||||
fetch('/api/captcha/generate')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.code === 0 && data.data) {
|
||||
captchaId = data.data.id;
|
||||
qEl.textContent = data.data.question;
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
if (ansEl) { ansEl.value = ''; ansEl.focus(); }
|
||||
} else {
|
||||
qEl.textContent = CAPTCHA_I18N.loadFailed;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
qEl.textContent = CAPTCHA_I18N.loadRetry;
|
||||
});
|
||||
}
|
||||
|
||||
function getCSRFToken() {
|
||||
var input = document.querySelector('input[name="_csrf"]');
|
||||
return input ? input.value : '';
|
||||
}
|
||||
|
||||
function doSubmit(form) {
|
||||
// 将 captcha 答案填入隐藏字段
|
||||
var ans = document.getElementById('captcha-modal-answer').value.trim();
|
||||
var hidden = form.querySelector('[name="g-recaptcha-response"]');
|
||||
if (hidden) hidden.value = captchaId + ':' + (ans || '0');
|
||||
|
||||
UIkit.modal('#math-captcha-modal').hide();
|
||||
|
||||
// 处理 AskPassword(提问箱密码)
|
||||
if (pendingNeedPassword) {
|
||||
UIkit.modal.prompt(CAPTCHA_I18N.needPassword, '', 'password')
|
||||
.then(function(password) {
|
||||
if (password) {
|
||||
var pwInput = document.getElementById('ask_password_input');
|
||||
if (pwInput) pwInput.value = password;
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
// 验证按钮
|
||||
document.getElementById('captcha-modal-submit').addEventListener('click', function() {
|
||||
if (verifying) return;
|
||||
var ans = document.getElementById('captcha-modal-answer').value.trim();
|
||||
if (!ans) {
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.inputAnswer;
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
verifying = true;
|
||||
fetch('/api/captcha/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({ id: captchaId, answer: parseInt(ans, 10) })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
verifying = false;
|
||||
if (data.correct) {
|
||||
if (pendingForm) doSubmit(pendingForm);
|
||||
} else {
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.wrongAnswer;
|
||||
errEl.style.display = 'block';
|
||||
fetchCaptcha(); // 自动刷新题目
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
verifying = false;
|
||||
var errEl = document.getElementById('captcha-modal-error');
|
||||
errEl.textContent = CAPTCHA_I18N.verifyFailed;
|
||||
errEl.style.display = 'block';
|
||||
fetchCaptcha();
|
||||
});
|
||||
});
|
||||
|
||||
// 回车提交
|
||||
document.getElementById('captcha-modal-answer').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
document.getElementById('captcha-modal-submit').click();
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新按钮
|
||||
document.getElementById('captcha-modal-refresh').addEventListener('click', fetchCaptcha);
|
||||
|
||||
// 拦截所有带 class="math-captcha-trigger" 的按钮
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.math-captcha-trigger');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
|
||||
var form = btn.closest('form');
|
||||
if (!form) return;
|
||||
pendingForm = form;
|
||||
|
||||
// 检查是否需要提问密码
|
||||
pendingNeedPassword = !!(form.querySelector('#ask_password_input'));
|
||||
|
||||
// 邮箱校验(提问页面)
|
||||
var notifyCb = document.getElementById('notify-checkbox');
|
||||
var emailInput = document.getElementById('email-input');
|
||||
if (notifyCb && notifyCb.checked && emailInput && emailInput.value.trim() === '') {
|
||||
UIkit.modal.alert(CAPTCHA_I18N.needEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchCaptcha();
|
||||
UIkit.modal('#math-captcha-modal').show();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,309 @@
|
||||
{{template "base/header" .}}
|
||||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目(R2 对无 Origin 请求的响应无 Vary,可缓存 31 天) -->
|
||||
<link rel="shortcut icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="apple-touch-icon" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="icon" sizes="192x192" href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||||
<style>
|
||||
/* 投票列表:与探索页"全部投票"一致的列表行样式 */
|
||||
.profile-votes-list { display: grid; gap: 0; }
|
||||
.profile-vote-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--tb-border, #e9e9e9);
|
||||
text-decoration: none;
|
||||
}
|
||||
.profile-vote-item:first-child { border-top: 1px solid var(--tb-border, #e9e9e9); }
|
||||
.profile-vote-item:hover { background: var(--tb-surface-hover, #f8fbff); text-decoration: none; }
|
||||
.profile-vote-content { min-width: 0; }
|
||||
.profile-vote-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--tb-text, #333);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.profile-vote-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.profile-vote-meta .uk-label { font-size: 0.72rem; line-height: 1.6; }
|
||||
.profile-vote-action {
|
||||
flex: 0 0 auto;
|
||||
color: var(--tb-primary, #1e87f0);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html[data-theme="dark"] .profile-vote-item { border-color: var(--tb-border); }
|
||||
html[data-theme="dark"] .profile-vote-title { color: var(--tb-text); }
|
||||
html[data-theme="dark"] .profile-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
|
||||
/* 个人主页 Tab:与探索页一致的胶囊切换样式 */
|
||||
/* 个人主页 Tab:全宽等分,置于主体顶部,作为一级导航(移动端整行可点) */
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--tb-border, #e5e5e5);
|
||||
border-radius: 9px;
|
||||
background: var(--tb-surface-2, #fafafa);
|
||||
}
|
||||
.profile-tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 9px 15px;
|
||||
background: transparent;
|
||||
color: var(--tb-text-muted, #666);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.profile-tab:hover { color: var(--tb-primary, #1e87f0); }
|
||||
.profile-tab.is-active {
|
||||
background: var(--tb-surface, #fff);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
|
||||
color: var(--tb-primary, #1e87f0);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 功能关闭时置灰禁用的 Tab(如投票功能关闭) */
|
||||
.profile-tab.is-disabled,
|
||||
.profile-tab:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--tb-text-muted, #999);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.profile-tab.is-disabled:hover { color: var(--tb-text-muted, #999); }
|
||||
.profile-panel[hidden] { display: none !important; }
|
||||
html[data-theme="dark"] .profile-tabs {
|
||||
border-color: var(--tb-border);
|
||||
background: var(--tb-surface-2);
|
||||
}
|
||||
html[data-theme="dark"] .profile-tab { color: var(--tb-text-muted); }
|
||||
html[data-theme="dark"] .profile-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
|
||||
html[data-theme="dark"] .profile-tab.is-disabled { color: var(--tb-text-muted); opacity: 0.55; }
|
||||
</style>
|
||||
<div class="uk-card uk-card-default uk-text-center">
|
||||
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
|
||||
<div class="uk-card-body">
|
||||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||||
<div class="icon-container">
|
||||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("usernamecolor").style.color = "{{.PageUser.Usernamecolor}}";
|
||||
document.getElementById("introcolor").style.color = "{{.PageUser.Introcolor}}";
|
||||
</script>
|
||||
<div>
|
||||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||||
{{ if .CanAsk }}
|
||||
<!-- jQuery 已改为按需加载:defer 按文档顺序先于 easy.qrcode 执行,保证就绪 -->
|
||||
<script defer src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script defer src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||||
<center><div id="boxqrimg"></div></center>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var options = {
|
||||
text: window.location.href,
|
||||
width: 200,
|
||||
height: 200,
|
||||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
logo: (function () {
|
||||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||||
})(),
|
||||
logoWidth: 60,
|
||||
logoHeight: 60,
|
||||
logoBackgroundTransparent: true,
|
||||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||||
crossOrigin: 'anonymous',
|
||||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||||
autoColor: false,
|
||||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
AO: '',
|
||||
AI: '',
|
||||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||||
};
|
||||
new QRCode(document.getElementById("boxqrimg"), options);
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="uk-text-center uk-text-small">{{t .LangCode "ask.scan" .PageUser.Name}}<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||||
|
||||
<div class="uk-text-center uk-margin-small-top">
|
||||
<a id="share-btn" href="/_/{{ .PageUser.Domain }}/share-card" download class="uk-button uk-button-default uk-button-small">
|
||||
📤 {{t .LangCode "ask.share"}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="share-loading-overlay" style="display:none; position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.85); z-index:9999;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;">
|
||||
<div uk-spinner="ratio: 2" class="uk-margin-bottom"></div>
|
||||
<p class="uk-text-large uk-text-muted">{{t .LangCode "ask.generating"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('share-btn');
|
||||
var overlay = document.getElementById('share-loading-overlay');
|
||||
if (!btn || !overlay) return;
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
overlay.style.display = 'block';
|
||||
btn.style.pointerEvents = 'none';
|
||||
fetch(btn.href)
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('{{t .LangCode "ask.networkError"}}');
|
||||
return response.blob();
|
||||
})
|
||||
.then(function(blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '{{t .LangCode "ask.shareFilename"}}';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('{{t .LangCode "ask.genFailed"}}');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(function() {
|
||||
overlay.style.display = 'none';
|
||||
btn.style.pointerEvents = 'auto';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<!-- 提问 / 投票 切换:默认显示提问,支持 ?tab=votes 直达(服务端初始态)与 #votes hash 覆盖 -->
|
||||
<div class="profile-tabs" role="tablist">
|
||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "questions" }} is-active{{ end }}" data-profile-tab="questions" role="tab" aria-selected="{{ if eq .ActiveProfileTab "questions" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
|
||||
{{ if .VoteEnabled }}
|
||||
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "votes" }} is-active{{ end }}" data-profile-tab="votes" role="tab" aria-selected="{{ if eq .ActiveProfileTab "votes" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.votesTab"}}</button>
|
||||
{{ else }}
|
||||
<!-- 投票功能关闭:保留 Tab 展示但置灰禁用,hover 提示原因 -->
|
||||
<button type="button" class="profile-tab is-disabled" disabled aria-disabled="true" uk-tooltip="{{ .VoteClosedMessage }}">{{t .LangCode "explore.votesTab"}} <span uk-icon="icon: lock; ratio: 0.8"></span></button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="profile-panel" data-profile-panel="questions"{{ if eq .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||||
{{template "question/new-question-template" .}}
|
||||
|
||||
{{ if ne (len .PageQuestions) 0 }}
|
||||
<p class="uk-text-left uk-text-muted uk-text-small">{{t .LangCode "ask.history" .PageUser.Name}} ({{ .AnsweredCount }})</p>
|
||||
{{ template "question/history-template" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="profile-panel" data-profile-panel="votes"{{ if ne .ActiveProfileTab "votes" }} hidden{{ end }}>
|
||||
{{ if .PageVotes }}
|
||||
<!-- 匿名投票板块:箱主"进行中"的公开投票(列表行样式,与探索页"全部投票"一致) -->
|
||||
<div class="profile-votes-list">
|
||||
{{ range .PageVotes }}
|
||||
<a href="/vote/{{ .LinkToken }}" class="profile-vote-item">
|
||||
<div class="profile-vote-content">
|
||||
<span class="profile-vote-title" title="{{ .Title }}">{{ .Title }}</span>
|
||||
<span class="profile-vote-meta">
|
||||
{{ if eq .Type "single" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{ end }}
|
||||
{{ if eq .Type "multiple" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{ end }}
|
||||
{{ if eq .Type "rating" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{ end }}
|
||||
{{ if eq .Type "rank" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{ end }}
|
||||
{{ if eq .Type "form" }}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{ end }}
|
||||
<span class="uk-text-meta">{{ .CreatedAt.Format "2006-01-02" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="profile-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="uk-alert uk-alert-primary uk-margin-remove-top">{{t .LangCode "explore.noVotes"}}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var tabs = document.querySelectorAll('[data-profile-tab]');
|
||||
var panels = document.querySelectorAll('[data-profile-panel]');
|
||||
function showPanel(name) {
|
||||
tabs.forEach(function(tab) {
|
||||
var active = tab.dataset.profileTab === name;
|
||||
tab.classList.toggle('is-active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
panels.forEach(function(panel) {
|
||||
panel.hidden = panel.dataset.profilePanel !== name;
|
||||
});
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.search + (name === 'votes' ? '#votes' : ''));
|
||||
}
|
||||
}
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab); });
|
||||
});
|
||||
// 仅当投票 Tab 可点击(功能开启)时才响应 #votes hash,避免投票关闭时切到空面板
|
||||
if (window.location.hash === '#votes' && document.querySelector('[data-profile-tab="votes"]')) {
|
||||
showPanel('votes');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{template "base/footer" .}}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
{{template "base/header" .}}
|
||||
<!--<link rel="stylesheet" href="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-y/uikit/3.3.3/css/uikit.min.css">-->
|
||||
<!-- 头像直连 R2:crossorigin 让 favicon 也以 CORS 模式请求,确保浏览器缓存的响应都带 ACAO;
|
||||
?v=2 击穿历史上被「无 ACAO 响应」污染的缓存条目 -->
|
||||
<link rel="shortcut icon"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="apple-touch-icon"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous"/>
|
||||
<link rel="icon" sizes="192x192"
|
||||
href="{{.PageUser.Avatar}}?v=2" crossorigin="anonymous">
|
||||
<div class="uk-card uk-card-default uk-text-center">
|
||||
<div class="uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light" style="min-height: 260px;" data-src="{{ .PageUser.Background }}" uk-img>
|
||||
<div class="uk-card-body">
|
||||
<img class="uk-border-circle uk-box-shadow-large" src="{{ if .PageUser.Avatar }}{{ .PageUser.Avatar }}?v=2{{ else }}{{ .DefaultAvatar }}{{ end }}" crossorigin="anonymous" style="width: 100px; height: 100px; max-width: 100%; max-height: 100%;">
|
||||
<h3 id="usernamecolor">{{ .PageUser.Name }}</h3>
|
||||
<p id="introcolor">{{ .PageUser.Intro }}</p>
|
||||
<div class="icon-container">
|
||||
{{ if and .PageUser.Acfunlink (ne .PageUser.Acfunlink "none") }}
|
||||
<a href="https://{{.PageUser.Acfunlink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/ailicili.png" alt="acfunlink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bililink (ne .PageUser.Bililink "none") }}
|
||||
<a href="https://{{.PageUser.Bililink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/bili.png" alt="bililink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Weibolink (ne .PageUser.Weibolink "none") }}
|
||||
<a href="https://{{.PageUser.Weibolink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/weibo.png" alt="weibolink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Xiaohongshulink (ne .PageUser.Xiaohongshulink "none") }}
|
||||
<a href="https://{{.PageUser.Xiaohongshulink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png" alt="xiaohongshulink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if and .PageUser.Bloglink (ne .PageUser.Bloglink "none") }}
|
||||
<a href="https://{{.PageUser.Bloglink}}" target="_blank">
|
||||
<img src="https://r2.337845818.best/tamabox/icon/social/blog.png" alt="bloglink" class="icon">
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("usernamecolor").style.color = {{.PageUser.Usernamecolor}};
|
||||
document.getElementById("introcolor").style.color = {{.PageUser.Introcolor}};
|
||||
</script>
|
||||
<div>
|
||||
<div class="uk-card uk-card-default uk-card-small uk-card-body">
|
||||
{{ if .CanAsk }}
|
||||
<!-- jQuery 按需加载(同步,保证下方内联二维码初始化就绪) -->
|
||||
<script src="https://cdnjs.snrat.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script src='https://r2.337845818.best/tamabox/cdn/js/remake/easy.qrcode.min.js' type="text/javascript" charset="utf-8"></script>
|
||||
<center><div id="boxqrimg"></div></center>
|
||||
<script type="text/javascript">
|
||||
var options = {
|
||||
text: window.location.href,
|
||||
width: 200,
|
||||
height: 200,
|
||||
// 与 Profile 编辑器保持一致:仅当启用自定义前景色时,才把 Qrcodebackcolor 作为前景色;
|
||||
// 否则强制黑色 #000000,避免未勾选却显示自定义前景色、与 Profile 预览不一致。
|
||||
colorDark: ({{.PageUser.UseCustomQRColor}} ? "{{.PageUser.Qrcodebackcolor}}" : "#000000") || "#000000",
|
||||
colorLight: "{{.PageUser.Qrcodecolor}}" || "#FFFFFF",
|
||||
dotScale: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleTiming: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
dotScaleA: parseFloat("{{.PageUser.Dotscale}}") || 0.4,
|
||||
correctLevel: QRCode.CorrectLevel.H,
|
||||
logo: (function () {
|
||||
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
|
||||
var a = "{{.PageUser.Avatar}}" || "{{.DefaultAvatar}}";
|
||||
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
|
||||
})(),
|
||||
logoWidth: 60,
|
||||
logoHeight: 60,
|
||||
logoBackgroundTransparent: true,
|
||||
// 直连 R2 等跨域图片须以 CORS 模式加载,canvas 才不被污染、可正常导出
|
||||
crossOrigin: 'anonymous',
|
||||
backgroundImage: ("{{.PageUser.BackgroundImage}}" ? "{{.PageUser.BackgroundImage}}" : undefined),
|
||||
backgroundImageAlpha: parseFloat("{{.PageUser.Backgroundimagealpha}}") || 0.8,
|
||||
autoColor: false,
|
||||
PO: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
PI: "{{.PageUser.Qrcodepdpcolor}}" || "#000000",
|
||||
AO: '',
|
||||
AI: '',
|
||||
timing: "{{.PageUser.Qrcodepdpcolor}}" || "#000000"
|
||||
};
|
||||
new QRCode(document.getElementById("boxqrimg"), options);
|
||||
</script>
|
||||
|
||||
<p class="uk-text-center uk-text-small">扫码向@{{ .PageUser.Name }}提问<br>{{ if .RegisterOnlyAsk }}{{t .LangCode "ask.registerOnly"}}{{ else }}{{t .LangCode "ask.anonymous"}}{{ end }}</p>
|
||||
{{ end }}
|
||||
{{template "base/alert" .}}
|
||||
{{template "question/new-question-template" .}}
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
{{ if ne (len .PageQuestions) 0 }}
|
||||
<p class="uk-text-left uk-text-muted uk-text-small">@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})</p>
|
||||
{{ template "question/history-template" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user