Files
tamabox/templates/user/profile.html
T
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00

1606 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{template "base/header" .}}
<!-- 二维码预览依赖 jQuery(同步加载,保证下方 easy.qrcode 初始化就绪);jQuery 统一使用 3.7.1 -->
<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>
<!-- Pickr 颜色选择器 -->
<link rel="stylesheet" href="https://r2.337845818.best/tamabox/cdn/lib/pickr/1.10.1/nano.min.css"/>
<script src="https://r2.337845818.best/tamabox/cdn/lib/pickr/1.10.1/pickr.min.js"></script>
<!-- Cropper.js 头像/背景裁剪(迁 cdnjs.snrat.com,sha256 与官方一致;R2 原存非 min 版,snrat 为 min 版语义等价) -->
<link rel="stylesheet" href="https://cdnjs.snrat.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"/>
<script src="https://cdnjs.snrat.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
<style>
.range-input-group {
display: flex;
align-items: center;
gap: 10px;
}
.range-input-group input[type="range"] {
flex: 1;
}
.range-input-group input[type="number"] {
width: 70px;
}
.range-input-group .range-value {
min-width: 40px;
text-align: right;
}
.color-picker {
display: inline-block;
vertical-align: middle;
}
.preview-card {
background: #fff;
padding: 20px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
text-align: center;
}
.preview-card .qrcode-wrapper {
width: 100%;
max-width: 220px;
margin: 0 auto;
}
.theme-btn {
margin: 2px;
font-size: var(--tb-fs-xs);
}
.social-item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.social-item label.checkbox-label {
margin: 0;
white-space: nowrap;
}
.social-item input[type="text"] {
flex: 1;
}
.social-item input[type="text"]:invalid {
border-color: #f0506e;
}
@media (max-width: 640px) {
.preview-card {
margin-top: 20px;
}
}
/* 面板切换:与提问箱主页一致的胶囊样式(用户设置 / 提问 / 私聊 / 投票) */
.profile-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface-2);
}
.profile-tab {
flex: 1;
border: 0;
border-radius: var(--tb-radius-xs);
padding: 9px 8px;
background: transparent;
color: var(--tb-text-muted);
cursor: pointer;
font-size: var(--tb-fs-md);
white-space: nowrap;
transition: background 0.2s ease, color 0.2s ease;
}
.profile-tab:hover { color: var(--tb-primary); }
.profile-tab.is-active {
background: var(--tb-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: var(--tb-primary);
font-weight: 600;
}
.profile-panel[hidden] { display: none !important; }
/* 数据面板入口卡片 */
.tb-entry {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 14px;
border: 1px solid var(--tb-border);
border-left: 3px solid var(--tb-primary);
border-radius: var(--tb-radius-sm);
background: var(--tb-surface);
text-decoration: none;
transition: border-color .2s ease, background .2s ease;
}
.tb-entry:hover {
background: var(--tb-primary-weak);
text-decoration: none;
}
.tb-entry-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tb-entry-title {
font-size: var(--tb-fs-md);
font-weight: 600;
color: var(--tb-text);
}
.tb-entry-desc {
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-entry-arrow {
margin-left: auto;
color: var(--tb-text-muted);
}
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); }
/* 功能控制卡片(部分模板 user/feature-control-form 使用) */
.feat-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
background: #fff;
}
.feat-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.feat-card-head h3 { font-size: var(--tb-fs-lg); }
.feat-status {
flex: 0 0 auto;
font-size: var(--tb-fs-xs);
line-height: 1.6;
padding: 0 8px;
border-radius: var(--tb-radius-xs);
}
.feat-status.is-open { background: #32d296; color: #fff; }
.feat-status.is-closed { background: #f0506e; color: #fff; }
.feat-hint {
display: block;
font-size: var(--tb-fs-xs);
line-height: 1.5;
color: var(--tb-text-muted);
}
.feat-field { display: block; }
.feat-field > label > span {
display: block;
margin-bottom: 4px;
font-size: var(--tb-fs-md);
}
.feat-field-label {
display: block;
margin-bottom: 4px;
font-size: var(--tb-fs-md);
}
/* 收集模式三选一(长期开放 / 定时收集 / 循环定时),胶囊样式复用 .feat-day */
.feat-modes {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.is-hidden { display: none !important; }
.feat-field input[type="datetime-local"],
.feat-field input[type="number"] { width: 100%; }
/* 循环定时(每周开放日 + 每日时间段) */
.feat-schedule { display: flex; flex-direction: column; gap: 8px; }
.feat-schedule-days {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.feat-day {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border: 1px solid var(--tb-border);
border-radius: 999px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-secondary);
cursor: pointer;
user-select: none;
}
.feat-day input { margin: 0; }
.feat-day.is-on {
border-color: var(--tb-primary);
background: rgba(30, 135, 240, 0.08);
color: var(--tb-primary);
}
.feat-schedule-times {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.feat-schedule-times label {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--tb-fs-md);
color: var(--tb-text-secondary);
}
.feat-schedule-times input[type="time"] {
width: 130px;
padding: 4px 8px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-xs);
}
html[data-theme="dark"] .feat-day {
border-color: var(--tb-border);
color: var(--tb-text-muted);
}
html[data-theme="dark"] .feat-day.is-on {
border-color: var(--tb-primary);
background: var(--tb-primary-weak);
color: var(--tb-primary);
}
html[data-theme="dark"] .feat-schedule-times input[type="time"] {
border-color: var(--tb-border);
background: var(--tb-surface);
color: var(--tb-text);
}
.feat-quota-line {
font-size: var(--tb-fs-md);
color: var(--tb-text-secondary);
text-align: center;
}
.feat-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
align-items: center;
margin-top: auto;
}
.feat-actions form { margin: 0; }
html[data-theme="dark"] .feat-card {
border-color: var(--tb-border);
background: var(--tb-surface);
}
html[data-theme="dark"] .feat-card-head h3 { color: var(--tb-text); }
html[data-theme="dark"] .feat-hint,
html[data-theme="dark"] .feat-quota-line { color: var(--tb-text-muted); }
/* 私聊面板聚合卡:chat 专属(功能开关/收集窗口 + 消息上限设置)同卡呈现,
feat 卡去掉自身边框避免嵌套;第二区 legend 降级为小节标题 */
.chat-zone .feat-card {
padding: 0;
border: 0;
background: transparent;
}
.chat-zone hr { margin: 14px 0; }
.chat-zone .uk-legend {
font-size: var(--tb-fs-lg);
margin: 0 0 10px;
}
/* 标签块输入:回车或逗号成词,每个词可独立删除;原 input 隐藏后作为提交载体 */
.tb-tag-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-xs);
background: #fff;
cursor: text;
}
.tb-tag-box:focus-within { border-color: var(--tb-primary); }
.tb-tag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: var(--tb-radius-xs);
background: var(--tb-primary);
color: var(--tb-on-primary);
font-size: var(--tb-fs-md);
line-height: 1.8;
}
.tb-tag-chip button {
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: var(--tb-fs-lg);
line-height: 1;
cursor: pointer;
opacity: 0.75;
}
.tb-tag-chip button:hover { opacity: 1; }
.tb-tag-entry {
flex: 1;
min-width: 120px;
border: 0;
outline: 0;
background: transparent;
color: inherit;
font-size: var(--tb-fs-md);
}
.tb-tag-meta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-tag-error { color: #f0506e; }
html[data-theme="dark"] .tb-tag-box {
border-color: var(--tb-border);
background: var(--tb-surface);
color: var(--tb-text);
}
html[data-theme="dark"] .tb-tag-entry { color: var(--tb-text); }
html[data-theme="dark"] .tb-tag-meta { color: var(--tb-text-muted); }
/* ===== profile account 面板卡片化(复用 UIkit 变量体系,暗色自动跟随) ===== */
.tb-pcard {
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
padding: 13px 14px 8px;
margin-bottom: 12px;
}
.tb-pcard-title {
margin: 0 0 10px;
font-size: var(--tb-fs-lg);
font-weight: 600;
}
/* 全页纵向压缩:面板内表单字段间距由 UIkit 默认 20px 收紧到 12px */
.profile-panel .uk-margin { margin-bottom: 12px; }
.tb-pcard-title .tb-pcard-extra {
float: right;
font-size: var(--tb-fs-xs);
font-weight: 400;
color: var(--tb-text-muted);
line-height: 1.6;
}
/* 头像与背景预览:两项同行、整体居中;头像框与背景框“显示高度”一致(150px) */
.tb-media-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: 14px 32px;
}
.tb-media-item {
/* 头像列与背景列等宽(280px),内部各自居中 → 两个“更换 xx”组件观感一致 */
flex: 0 0 280px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.tb-media-frame {
display: block;
flex: none;
border: 1px solid var(--tb-border);
background: #f7f7f7;
overflow: hidden;
}
.tb-media-frame.tb-frame-avatar {
width: 150px;
height: 150px;
border-radius: 50%;
}
.tb-media-frame.tb-frame-bg {
/* 16:9 且高 150 ≈ 头像框直径 150:两框视觉等高 */
width: 267px;
max-width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--tb-radius-sm);
}
.tb-media-frame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tb-ratio-tag {
display: inline-block;
margin: 8px 0 0;
font-size: var(--tb-fs-xs);
line-height: 1;
color: var(--tb-text-secondary);
background: #f4f4f4;
border: 1px solid var(--tb-border);
border-radius: 999px;
padding: 4px 9px;
}
.tb-file-hint {
display: block;
font-size: var(--tb-fs-xs);
line-height: 1.5;
color: var(--tb-text-muted);
margin-top: 6px;
}
.tb-media-actions { margin-top: 10px; }
/* 更换头像 / 更换背景两个按钮同宽 */
.tb-media-actions .uk-button { min-width: 150px; }
/* 卡片级保存按钮行(居中):头像背景、基础资料、二维码、社交、改密各块共用 */
.tb-card-save,
.tb-media-save {
margin-top: 6px;
text-align: center;
}
/* 账号操作:导出两按钮同行、退出登录与停用同行;按钮组居中、说明行小字居中 */
.tb-account-actions { margin-top: 0; }
.tb-account-pair {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 10px 14px;
padding: 8px 0;
}
.tb-account-pair form { margin: 0; }
.tb-account-note {
display: block;
margin: 0 0 10px;
text-align: center;
font-size: var(--tb-fs-xs);
line-height: 1.6;
color: var(--tb-text-muted);
}
.tb-account-note:last-child { margin-bottom: 0; }
/* 裁剪弹窗:图片区域限制在视口内,移动端可用 */
.tb-crop-stage {
max-width: 100%;
text-align: center;
}
.tb-crop-stage img {
display: inline-block;
max-width: 100%;
max-height: 58vh;
}
#media-crop-modal .uk-modal-dialog { width: 640px; }
.tb-crop-hint { margin-top: 12px; }
html[data-theme="dark"] .tb-pcard-title { color: var(--tb-text); }
html[data-theme="dark"] .tb-pcard-extra,
html[data-theme="dark"] .tb-file-hint { color: var(--tb-text-muted); }
html[data-theme="dark"] .tb-media-frame {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .tb-ratio-tag {
color: var(--tb-text-muted);
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .tb-account-note { color: var(--tb-text-muted); }
html[data-theme="dark"] .tb-pcard .uk-accordion-title {
color: var(--tb-text);
}
html[data-theme="dark"] .tb-pcard .uk-accordion-title:hover {
color: var(--tb-primary);
}
/* 移动端:更紧凑的留白与全宽操作区 */
@media (max-width: 640px) {
.tb-pcard { padding: 13px 12px 8px; }
.tb-media-row { gap: 14px; }
.tb-media-item { flex-basis: 100%; width: auto; max-width: none; }
.tb-media-frame.tb-frame-avatar { width: 120px; height: 120px; }
.tb-media-frame.tb-frame-bg { width: 100%; max-width: none; }
.tb-media-actions .uk-button,
.tb-media-save .uk-button,
.tb-card-save .uk-button { width: 100%; }
}
</style>
<!-- 提示条置于面板之外:任何面板保存后的 flash 都可见(面板会被 hidden 隐藏) -->
{{template "base/alert" .}}
<!-- 数据面板入口。刻意不放进上面的 tab 栏:tabs 是「改设置」的横向切换,
而数据面板是一个独立的只读页面,混进 tab 会让 tab 栏在窄屏上挤成六个。 -->
<a class="tb-entry" href="/user/insights">
<span class="tb-entry-body">
<span class="tb-entry-title">{{t .LangCode "insights.entryTitle"}}</span>
<span class="tb-entry-desc">{{t .LangCode "insights.entryDesc"}}</span>
</span>
<span class="tb-entry-arrow" aria-hidden="true">→</span>
</a>
<!-- 头像/背景裁剪弹窗(Cropper.js):确认后把裁剪结果写回对应的文件域,随主表单一并提交 -->
<div id="media-crop-modal" uk-modal>
<div class="uk-modal-dialog">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h2 class="uk-modal-title">{{t .LangCode "profile.cropTitle"}}</h2>
</div>
<div class="uk-modal-body">
<div class="tb-crop-stage">
<img id="media-crop-image" alt="">
</div>
<p class="uk-text-meta tb-crop-hint" id="media-crop-hint"></p>
</div>
<div class="uk-modal-footer uk-text-right">
<button type="button" class="uk-button uk-button-default uk-modal-close">{{t .LangCode "common.cancel"}}</button>
<button type="button" class="uk-button uk-button-primary" id="media-crop-confirm">{{t .LangCode "profile.cropConfirm"}}</button>
</div>
</div>
</div>
<!-- 面板切换:用户设置 与 提问/私聊/投票 的功能管理分开 -->
<div class="profile-tabs" role="tablist">
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "account" }} is-active{{ end }}" data-profile-tab="account" role="tab" aria-selected="{{ if eq .ActiveProfileTab "account" }}true{{ else }}false{{ end }}">{{t .LangCode "admin.tabAccount"}}</button>
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "question" }} is-active{{ end }}" data-profile-tab="question" role="tab" aria-selected="{{ if eq .ActiveProfileTab "question" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.questionsTab"}}</button>
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "chat" }} is-active{{ end }}" data-profile-tab="chat" role="tab" aria-selected="{{ if eq .ActiveProfileTab "chat" }}true{{ else }}false{{ end }}">{{t .LangCode "qlist.chatsTab"}}</button>
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "vote" }} is-active{{ end }}" data-profile-tab="vote" role="tab" aria-selected="{{ if eq .ActiveProfileTab "vote" }}true{{ else }}false{{ end }}">{{t .LangCode "explore.votesTab"}}</button>
<button type="button" class="profile-tab{{ if eq .ActiveProfileTab "security" }} is-active{{ end }}" data-profile-tab="security" role="tab" aria-selected="{{ if eq .ActiveProfileTab "security" }}true{{ else }}false{{ end }}">{{t .LangCode "admin.tabSecurity"}}</button>
</div>
<!-- ===================== 用户设置面板 ===================== -->
<div class="profile-panel" data-profile-panel="account"{{ if ne .ActiveProfileTab "account" }} hidden{{ end }}>
<form id="profile-form" method="post" enctype="multipart/form-data" action="/user/profile/update">
{{ .CSRFTokenHTML }}
<!-- 1 基础资料:账号与展示基础信息 -->
<section class="tb-pcard">
<h3 class="tb-pcard-title">{{t .LangCode "profile.card.basic"}}</h3>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.email"}}</label>
<input class="uk-input" type="text" disabled value="{{.LoggedUser.Email}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.name"}}</label>
<input name="name" class="uk-input" type="text" value="{{.LoggedUser.Name}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.nameColor"}}</label>
<div class="color-picker" data-default="{{.LoggedUser.Usernamecolor}}"></div>
<input type="hidden" name="usernamecolor" value="{{.LoggedUser.Usernamecolor}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.intro"}}</label>
<input name="intro" class="uk-input" type="text" value="{{.LoggedUser.Intro}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.introColor"}}</label>
<div class="color-picker" data-default="{{.LoggedUser.Introcolor}}"></div>
<input type="hidden" name="introcolor" value="{{.LoggedUser.Introcolor}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.lang"}}</label>
<select name="language" class="uk-select">
{{ range .SupportedLangs }}
<option value="{{.Code}}" {{ if eq $.LoggedUser.Language .Code }}selected{{ end }}>{{.Name}}</option>
{{ end }}
</select>
<span class="uk-text-small uk-text-muted">{{t .LangCode "admin.langHint"}}</span>
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.notify"}}</label>
<label>
<input name="notify_email" class="uk-checkbox" type="checkbox"
{{ if eq .LoggedUser.Notify "email"}}checked{{end}}>
<span class="uk-text-small"> {{t .LangCode "admin.notifyEmail"}}</span>
</label>
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveBasic"}}</button>
</div>
</section>
<!-- 2 头像与背景:选择文件即弹裁剪框,预览实时更新;头像 1:1、背景 16:9 -->
<section class="tb-pcard">
<h3 class="tb-pcard-title">{{t .LangCode "profile.card.avatarBg"}}</h3>
<div class="tb-media-row">
<div class="tb-media-item">
<span class="tb-media-frame tb-frame-avatar">
<img id="avatar-prev" alt="" src="{{ if .LoggedUser.Avatar }}{{.LoggedUser.Avatar}}{{ else }}{{.DefaultAvatar}}{{ end }}">
</span>
<span class="tb-ratio-tag">1:1</span>
<div class="tb-media-actions">
<input type="file" name="avatar" id="avatar-input" accept="image/*" class="uk-hidden">
<button type="button" class="uk-button uk-button-default uk-button-small" data-pick-image="avatar">{{t .LangCode "profile.pickAvatar"}}</button>
</div>
<span class="tb-file-hint">{{t .LangCode "profile.avatarCropHint"}}</span>
</div>
<div class="tb-media-item">
<span class="tb-media-frame tb-frame-bg">
<img id="bg-prev" alt=""{{ if .LoggedUser.Background }} src="{{.LoggedUser.Background}}"{{ end }}>
</span>
<span class="tb-ratio-tag">16:9</span>
<div class="tb-media-actions">
<input type="file" name="background" id="background-input" accept="image/*" class="uk-hidden">
<button type="button" class="uk-button uk-button-default uk-button-small" data-pick-image="background">{{t .LangCode "profile.pickBackground"}}</button>
</div>
<span class="tb-file-hint">{{t .LangCode "profile.backgroundCropHint"}}</span>
</div>
</div>
<!-- 图片更换后在此即时保存(与主表单共用提交,裁剪结果随文件域一并上传) -->
<div class="tb-media-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveMedia"}}</button>
</div>
</section>
<!-- 3 二维码样式:默认展开(可折叠),独立保存 -->
<section class="tb-pcard">
<ul class="uk-accordion" uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">{{t .LangCode "profile.card.qr"}}</a>
<div class="uk-accordion-content">
<div class="uk-grid" uk-grid>
<div class="uk-width-2-3@m uk-width-1-1">
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.qrBgColor"}}</label>
<div class="color-picker" data-default="{{.LoggedUser.Qrcodecolor}}"></div>
<input type="hidden" name="qrcodecolor" value="{{.LoggedUser.Qrcodecolor}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.qrPdColor"}}</label>
<div class="color-picker" data-default="{{.LoggedUser.Qrcodepdpcolor}}"></div>
<input type="hidden" name="qrcodepdpcolor" value="{{.LoggedUser.Qrcodepdpcolor}}">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.qrFgColor"}}</label>
<div class="uk-margin-small">
<label>
<input type="checkbox" id="use-custom-fg" name="use_custom_qr_color"
{{ if .LoggedUser.UseCustomQRColor }}checked{{ end }}>
{{t .LangCode "admin.useCustomFg"}}
</label>
</div>
<div class="color-picker" data-default="{{.LoggedUser.Qrcodebackcolor}}"></div>
<input type="hidden" name="qrcodebackcolor" value="{{.LoggedUser.Qrcodebackcolor}}">
<span class="uk-text-small uk-text-muted">{{t .LangCode "admin.customFgHint"}}</span>
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.qrDotSize"}}</label>
<div class="range-input-group">
<input name="dotscale" class="uk-range" type="range" step="0.01" min="0" max="1" value="{{.LoggedUser.Dotscale}}">
<span class="range-value" id="dotscale-val">{{.LoggedUser.Dotscale}}</span>
<input class="uk-input uk-form-width-xsmall" type="number" step="0.01" min="0" max="1" id="dotscale-num" value="{{.LoggedUser.Dotscale}}">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.qrBgAlpha"}}</label>
<div class="range-input-group">
<input name="backgroundimagealpha" class="uk-range" type="range" step="0.01" min="0" max="1" value="{{.LoggedUser.Backgroundimagealpha}}">
<span class="range-value" id="bgalpha-val">{{.LoggedUser.Backgroundimagealpha}}</span>
<input class="uk-input uk-form-width-xsmall" type="number" step="0.01" min="0" max="1" id="bgalpha-num" value="{{.LoggedUser.Backgroundimagealpha}}">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.qrBackground"}}</label>
<div uk-form-custom="target: true">
<input type="file" name="backgroundimage" accept="image/*" id="backgroundimage-file">
<input class="uk-input uk-form-width-large" type="text" placeholder="{{t .LangCode "admin.qrBackgroundPlaceholder"}}" disabled>
</div>
<span class="uk-text-small uk-text-muted">{{t .LangCode "admin.qrPreviewHint"}}</span>
</div>
</div>
<div class="uk-width-1-3@m uk-width-1-1">
<div class="preview-card">
<h4 class="uk-text-center">{{t .LangCode "admin.preview"}}</h4>
<div class="qrcode-wrapper" id="qrcode-preview"></div>
<hr class="uk-margin-small">
<div class="uk-text-small">
<strong>{{t .LangCode "admin.themePreset"}}</strong>
<div class="uk-flex uk-flex-wrap uk-flex-center">
<button type="button" class="uk-button uk-button-default uk-button-small theme-btn" data-theme="default">{{t .LangCode "admin.themeDefault"}}</button>
<button type="button" class="uk-button uk-button-default uk-button-small theme-btn" data-theme="dark">{{t .LangCode "admin.themeDark"}}</button>
<button type="button" class="uk-button uk-button-default uk-button-small theme-btn" data-theme="ocean">{{t .LangCode "admin.themeOcean"}}</button>
<button type="button" class="uk-button uk-button-default uk-button-small theme-btn" data-theme="sunset">{{t .LangCode "admin.themeSunset"}}</button>
</div>
</div>
<div class="uk-margin-small-top">
<button type="button" id="reset-qr-defaults" class="uk-button uk-button-default uk-button-small uk-width-1-1">{{t .LangCode "admin.resetDefaults"}}</button>
</div>
<p class="uk-text-meta uk-margin-small-top">{{t .LangCode "admin.previewHint"}}</p>
</div>
</div>
</div>
</div>
</li>
</ul>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveQR"}}</button>
</div>
</section>
<!-- 4 社交主页:默认展开(可折叠),独立保存 -->
<section class="tb-pcard">
<ul class="uk-accordion" uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">{{t .LangCode "admin.social"}}</a>
<div class="uk-accordion-content">
<span class="uk-text-muted">{{t .LangCode "admin.socialHint"}}</span>
<div class="uk-margin social-item">
<label class="checkbox-label">
<input type="checkbox" class="social-enable" data-target="acfunlink"> {{t .LangCode "admin.enableAcfun"}}
</label>
<input name="acfunlink" class="uk-input social-input" type="text" placeholder="acfun.cn/xxx" value="{{.LoggedUser.Acfunlink}}">
</div>
<div class="uk-margin social-item">
<label class="checkbox-label">
<input type="checkbox" class="social-enable" data-target="bililink"> {{t .LangCode "admin.enableBili"}}
</label>
<input name="bililink" class="uk-input social-input" type="text" placeholder="bilibili.com/xxx" value="{{.LoggedUser.Bililink}}">
</div>
<div class="uk-margin social-item">
<label class="checkbox-label">
<input type="checkbox" class="social-enable" data-target="weibolink"> {{t .LangCode "admin.enableWeibo"}}
</label>
<input name="weibolink" class="uk-input social-input" type="text" placeholder="weibo.com/xxx" value="{{.LoggedUser.Weibolink}}">
</div>
<div class="uk-margin social-item">
<label class="checkbox-label">
<input type="checkbox" class="social-enable" data-target="xiaohongshulink"> {{t .LangCode "admin.enableXiaohongshu"}}
</label>
<input name="xiaohongshulink" class="uk-input social-input" type="text" placeholder="xiaohongshu.com/xxx" value="{{.LoggedUser.Xiaohongshulink}}">
</div>
<div class="uk-margin social-item">
<label class="checkbox-label">
<input type="checkbox" class="social-enable" data-target="bloglink"> {{t .LangCode "admin.enableBlog"}}
</label>
<input name="bloglink" class="uk-input social-input" type="text" placeholder="example.com" value="{{.LoggedUser.Bloglink}}">
</div>
</div>
</li>
</ul>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.saveSocial"}}</button>
</div>
</section>
<!-- 5 修改密码:保存成功后强制登出,需重新登录 -->
<section class="tb-pcard">
<h3 class="tb-pcard-title">{{t .LangCode "profile.card.password"}}</h3>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.oldPassword"}}</label>
<input name="old_password" class="uk-input" type="password" placeholder="{{t .LangCode "admin.oldPasswordHint"}}" autocomplete="current-password">
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.newPassword"}}</label>
<input name="new_password" class="uk-input" type="password" placeholder="{{t .LangCode "admin.newPasswordHint"}}" autocomplete="new-password">
</div>
<p class="uk-text-small uk-text-muted">{{t .LangCode "profile.pwdNote"}}</p>
<hr class="uk-margin-small">
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.savePassword"}}</button>
</div>
</section>
</form>
<!-- 6 标签与展示:独立表单,保存即生效 -->
<section class="tb-pcard">
<h3 class="tb-pcard-title">{{t .LangCode "admin.tags"}}</h3>
<form method="post" action="/user/tags/update">
{{ .CSRFTokenHTML }}
<div class="uk-margin">
<label class="uk-form-label">{{t .LangCode "admin.tagsHint"}}</label>
<input name="tags" class="uk-input" type="text" value="{{.LoggedUser.Tags}}" placeholder="{{t .LangCode "admin.tagsPlaceholder"}}"
data-tb-tag-input
{{ with $.TagLimits }}data-tb-max="{{ .MaxTags }}" data-tb-max-len="{{ .MaxTagLen }}"{{ end }}
data-tb-msg-too-many="{{t .LangCode "admin.flash.tagsTooMany"}}"
data-tb-msg-too-long="{{t .LangCode "admin.flash.tagTooLong"}}"
data-tb-msg-duplicate="{{t .LangCode "admin.tagInputDuplicate"}}"
data-tb-msg-hint="{{t .LangCode "admin.tagInputHint"}}">
</div>
<div class="uk-margin">
<label>
<input name="show_in_home" class="uk-checkbox" type="checkbox" {{ if .LoggedUser.ShowInHome }}checked{{end}}>
<span class="uk-text-small"> {{t .LangCode "admin.showInHome"}}</span>
</label>
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "admin.updateTags"}}</button>
</div>
</form>
</section>
<!-- 7 账号操作:导出两按钮同行;退出登录与停用我的账号同行
(原「页面访问密码」已迁入「防护设置」面板,此处不再重复) -->
<section class="tb-pcard">
<h3 class="tb-pcard-title">{{t .LangCode "admin.account"}}</h3>
<div class="tb-account-actions">
<div class="tb-account-pair">
<form action="/user/profile/export" method="post" target="_blank" data-tb-skip-submit-feedback="1">
{{ .CSRFTokenHTML }}
<button class="uk-button uk-button-default">{{t .LangCode "admin.export"}}</button>
</form>
<form action="/user/profile/export-json" method="post" target="_blank" data-tb-skip-submit-feedback="1">
{{ .CSRFTokenHTML }}
<button class="uk-button uk-button-default">{{t .LangCode "admin.exportJSON"}}</button>
</form>
</div>
<span class="tb-account-note">{{t .LangCode "admin.exportHint" (t .LangCode "site.title")}} {{t .LangCode "admin.exportJSONHint"}}</span>
<div class="tb-account-pair">
<!-- Logout 使用独立 POST 表单(携带 CSRF token),GET 链接会留下 CSRF 风险 -->
<form method="post" action="/user/logout">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-danger">{{t .LangCode "admin.logout"}}</button>
</form>
<a class="uk-button uk-button-danger" href="/user/profile/deactivate">{{t .LangCode "admin.deactivate"}}</a>
</div>
<span class="tb-account-note">{{t .LangCode "admin.deactivateHint" | safe}}</span>
</div>
</section>
<!-- 主题外观:只改配色 / 图案 / 整页背景,不涉及布局;仅提问箱相关页面生效 -->
{{ if .Themes }}
<section class="tb-pcard">
<h3 class="tb-pcard-title">{{t .LangCode "profile.theme.title"}}</h3>
<form method="post" action="/user/profile/theme">
{{ .CSRFTokenHTML }}
<p class="tb-theme-hint">{{t .LangCode "profile.theme.hint"}}</p>
<div class="tb-theme-grid">
<label class="tb-theme-opt">
<input class="uk-radio" type="radio" name="theme" value=""{{ if eq .CurrentTheme "" }} checked{{ end }}>
<span class="tb-theme-swatch" style="background: linear-gradient(135deg, var(--tb-surface-2) 0 55%, var(--tb-border) 55% 100%)"></span>
<span class="tb-theme-name">{{t .LangCode "profile.theme.followDefault"}}</span>
</label>
{{ range .Themes }}
<label class="tb-theme-opt">
<input class="uk-radio" type="radio" name="theme" value="{{ .Slug }}"{{ if eq $.CurrentTheme .Slug }} checked{{ end }}>
<span class="tb-theme-swatch"{{ if .Swatch }} style="background: {{ safeCSS .Swatch }}"{{ end }}></span>
<span class="tb-theme-name">{{ themeName $.LangCode .Slug }}</span>
</label>
{{ end }}
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "profile.theme.save"}}</button>
</div>
</form>
</section>
{{ end }}
</div><!-- /用户设置面板 -->
<!-- ===================== 提问功能管理面板 ===================== -->
<div class="profile-panel" data-profile-panel="question"{{ if ne .ActiveProfileTab "question" }} hidden{{ end }}>
{{ with .FeatureQuestion }}{{template "user/feature-control-form" .}}{{ end }}
</div><!-- /提问功能管理面板 -->
<!-- ===================== 私聊功能管理面板 ===================== -->
<div class="profile-panel" data-profile-panel="chat"{{ if ne .ActiveProfileTab "chat" }} hidden{{ end }}>
<!-- 私聊专属聚合卡:功能开关/收集窗口 + 私聊设置(消息上限)同属私聊,归入一张卡片 -->
<section class="tb-pcard chat-zone">
{{ with .FeatureChat }}{{template "user/feature-control-form" .}}{{ end }}
<hr>
<form method="post" action="/user/profile/update-chat-settings">
{{ $.CSRFTokenHTML }}
<legend class="uk-legend">{{t $.LangCode "admin.chat"}}</legend>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.chatMaxMessages"}}</label>
{{ with $.ChatLimits }}
<input name="chat_max_messages" class="uk-input uk-width-small" type="number" min="1" max="{{ .MaxChatMessages }}" value="{{ $.LoggedUser.ChatMaxMessages }}">
{{ end }}
</div>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.chatDailyLimit"}}</label>
{{ with $.ChatLimits }}
<input name="chat_daily_limit" class="uk-input uk-width-small" type="number" min="0" max="{{ .DailyMessageLimitMax }}" value="{{ $.LoggedUser.DailyMessageLimit }}">
{{ end }}
<span class="uk-text-muted uk-text-small">{{t $.LangCode "admin.dailyLimitHint"}}</span>
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updateChat"}}</button>
</div>
</form>
</section>
</div><!-- /私聊功能管理面板 -->
<!-- ===================== 投票功能管理面板 ===================== -->
<div class="profile-panel" data-profile-panel="vote"{{ if ne .ActiveProfileTab "vote" }} hidden{{ end }}>
{{ with .FeatureVote }}{{template "user/feature-control-form" .}}{{ end }}
</div><!-- /投票功能管理面板 -->
<!-- ===================== 内容防护面板 =====================
聚合「内容防护」相关设置:防骚扰、屏蔽词、审核严格度、页面访问密码、提问密码。
原先散落在提问/私聊/投票三个面板(屏蔽词与审核严格度还被重复渲染 3 份),
现统一收拢到本面板;各表单 action 携带 ?tab=security,保存后回到此处。 -->
<div class="profile-panel" data-profile-panel="security"{{ if ne .ActiveProfileTab "security" }} hidden{{ end }}>
<section class="tb-pcard">
<div class="uk-margin">
<form method="post" action="/user/harassment/update?tab=security">
{{ $.CSRFTokenHTML }}
<legend class="uk-legend">{{t $.LangCode "admin.harassment"}}</legend>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.askLimit"}}</label><br>
<label>
<input name="register_only" class="uk-checkbox" type="checkbox"
{{ if eq $.LoggedUser.HarassmentSetting "register_only"}}checked{{end}} >
<span class="uk-text-small"> {{t $.LangCode "admin.registerOnly"}}</span>
</label>
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updateHarassment"}}</button>
</div>
</form>
</div>
<hr>
<!-- 全局屏蔽词:提问/私聊/投票共用同一份词表(见 user/global-blockwords 部分模板) -->
{{ template "user/global-blockwords" (dict "Root" $ "Action" "/user/blockwords/update?tab=security") }}
<!-- 审核严格度:提问/私聊/投票三条链路共用同一档位(见 user/moderation-strictness 部分模板) -->
{{ template "user/moderation-strictness" (dict "Root" $ "Action" "/user/moderation-strictness/update?tab=security") }}
<hr>
<div class="uk-margin">
<form method="post" action="/user/profile/update-page-password?tab=security">
{{ $.CSRFTokenHTML }}
<legend class="uk-legend">{{t $.LangCode "admin.pagePassword"}}</legend>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.pagePasswordHint"}}</label>
<input name="password" class="uk-input" type="password" minlength="6" maxlength="30" placeholder="{{t $.LangCode "admin.pagePasswordPlaceholder"}}">
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updatePagePassword"}}</button>
</div>
</form>
</div>
<hr>
<div class="uk-margin">
<form method="post" action="/user/profile/update-ask-password?tab=security">
{{ $.CSRFTokenHTML }}
<legend class="uk-legend">{{t $.LangCode "admin.askPassword"}}</legend>
<div class="uk-margin">
<label class="uk-form-label">{{t $.LangCode "admin.askPasswordHint"}}</label>
<input name="password" class="uk-input" type="password" minlength="6" maxlength="30" placeholder="{{t $.LangCode "admin.askPasswordPlaceholder"}}">
</div>
<div class="tb-card-save">
<button type="submit" class="uk-button uk-button-primary">{{t $.LangCode "admin.updateAskPassword"}}</button>
</div>
</form>
</div>
</section>
</div><!-- /内容防护面板 -->
<script>
(function() {
// ========== 社交信息复选框交互 + 非空验证 ==========
function initSocialCheckboxes() {
document.querySelectorAll('.social-item').forEach(function(item) {
var checkbox = item.querySelector('.social-enable');
var input = item.querySelector('.social-input');
if (!checkbox || !input) return;
// 初始化状态
var currentVal = input.value.trim();
if (currentVal === 'none' || currentVal === '') {
checkbox.checked = false;
input.value = 'none';
input.disabled = true;
input.removeAttribute('required');
} else {
checkbox.checked = true;
input.disabled = false;
input.setAttribute('required', 'required');
}
// 复选框切换
checkbox.addEventListener('change', function() {
if (checkbox.checked) {
input.disabled = false;
if (input.value === 'none') input.value = '';
input.setAttribute('required', 'required');
input.focus();
} else {
input.value = 'none';
input.disabled = true;
input.removeAttribute('required');
// 清除可能出现的自定义验证消息
input.setCustomValidity('');
}
});
// 输入时清除自定义验证消息
input.addEventListener('input', function() {
if (input.validity.customError) {
input.setCustomValidity('');
}
});
});
}
// 主表单提交验证
var profileForm = document.getElementById('profile-form');
profileForm.addEventListener('submit', function(e) {
var valid = true;
document.querySelectorAll('.social-item').forEach(function(item) {
var checkbox = item.querySelector('.social-enable');
var input = item.querySelector('.social-input');
if (checkbox.checked && input.value.trim() === '') {
input.setCustomValidity('{{t .LangCode "admin.socialRequired"}}');
valid = false;
} else {
input.setCustomValidity('');
}
});
if (!valid) {
e.preventDefault();
// 触发第一个无效输入框的报告
profileForm.reportValidity();
} else {
// 提交前确保未启用的项值为 "none"
document.querySelectorAll('.social-item').forEach(function(item) {
var checkbox = item.querySelector('.social-enable');
var input = item.querySelector('.social-input');
if (!checkbox.checked) {
input.value = 'none';
}
});
}
});
initSocialCheckboxes();
// ========== 其余脚本(颜色选择器、预览等) ==========
function debounce(func, wait) {
var timeout;
return function() {
var context = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function() { func.apply(context, args); }, wait);
};
}
// Pickr 初始化
document.querySelectorAll('.color-picker').forEach(function(el) {
var hiddenInput = el.nextElementSibling;
if (!hiddenInput || hiddenInput.type !== 'hidden') return;
var pickr = Pickr.create({
el: el,
theme: 'nano',
default: hiddenInput.value || '#000000',
defaultRepresentation: 'HEX',
components: {
preview: true,
opacity: false,
hue: true,
interaction: {
hex: true,
input: true,
clear: true,
save: true
}
}
});
pickr.on('save', function(color) {
var hex = color.toHEXA().toString();
hiddenInput.value = hex;
hiddenInput.dispatchEvent(new Event('input'));
});
});
// 滑块联动
function bindRangeNumber(range, num, span) {
if (!range || !num) return;
var update = function() {
num.value = range.value;
if (span) span.textContent = parseFloat(range.value).toFixed(2);
debouncedUpdatePreview();
};
range.addEventListener('input', update);
num.addEventListener('change', function() {
var val = parseFloat(num.value);
if (isNaN(val)) return;
range.value = val;
if (span) span.textContent = val.toFixed(2);
debouncedUpdatePreview();
});
if (span) span.textContent = parseFloat(range.value).toFixed(2);
}
var dotScaleRange = document.querySelector('input[name="dotscale"]');
var dotScaleNum = document.getElementById('dotscale-num');
var dotScaleSpan = document.getElementById('dotscale-val');
var bgAlphaRange = document.querySelector('input[name="backgroundimagealpha"]');
var bgAlphaNum = document.getElementById('bgalpha-num');
var bgAlphaSpan = document.getElementById('bgalpha-val');
bindRangeNumber(dotScaleRange, dotScaleNum, dotScaleSpan);
bindRangeNumber(bgAlphaRange, bgAlphaNum, bgAlphaSpan);
// 背景图即时预览
var bgImageFile = document.getElementById('backgroundimage-file');
window._bgImageDataURL = null;
if (bgImageFile) {
bgImageFile.addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
window._bgImageDataURL = ev.target.result;
debouncedUpdatePreview();
};
reader.readAsDataURL(file);
});
}
// 二维码预览核心
var boxURL = window.location.origin + '/_/' + '{{.LoggedUser.Domain}}';
var previewEl = document.getElementById('qrcode-preview');
var useCustomFg = document.getElementById('use-custom-fg');
updatePreview();
function getInputVal(name, fallback) {
var input = document.querySelector('input[name="' + name + '"]');
var v = input ? (input.value || '').trim() : '';
return v || (fallback || '#000000');
}
function updatePreview() {
var useFg = useCustomFg.checked;
var containerWidth = previewEl.clientWidth || 220;
var size = Math.min(containerWidth, 220);
// 关键修复:禁止 autoColor。新用户未设颜色时 easy.qrcode 的 autoColor 会自动取一个
// ≈背景色的前景,导致整张二维码变成背景色方块、无法扫描。改为始终传入明确的
// colorDark / colorLight 兜底值(空字段时回退到标准黑白)。
var dotScaleVal = parseFloat(dotScaleRange.value) || 0.4;
var bgAlphaVal = parseFloat(bgAlphaRange.value) || 0.8;
var options = {
text: boxURL,
width: size,
height: size,
colorDark: useFg ? getInputVal('qrcodebackcolor', '#000000') : '#000000',
colorLight: getInputVal('qrcodecolor', '#FFFFFF'),
dotScale: dotScaleVal,
dotScaleTiming: dotScaleVal,
dotScaleA: dotScaleVal,
correctLevel: QRCode.CorrectLevel.H,
logo: (function () {
// ?v=2:击穿曾被无 ACAO 响应污染的浏览器缓存条目;仅对 http(s) 绝对地址追加
var a = "{{.LoggedUser.Avatar}}" || "{{.DefaultAvatar}}";
return a ? (a.indexOf("http") === 0 ? a + "?v=2" : a) : undefined;
})(),
logoWidth: Math.floor(size * 0.25),
logoHeight: Math.floor(size * 0.25),
logoBackgroundTransparent: true,
// 直连 R2 等跨域图片须以 CORS 模式加载,画布才不会被污染、可正常导出;
// 前提是图片所在桶/域名已配 Access-Control-Allow-Origin(如 R2 CORS Policy)。
crossOrigin: 'anonymous',
// 默认不注入任何二维码背景图:仅在用户自己上传/设置了 BackgroundImage 时才使用,
// 或本次会话通过本地文件选择器临时预览(window._bgImageDataURL)。
// 没有背景图时(autoColor:false + 显式 colorDark/colorLight)才能稳定输出可扫描的黑白二维码,
// 否则在「无背景图却勾选自定义前景色」时容易糊成一坨。
backgroundImage: window._bgImageDataURL || ("{{.LoggedUser.BackgroundImage}}" ? "{{.LoggedUser.BackgroundImage}}" : undefined),
backgroundImageAlpha: bgAlphaVal,
autoColor: false,
PO: getInputVal('qrcodepdpcolor', '#000000'),
PI: getInputVal('qrcodepdpcolor', '#000000'),
AO: '',
AI: '',
timing: getInputVal('qrcodepdpcolor', '#000000')
};
// 降级渲染(P0-②):头像 logo / 背景图慢或失败时先出基础码,探针成功自动升级带 logo;
// 结果按 URL 同会话记忆,拖动滑块高频重绘时 logo 已知可用 → 一次成型、不闪降级。
window.tbRenderQR(previewEl, function () { return options; });
}
var debouncedUpdatePreview = debounce(updatePreview, 150);
// 二维码卡默认展开后:若初始不在 account 面板(?tab=question 等),容器曾 hidden 导致
// 预览按回退 220px 绘制;面板切回时由 tab 脚本调用本函数按真实宽度重绘。
window.__tbRefreshAccountPreview = debouncedUpdatePreview;
var colorInputs = document.querySelectorAll('input[type="hidden"][name="qrcodebackcolor"], input[type="hidden"][name="qrcodecolor"], input[type="hidden"][name="qrcodepdpcolor"]');
colorInputs.forEach(function(input) {
input.addEventListener('input', debouncedUpdatePreview);
});
useCustomFg.addEventListener('change', debouncedUpdatePreview);
window.addEventListener('resize', debouncedUpdatePreview);
// uk-accordion 展开/收起时按真实宽度重绘(初始默认展开;从其它面板切回见下方 tab 钩子)
document.addEventListener('shown', function(e) {
if (e.target && e.target.closest && e.target.closest('.uk-accordion')) {
debouncedUpdatePreview();
}
});
updatePreview();
// 预设主题
var themes = {
'default': {
qrcodecolor: '#FFFFFF',
qrcodebackcolor: '#000000',
qrcodepdpcolor: '#000000',
dotscale: 0.35,
bgalpha: 0.8
},
'dark': {
qrcodecolor: '#1a1a1a',
qrcodebackcolor: '#FFFFFF',
qrcodepdpcolor: '#FFFFFF',
dotscale: 0.5,
bgalpha: 0.9
},
'ocean': {
qrcodecolor: '#E0F7FA',
qrcodebackcolor: '#006064',
qrcodepdpcolor: '#004D40',
dotscale: 0.4,
bgalpha: 0.7
},
'sunset': {
qrcodecolor: '#FFF3E0',
qrcodebackcolor: '#BF360C',
qrcodepdpcolor: '#870000',
dotscale: 0.45,
bgalpha: 0.75
}
};
function applyTheme(theme) {
var t = themes[theme];
if (!t) return;
document.querySelector('input[name="qrcodecolor"]').value = t.qrcodecolor;
document.querySelector('input[name="qrcodebackcolor"]').value = t.qrcodebackcolor;
document.querySelector('input[name="qrcodepdpcolor"]').value = t.qrcodepdpcolor;
dotScaleRange.value = t.dotscale;
dotScaleNum.value = t.dotscale;
dotScaleSpan.textContent = parseFloat(t.dotscale).toFixed(2);
bgAlphaRange.value = t.bgalpha;
bgAlphaNum.value = t.bgalpha;
bgAlphaSpan.textContent = parseFloat(t.bgalpha).toFixed(2);
document.querySelector('input[name="qrcodecolor"]').dispatchEvent(new Event('input'));
document.querySelector('input[name="qrcodebackcolor"]').dispatchEvent(new Event('input'));
document.querySelector('input[name="qrcodepdpcolor"]').dispatchEvent(new Event('input'));
debouncedUpdatePreview();
}
document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
applyTheme(this.getAttribute('data-theme'));
});
});
document.getElementById('reset-qr-defaults').addEventListener('click', function() {
applyTheme('default');
useCustomFg.checked = false;
debouncedUpdatePreview();
});
})();
</script>
<script>
// 面板切换:用户设置 / 提问 / 私聊 / 投票。
// 服务端已按 ?tab= 渲染初始面板,此处仅在前端切换,并用 hash 保持可分享。
document.addEventListener('DOMContentLoaded', function() {
var tabs = document.querySelectorAll('[data-profile-tab]');
var panels = document.querySelectorAll('[data-profile-panel]');
function showPanel(name, pushToURL) {
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;
});
// 切回用户设置面板时按真实宽度重绘二维码预览(展开状态不会再触发 accordion 'shown')
if (name === 'account' && window.__tbRefreshAccountPreview) {
window.__tbRefreshAccountPreview();
}
if (pushToURL && window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, '/user/profile?tab=' + name);
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showPanel(tab.dataset.profileTab, true); });
});
var hash = (window.location.hash || '').replace('#', '');
if (hash && document.querySelector('[data-profile-tab="' + hash + '"]')) {
showPanel(hash, false);
}
});
</script>
<script>
// 标签 / 屏蔽词的标签块输入:把原本的逗号分隔文本框升级为"回车或逗号成词 + 逐条删除"。
// 渐进增强:接管前原 input 就是可用的普通文本框;接管后原 input 转为隐藏的提交载体,
// 提交前把标签块拼回逗号串写回它,后端解析逻辑完全不变(仍按逗号分隔)。
(function () {
// 后端兼容中文逗号,前端切分口径保持一致
function parseList(value) {
return String(value || '').split(/[,,]/).map(function (s) {
return s.trim();
}).filter(function (s) { return s !== ''; });
}
function initTagInput(input) {
var max = parseInt(input.getAttribute('data-tb-max'), 10);
var maxLen = parseInt(input.getAttribute('data-tb-max-len'), 10);
// data 属性缺失(如错误分支重渲染)时回落到与 db 常量一致的宽松值,后端仍是唯一权威
if (!max || max < 1) { max = 10; }
if (!maxLen || maxLen < 1) { maxLen = 10; }
var msgTooMany = input.getAttribute('data-tb-msg-too-many') || '';
var msgTooLong = input.getAttribute('data-tb-msg-too-long') || '';
var msgDuplicate = input.getAttribute('data-tb-msg-duplicate') || '';
var hint = input.getAttribute('data-tb-msg-hint') || '';
var items = parseList(input.value);
var box = document.createElement('div');
box.className = 'tb-tag-box';
var entry = document.createElement('input');
entry.type = 'text';
entry.className = 'tb-tag-entry';
box.appendChild(entry);
var meta = document.createElement('div');
meta.className = 'tb-tag-meta';
var count = document.createElement('span');
var error = document.createElement('span');
error.className = 'tb-tag-error';
meta.appendChild(count);
meta.appendChild(error);
var wrap = document.createElement('div');
wrap.appendChild(box);
wrap.appendChild(meta);
input.style.display = 'none';
if (input.parentNode) { input.parentNode.insertBefore(wrap, input.nextSibling); }
function sync() { input.value = items.join(','); }
function showError(msg) { error.textContent = msg || ''; }
function commit(raw) {
var v = String(raw || '').trim().replace(/[,,]/g, '');
if (v === '') { return; }
if (items.length >= max) { showError(msgTooMany); return; }
if (Array.from(v).length > maxLen) { showError(msgTooLong.replace('%s', v)); return; }
if (items.indexOf(v) >= 0) { showError(msgDuplicate); return; }
showError('');
items.push(v);
render();
}
function render() {
Array.prototype.slice.call(box.childNodes).forEach(function (node) {
if (node !== entry) { box.removeChild(node); }
});
items.forEach(function (text, i) {
var chip = document.createElement('span');
chip.className = 'tb-tag-chip';
chip.appendChild(document.createTextNode(text));
var del = document.createElement('button');
del.type = 'button';
del.textContent = '×';
del.title = text;
del.addEventListener('click', function () {
items.splice(i, 1);
showError('');
render();
});
chip.appendChild(del);
box.insertBefore(chip, entry);
});
entry.disabled = items.length >= max;
entry.placeholder = entry.disabled ? '' : hint;
count.textContent = items.length + ' / ' + max;
sync();
}
box.addEventListener('click', function (e) {
if (e.target === box) { entry.focus(); }
});
entry.addEventListener('input', function () {
if (entry.value.indexOf(',') >= 0 || entry.value.indexOf(',') >= 0) {
commit(entry.value);
entry.value = '';
}
});
entry.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
commit(entry.value);
entry.value = '';
} else if (e.key === 'Backspace' && entry.value === '' && items.length > 0) {
items.pop();
showError('');
render();
}
});
// 失焦时收下残留输入,避免"打了字没回车就提交"导致内容丢失
entry.addEventListener('blur', function () {
if (entry.value.trim() !== '') {
commit(entry.value);
entry.value = '';
}
});
if (input.form) {
input.form.addEventListener('submit', function () {
commit(entry.value);
entry.value = '';
sync();
});
}
render();
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-tb-tag-input]').forEach(initTagInput);
});
})();
</script>
<script data-cfasync="false">
// ========== 头像 / 背景:选择即弹裁剪框(1:1 / 16:9),确认后写回 file input 随主表单提交 ==========
(function () {
var MSG_AVATAR_TOO_LARGE = '{{t .LangCode "profile.err.avatarTooLarge"}}';
var MSG_BG_TOO_LARGE = '{{t .LangCode "profile.err.backgroundTooLarge"}}';
var MSG_NO_CROPPER = '{{t .LangCode "profile.err.cropLibUnavailable"}}';
var HINT_AVATAR = '{{t .LangCode "profile.avatarCropHint"}}';
var HINT_BG = '{{t .LangCode "profile.backgroundCropHint"}}';
var MAX_FILE = 2 * 1024 * 1024; // 与 storage.MaxAvatarSize / MaxBackgroundSize 一致
var media = {
avatar: {
input: document.getElementById('avatar-input'),
prev: document.getElementById('avatar-prev'),
ratio: 1,
hint: HINT_AVATAR,
maxSizeMsg: MSG_AVATAR_TOO_LARGE,
// 仅限制输出上限,避免小图被强行放大;canvas 宽高比由裁剪框锁定保证
canvas: { maxWidth: 512 },
fileType: 'image/png', fileExt: 'png', quality: null
},
background: {
input: document.getElementById('background-input'),
prev: document.getElementById('bg-prev'),
ratio: 16 / 9,
hint: HINT_BG,
maxSizeMsg: MSG_BG_TOO_LARGE,
canvas: { maxWidth: 1920 },
fileType: 'image/jpeg', fileExt: 'jpg', quality: 0.92
}
};
var modalEl = document.getElementById('media-crop-modal');
var stageImg = document.getElementById('media-crop-image');
var hintEl = document.getElementById('media-crop-hint');
var confirmBtn = document.getElementById('media-crop-confirm');
var cropper = null;
var pendingKey = null; // avatar | background
var pendingURL = null; // 原始选中文件的 objectURL
var appliedURL = {}; // 已应用裁剪结果的 objectURL(替换新文件时回收)
// 不在解析期创建 UIkit 组件:Cloudflare Rocket Loader / defer 环境下 UIkit 可能尚未执行,
// 顶层写 UIkit.modal(...) 会抛 ReferenceError 中断整个脚本,导致下方按钮监听全部失效。
// 改为在使用前懒创建(见 startCrop),并统一经由 whenReady 等待 UIkit / Cropper 就绪。
var modalObj = null;
function notify(msg) {
// UIkit 不可用时退回原生 alert,保证任何环境下用户都能看到原因
if (window.UIkit) { UIkit.modal.alert(msg); } else { window.alert(msg); }
}
// UIkit(header defer)与 Cropper(R2 CDN)就绪后回调;Rocket Loader 会延迟第三方脚本执行,
// 但最多容忍 3s —— 超时说明资源确实缺失(如 Cropper 尚未上传),给出可理解提示而非静默失败。
function whenReady(cb) {
if (window.UIkit && window.Cropper) { cb(); return; }
var waited = 0;
var timer = setInterval(function () {
waited += 100;
if (window.UIkit && window.Cropper) {
clearInterval(timer);
cb();
} else if (waited >= 3000) {
clearInterval(timer);
notify(MSG_NO_CROPPER);
}
}, 100);
}
function revoke(store, key) {
if (store[key]) { URL.revokeObjectURL(store[key]); store[key] = null; }
}
function startCrop(key) {
var cfg = media[key];
if (!cfg) return;
// 文件大小即时校验(不依赖第三方库,立即反馈)
var file = cfg.input && cfg.input.files && cfg.input.files[0];
if (!file) return;
if (file.size > MAX_FILE) { notify(cfg.maxSizeMsg); cfg.input.value = ''; return; }
whenReady(function () {
// 等待期间用户可能重新选择了文件,重新读取
var f = cfg.input.files && cfg.input.files[0];
if (!f) return;
if (f.size > MAX_FILE) { notify(cfg.maxSizeMsg); cfg.input.value = ''; return; }
if (!modalObj) { modalObj = UIkit.modal(modalEl); }
revoke(appliedURL, key);
pendingKey = key;
if (pendingURL) { URL.revokeObjectURL(pendingURL); pendingURL = null; }
pendingURL = URL.createObjectURL(f);
hintEl.textContent = cfg.hint;
stageImg.onload = function () { initCropper(); };
stageImg.src = pendingURL;
modalObj.show();
});
}
function initCropper() {
if (cropper) { cropper.destroy(); cropper = null; }
if (!pendingKey) return;
var cfg = media[pendingKey];
// 容器宽度优先:modal 显示后再创建,保证测量准确(移动端可用手势拖拽/缩放)
var box = stageImg.parentElement;
cropper = new Cropper(stageImg, {
viewMode: 1,
dragMode: 'move',
aspectRatio: cfg.ratio,
autoCropArea: 1,
responsive: true,
background: false,
rotatable: true,
zoomable: true,
wheelZoomRatio: 0.08
});
}
// 收起时销毁实例,回收资源
modalEl.addEventListener('hidden', function () {
if (cropper) { cropper.destroy(); cropper = null; }
if (pendingURL) { URL.revokeObjectURL(pendingURL); pendingURL = null; }
stageImg.removeAttribute('src');
pendingKey = null;
});
confirmBtn.addEventListener('click', function () {
if (!cropper || !pendingKey || !modalObj) return;
var cfg = media[pendingKey];
var canvas = cropper.getCroppedCanvas(cfg.canvas);
if (!canvas) return;
canvas.toBlob(function (blob) {
if (!blob) return;
var dt = new DataTransfer();
dt.items.add(new File([blob], 'cropped-' + pendingKey + '.' + cfg.fileExt, { type: cfg.fileType }));
cfg.input.files = dt.files;
appliedURL[pendingKey] = URL.createObjectURL(blob);
if (cfg.prev) { cfg.prev.src = appliedURL[pendingKey]; cfg.prev.style.display = 'block'; }
modalObj.hide();
}, cfg.fileType, cfg.quality || undefined);
});
// 入口按钮触发文件选择
document.querySelectorAll('[data-pick-image]').forEach(function (btn) {
btn.addEventListener('click', function () {
var key = btn.getAttribute('data-pick-image');
var input = media[key] && media[key].input;
if (input) input.click();
});
});
// 选择文件后进入裁剪
Object.keys(media).forEach(function (key) {
var input = media[key].input;
if (!input) return;
input.addEventListener('change', function () { startCrop(key); });
});
})();
</script>
{{template "base/footer" .}}