Files
SingBox-Gopanel/internal/handler/realname_handler.go
CN-JS-HuiBai 1ed31b9292
All checks were successful
build / build (api, amd64, linux) (push) Successful in -47s
build / build (api, arm64, linux) (push) Successful in -48s
build / build (api.exe, amd64, windows) (push) Successful in -47s
first commit
2026-04-17 09:49:16 +08:00

270 lines
12 KiB
Go

package handler
import (
"fmt"
"net/http"
"strconv"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"github.com/gin-gonic/gin"
)
// RealNameIndex renders the beautified plugin management page.
func RealNameIndex(c *gin.Context) {
var appNameSetting model.Setting
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
appName := appNameSetting.Value
if appName == "" {
appName = "XBoard"
}
securePath := c.Param("path")
apiEndpoint := fmt.Sprintf("/api/v1/%%s/realname/records", securePath)
reviewEndpoint := fmt.Sprintf("/api/v1/%%s/realname/review", securePath)
// We use %% for literal percent signs in Sprintf
// and we avoid backticks in the JS code by using regular strings to remain compatible with Go raw strings.
html := fmt.Sprintf(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%%s - 实名验证管理</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0f172a;
--bg-accent: #1e293b;
--primary: #6366f1;
--secondary: #a855f7;
--text-main: #f8fafc;
--text-dim: #94a3b8;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--glass: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Outfit', sans-serif; }
body { background: var(--bg-deep); color: var(--text-main); min-height: 100vh; overflow-x: hidden; position: relative; padding: 40px 20px; }
body::before {
content: ''; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: radial-gradient(circle at 10%% 10%%, rgba(99, 102, 241, 0.1), transparent 40%%),
radial-gradient(circle at 90%% 90%%, rgba(168, 85, 247, 0.1), transparent 40%%);
z-index: -1;
}
.container { max-width: 1200px; margin: 0 auto; animation: fadeIn 0.8s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 40px; }
h1 { font-size: 2.5rem; font-weight: 600; background: linear-gradient(135deg, var(--primary), var(--secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.header p { color: var(--text-dim); font-size: 1.1rem; margin-top: 8px; }
.main-card { background: var(--glass); border: 1px solid var(--glass-border); backdrop-filter: blur(20px); border-radius: 24px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
.toolbar { padding: 24px 32px; border-bottom: 1px solid var(--glass-border); display: flex; justify-content: space-between; align-items: center; }
.search-box input { background: var(--bg-accent); border: 1px solid var(--glass-border); color: white; padding: 12px 20px; border-radius: 12px; font-size: 14px; width: 300px; outline: none; transition: border-color 0.3s; }
.search-box input:focus { border-color: var(--primary); }
table { width: 100%%%%; border-collapse: collapse; }
th { text-align: left; padding: 16px 32px; font-size: 0.8rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--glass-border); }
td { padding: 20px 32px; border-bottom: 1px solid var(--glass-border); font-size: 0.95rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
.badge { padding: 6px 12px; border-radius: 100px; font-size: 0.75rem; font-weight: 600; }
.badge.approved { background: rgba(16, 185, 129, 0.1); color: var(--success); }
.badge.pending { background: rgba(245, 158, 11, 0.1); color: var(--warning); }
.badge.rejected { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
.btn { padding: 8px 16px; border-radius: 10px; font-weight: 600; font-size: 0.85rem; border: none; cursor: pointer; transition: 0.3s; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: #5a5ce5; transform: translateY(-2px); }
.btn-danger { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: white; }
.pagination { padding: 24px 32px; display: flex; justify-content: space-between; align-items: center; color: var(--text-dim); font-size: 0.9rem; }
.btn-nav { background: var(--bg-accent); color: white; padding: 8px 16px; border-radius: 8px; font-weight: 600; }
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
#toast-box { position: fixed; top: 20px; right: 20px; background: var(--bg-accent); border: 1px solid var(--glass-border); padding: 12px 24px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); transform: translateX(200%%); transition: 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 1000; }
#toast-box.show { transform: translateX(0); }
</style>
</head>
<body>
<div class="container">
<header class="header">
<div>
<h1>实名验证管理</h1>
<p>集中处理全站用户的身份验证申请</p>
</div>
<a href="/%%s" class="btn btn-primary" style="background: var(--bg-accent)">返回控制台</a>
</header>
<div class="main-card">
<div class="toolbar">
<div class="search-box">
<input type="text" id="keyword" placeholder="搜邮箱或姓名..." onkeypress="if(event.keyCode==13) loadData(1)">
</div>
<div id="status-label" style="font-weight: 600">正在获取数据...</div>
</div>
<table>
<thead>
<tr>
<th>用户 ID</th>
<th>邮箱</th>
<th>真实姓名</th>
<th>认证状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="table-body">
<!-- Rows will be injected here -->
</tbody>
</table>
<div class="pagination">
<button class="btn-nav" id="prev-btn" onclick="loadData(state.page - 1)">上一页</button>
<div id="page-label">第 1 / 1 页</div>
<button class="btn-nav" id="next-btn" onclick="loadData(state.page + 1)">下一页</button>
</div>
</div>
</div>
<div id="toast-box">操作成功</div>
<script>
const state = { page: 1, lastPage: 1 };
const api = "%%s";
const reviewApi = "%%s";
function showToast(msg) {
const t = document.getElementById('toast-box');
t.innerText = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
async function loadData(page = 1) {
state.page = page;
const keyword = document.getElementById('keyword').value;
try {
const res = await fetch(api + '?page=' + page + '&keyword=' + keyword);
const data = await res.json();
const list = data.data || [];
state.lastPage = data.pagination.last_page;
document.getElementById('page-label').innerText = '第 ' + page + ' / ' + state.lastPage + ' 页';
document.getElementById('status-label').innerText = '共计 ' + data.pagination.total + ' 条记录';
let html = '';
list.forEach(item => {
const statusLabel = item.status === 'approved' ? '已通过' : (item.status === 'pending' ? '待审核' : '已驳回');
html += '<tr>' +
'<td><span style="opacity: 0.5">#</span>' + item.user_id + '</td>' +
'<td>' + item.user.email + '</td>' +
'<td>' + (item.real_name || '-') + '</td>' +
'<td><span class="badge ' + item.status + '">' + statusLabel + '</span></td>' +
'<td>' +
(item.status === 'pending' ? '<button class="btn btn-primary" onclick="review(' + item.id + ', \\'approved\\')">通过</button> ' : '') +
'<button class="btn btn-danger" onclick="review(' + item.id + ', \\'rejected\\')">驳回</button>' +
'</td>' +
'</tr>';
});
document.getElementById('table-body').innerHTML = html || '<tr><td colspan="5" style="text-align:center; padding:100px; opacity:0.3">暂无数据</td></tr>';
document.getElementById('prev-btn').disabled = page <= 1;
document.getElementById('next-btn').disabled = page >= state.lastPage;
} catch (e) { console.error(e); showToast('加载失败'); }
}
async function review(id, status) {
try {
const res = await fetch(reviewApi + '/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
const data = await res.json();
showToast(data.message || '操作成功');
loadData(state.page);
} catch (e) { showToast('操作失败'); }
}
loadData();
</script>
</body>
</html>
`, appName, securePath, apiEndpoint, reviewEndpoint)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, html)
}
// RealNameRecords handles the listing of authentication records.
func RealNameRecords(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize := 15
keyword := c.Query("keyword")
var records []model.RealNameAuth
var total int64
query := database.DB.Preload("User").Model(&model.RealNameAuth{})
if keyword != "" {
query = query.Joins("JOIN v2_user ON v2_user.id = v2_realname_auth.user_id").
Where("v2_user.email LIKE ?", "%%"+keyword+"%%")
}
query.Count(&total)
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("created_at DESC").Find(&records)
lastPage := (total + int64(pageSize) - 1) / int64(pageSize)
c.JSON(http.StatusOK, gin.H{
"data": records,
"pagination": gin.H{
"total": total,
"current": page,
"last_page": lastPage,
},
})
}
// RealNameReview handles approval or rejection of a record.
func RealNameReview(c *gin.Context) {
id := c.Param("id")
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
return
}
var record model.RealNameAuth
if err := database.DB.First(&record, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"message": "记录不存在"})
return
}
record.Status = req.Status
record.ReviewedAt = time.Now().Unix()
database.DB.Save(&record)
// Sync User Expiration if approved
if req.Status == "approved" {
// Set a long expiration date (e.g., 2099-12-31)
expiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
database.DB.Model(&model.User{}).Where("id = ?", record.UserID).Update("expired_at", expiry)
}
c.JSON(http.StatusOK, gin.H{"message": "审核操作成功"})
}