270 lines
12 KiB
Go
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": "审核操作成功"})
|
|
}
|