New warehouse

This commit is contained in:
2025-10-24 00:09:45 +08:00
commit ac07e27908
75 changed files with 26814 additions and 0 deletions

View File

@@ -0,0 +1,412 @@
{{ define "apps.html" }}
<section>
<h2>应用管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddApp"><i class="layui-icon layui-icon-add-1"></i> 新增应用</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteApps"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableApps"><i class="layui-icon layui-icon-ok-circle"></i> 批量启用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableApps"><i class="layui-icon layui-icon-close-fill"></i> 批量禁用</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="appFilterForm" lay-filter="appFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">搜索</label>
<div class="layui-input-inline">
<input type="text" name="search" placeholder="应用名称/UUID" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchApps">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetApps">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">应用列表</div>
<div class="layui-card-body">
<table id="appsTable" lay-filter="appsTableFilter"></table>
<script type="text/html" id="tpl-apps-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
<script type="text/html" id="tpl-apps-status">
{{`{{# if(d.status === 1) { }}`}}
<span class="layui-badge layui-bg-green">启用</span>
{{`{{# } else { }}`}}
<span class="layui-badge">禁用</span>
{{`{{# } }}`}}
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容:新增/编辑应用 -->
<div id="appFormModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="appForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">应用名称</label>
<div class="layui-input-block">
<input type="text" name="name" placeholder="请输入应用名称" autocomplete="off" class="layui-input" lay-verify="required" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">版本号</label>
<div class="layui-input-block">
<input type="text" name="version" placeholder="请输入版本号默认1.0.0" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="1" selected>启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">强制更新</label>
<div class="layui-input-block">
<select name="force_update">
<option value="0" selected>不开启</option>
<option value="1">开启</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">更新方式</label>
<div class="layui-input-block">
<select name="download_type" lay-filter="downloadTypeChange">
<option value="0" selected>不启用更新</option>
<option value="1">自动更新</option>
<option value="2">手动下载</option>
</select>
</div>
</div>
<div class="layui-form-item" id="downloadUrlItem">
<label class="layui-form-label">下载地址</label>
<div class="layui-input-block">
<input type="text" name="download_url" placeholder="请输入下载地址" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="submit" class="layui-btn" lay-submit lay-filter="appFormSubmit">提交</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnCancelApp">取消</button>
</div>
</div>
</form>
</div>
<script>
layui.use(['table', 'form', 'layer', 'element'], function() {
const table = layui.table;
const form = layui.form;
const layer = layui.layer;
const $ = layui.$;
// 格式化时间函数
function formatDateTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
// 渲染表格
const appsTable = table.render({
elem: '#appsTable',
id: 'appsTable',
url: '/admin/api/apps/list',
parseData: function(res) {
// 后端返回的数据结构处理
return {
code: res.code,
msg: res.msg || '',
count: res.count || 0,
data: res.data || []
};
},
request: {
pageName: 'page', // 页码的参数名称默认page
limitName: 'page_size' // 每页数据量的参数名称默认limit
},
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
done: function(res, curr, count) {
// 表格渲染完成后的回调
},
cols: [[
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '应用名称', minWidth: 180 },
{ field: 'uuid', title: 'UUID', minWidth: 320 },
{ field: 'version', title: '版本', width: 100 },
{
field: 'status',
title: '状态',
width: 100,
templet: (d) => {
if (d.status === 1) return '<span style="color: #5FB878;">启用</span>';
return '<span style="color: #FF5722;">禁用</span>';
}
},
{
field: 'secret',
title: '密钥',
minWidth: 320,
templet: (d) => '<span style="font-family: monospace;">' + d.secret + '</span>'
},
{
field: 'created_at',
title: '创建时间',
width: 180,
templet: (d) => formatDateTime(d.created_at)
},
{ fixed: 'right', title: '操作', toolbar: '#tpl-apps-ops', width: 120 }
]]
});
// 搜索功能
$('#btnSearchApps').on('click', function() {
const search = $('input[name="search"]').val();
appsTable.reload({
where: {
search: search
},
page: {
curr: 1
}
});
});
// 重置搜索
$('#btnResetApps').on('click', function() {
$('#appFilterForm')[0].reset();
appsTable.reload({
where: {},
page: {
curr: 1
}
});
});
// 新增应用
$('#btnAddApp').on('click', function() {
$('#appForm')[0].reset();
$('input[name="id"]').val('');
layer.open({
type: 1,
title: '新增应用',
content: $('#appFormModal'),
area: ['500px', '460px'],
btn: false,
shadeClose: false
});
form.render();
});
// 监听更新方式切换(保留事件监听器以备将来扩展)
form.on('select(downloadTypeChange)', function(data) {
// 下载地址字段现在始终显示,无需切换显示状态
});
// 表单提交
form.on('submit(appFormSubmit)', function(data) {
const isEdit = data.field.id !== '';
const url = isEdit ? '/admin/api/apps/update' : '/admin/api/apps/create';
// 转换字段类型为正确的数据类型
const formData = {
...data.field,
status: parseInt(data.field.status) || 0,
download_type: parseInt(data.field.download_type) || 0,
force_update: parseInt(data.field.force_update) || 0
};
// 如果是编辑模式确保id也是整数
if (isEdit) {
formData.id = parseInt(data.field.id);
}
$.ajax({
url: url,
type: 'POST',
data: JSON.stringify(formData),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
layer.closeAll();
appsTable.reload();
} else {
layer.msg(res.msg || '操作失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '操作失败', {icon: 2});
}
});
return false;
});
// 取消按钮
$('#btnCancelApp').on('click', function() {
layer.closeAll();
});
// 表格工具栏事件
table.on('tool(appsTableFilter)', function(obj) {
const data = obj.data;
if (obj.event === 'edit') {
// 编辑
$('#appForm')[0].reset();
$('input[name="id"]').val(data.id);
$('input[name="name"]').val(data.name);
$('input[name="version"]').val(data.version);
$('select[name="status"]').val(data.status);
$('select[name="download_type"]').val(data.download_type || 0);
$('input[name="download_url"]').val(data.download_url || '');
$('select[name="force_update"]').val(data.force_update || 0);
layer.open({
type: 1,
title: '编辑应用',
content: $('#appFormModal'),
area: ['500px', '460px'],
btn: false,
shadeClose: false
});
form.render();
} else if (obj.event === 'del') {
// 删除
layer.confirm('确定删除该应用吗?', {icon: 3, title: '提示'}, function(index) {
$.ajax({
url: '/admin/api/apps/delete',
type: 'POST',
data: JSON.stringify({id: data.id}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '删除失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '删除失败', {icon: 2});
}
});
layer.close(index);
});
}
});
// 批量删除
$('#btnBatchDeleteApps').on('click', function() {
const checkStatus = table.checkStatus('appsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要删除的应用', {icon: 2});
return;
}
layer.confirm('确定删除选中的 ' + data.length + ' 个应用吗?', {icon: 3, title: '提示'}, function(index) {
const ids = data.map(item => item.id);
$.ajax({
url: '/admin/api/apps/batch_delete',
type: 'POST',
data: JSON.stringify({ids: ids}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '批量删除失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '批量删除失败', {icon: 2});
}
});
layer.close(index);
});
});
// 批量启用
$('#btnBatchEnableApps').on('click', function() {
const checkStatus = table.checkStatus('appsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要启用的应用', {icon: 2});
return;
}
const ids = data.map(item => item.id);
$.ajax({
url: '/admin/api/apps/batch_update_status',
type: 'POST',
data: JSON.stringify({ids: ids, status: 1}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '批量启用失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '批量启用失败', {icon: 2});
}
});
});
// 批量禁用
$('#btnBatchDisableApps').on('click', function() {
const checkStatus = table.checkStatus('appsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要禁用的应用', {icon: 2});
return;
}
const ids = data.map(item => item.id);
$.ajax({
url: '/admin/api/apps/batch_update_status',
type: 'POST',
data: JSON.stringify({ids: ids, status: 0}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '批量禁用失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '批量禁用失败', {icon: 2});
}
});
});
});
</script>
</section>
{{ end }}

View File

@@ -0,0 +1,415 @@
{{ define "card_types.html" }}
<section>
<h2>卡密类型管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddCardType"><i class="layui-icon layui-icon-add-1"></i> 新增类型</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableCardTypes"><i class="layui-icon layui-icon-ok-circle"></i> 批量启用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableCardTypes"><i class="layui-icon layui-icon-close-fill"></i> 批量禁用</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteCardTypes"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="cardTypeFilterForm" lay-filter="cardTypeFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">名称</label>
<div class="layui-input-inline">
<input type="text" name="keyword" placeholder="卡密类型名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchCardTypes">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetCardTypes">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">卡密类型列表</div>
<div class="layui-card-body">
<table id="cardTypesTable" lay-filter="cardTypesTableFilter"></table>
<script type="text/html" id="tpl-cardtypes-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容 -->
<div id="cardTypeFormModal" style="display:none;padding:16px">
<!-- 参考demo表单2样式添加layui-form-pane类实现方框风格 -->
<form class="layui-form layui-form-pane" id="cardTypeForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">名称</label>
<div class="layui-input-block">
<input type="text" name="name" required lay-verify="required" placeholder="请输入类型名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">登录</label>
<div class="layui-input-block">
<input type="text" name="login_types" placeholder="请输入登录方式名称,多个用逗号分隔" class="layui-input" />
</div>
</div>
<!-- 操作按钮已移除,统一由 layer.open 的 btn 控制“提交/取消” -->
<!-- 操作按钮移除:统一由 layer.open 的 btn 控制“提交/取消” -->
</form>
</div>
</section>
<script>
layui.use(['table', 'form', 'layer'], () => {
const { table, form, layer, $ } = layui;
let currentFormLayerIndex; // 保存当前表单弹窗的索引
// 渲染表格
const cardTypesTable = table.render({
elem: '#cardTypesTable',
url: '/admin/api/card_types/list',
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
cols: [[
{ type: 'checkbox' },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '名称' },
{
field: 'status',
title: '状态',
width: 100,
templet: (d) => {
return d.status === 1
? '<span class="layui-badge layui-bg-green">启用</span>'
: '<span class="layui-badge">禁用</span>';
}
},
{ field: 'login_types', title: '登录方式' },
{
field: 'created_at',
title: '创建时间',
width: 180,
templet: (d) => {
return formatDateTime(d.created_at);
}
},
{
field: 'updated_at',
title: '更新时间',
width: 180,
templet: (d) => {
return formatDateTime(d.updated_at);
}
},
{ title: '操作', toolbar: '#tpl-cardtypes-ops', width: 150, fixed: 'right' }
]],
parseData: (res) => {
// 后端已返回正确格式,直接使用
return {
"code": res.code,
"msg": res.msg || '',
"count": res.data ? res.data.total : 0,
"data": res.data ? res.data.items : []
};
},
request: {
pageName: 'page',
limitName: 'page_size'
},
where: {}
});
// 格式化日期时间
const formatDateTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
// 监听表格工具条
table.on('tool(cardTypesTableFilter)', (obj) => {
const { data, event } = obj;
if (event === 'edit') {
editCardType(data);
} else if (event === 'del') {
deleteCardType(data.id);
}
});
// 新增卡密类型
$('#btnAddCardType').on('click', () => {
showCardTypeForm();
});
// 显示表单弹窗(统一使用 layer.open 的按钮作为确认/取消)
const showCardTypeForm = (data = null) => {
const title = data ? '编辑卡密类型' : '新增卡密类型';
currentFormLayerIndex = layer.open({
type: 1,
title: title,
content: $('#cardTypeFormModal'),
area: ['500px', '300px'],
btn: ['提交', '取消'],
btnAlign: 'c',
yes: () => {
// 点击“提交”时,执行统一的提交方法
doCardTypeSubmit();
},
btn2: (index) => {
// 点击“取消”时,关闭当前弹窗
layer.close(index);
},
success: () => {
// 成功打开弹窗后渲染表单,并根据是否为编辑模式进行回填
form.render();
if (data) {
// 编辑模式,填充数据
$('#cardTypeForm input[name="id"]').val(data.id);
$('#cardTypeForm input[name="name"]').val(data.name);
$('#cardTypeForm select[name="status"]').val(data.status);
$('#cardTypeForm input[name="login_types"]').val(data.login_types);
form.render('select');
} else {
// 新增模式,清空表单
$('#cardTypeForm')[0].reset();
$('#cardTypeForm input[name="id"]').val('');
form.render();
}
}
});
};
// 编辑卡密类型
const editCardType = (data) => {
showCardTypeForm(data);
};
// 提交表单(通过 layer.open 的“提交”按钮触发)
const doCardTypeSubmit = () => {
// 读取表单数据
const idValue = $('#cardTypeForm input[name="id"]').val();
const formData = {
id: idValue ? parseInt(idValue) : 0,
name: $('#cardTypeForm input[name="name"]').val().trim(),
status: parseInt($('#cardTypeForm select[name="status"]').val()),
login_types: $('#cardTypeForm input[name="login_types"]').val().trim()
};
// 校验必填项
if (!formData.name) {
layer.msg('请输入类型名称', { icon: 2 });
return;
}
// 根据是否存在 id 判断是创建还是更新
const url = formData.id ? '/admin/api/card_types/update' : '/admin/api/card_types/create';
const loadIndex = layer.load(2);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
.then(res => res.json())
.then(res => {
layer.close(loadIndex);
if (res.code === 0) {
layer.msg(res.msg || (formData.id ? '更新成功' : '创建成功'), { icon: 1 });
layer.close(currentFormLayerIndex);
cardTypesTable.reload();
} else {
layer.msg(res.msg || '操作失败', { icon: 2 });
}
})
.catch(() => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
};
// 取消按钮已移除,统一由 layer.open 的“取消”按钮处理
// 取消按钮已移除:统一由 layer.open 的“取消”按钮处理
// 删除卡密类型
const deleteCardType = (id) => {
layer.confirm('确定要删除这个卡密类型吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
fetch('/admin/api/card_types/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardTypesTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 批量删除
$('#btnBatchDeleteCardTypes').on('click', () => {
const checkStatus = table.checkStatus('cardTypesTable');
if (checkStatus.data.length === 0) {
layer.msg('请选择要删除的数据', { icon: 2 });
return;
}
layer.confirm(`确定要删除选中的 ${checkStatus.data.length} 条数据吗?`, {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const ids = checkStatus.data.map(item => item.id);
const loadIndex = layer.load(2);
fetch('/admin/api/card_types/batch_delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardTypesTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
});
// 批量启用
$('#btnBatchEnableCardTypes').on('click', () => {
batchUpdateStatus('/admin/api/card_types/batch_enable', '启用');
});
// 批量禁用
$('#btnBatchDisableCardTypes').on('click', () => {
batchUpdateStatus('/admin/api/card_types/batch_disable', '禁用');
});
// 批量更新状态的通用函数
const batchUpdateStatus = (url, action) => {
const checkStatus = table.checkStatus('cardTypesTable');
if (checkStatus.data.length === 0) {
layer.msg('请选择要操作的数据', { icon: 2 });
return;
}
layer.confirm(`确定要${action}选中的 ${checkStatus.data.length} 条数据吗?`, {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const ids = checkStatus.data.map(item => item.id);
const loadIndex = layer.load(2);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardTypesTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 搜索功能
$('#btnSearchCardTypes').on('click', () => {
const keyword = $('#cardTypeFilterForm input[name="keyword"]').val();
const status = $('#cardTypeFilterForm select[name="status"]').val();
cardTypesTable.reload({
where: {
keyword: keyword,
status: status
},
page: {
curr: 1
}
});
});
// 重置搜索
$('#btnResetCardTypes').on('click', () => {
$('#cardTypeFilterForm')[0].reset();
form.render();
cardTypesTable.reload({
where: {},
page: {
curr: 1
}
});
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,771 @@
{{ define "cards.html" }}
<section>
<h2>卡密管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddCard"><i class="layui-icon layui-icon-add-1"></i> 新增卡密</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteCards"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableCards"><i class="layui-icon layui-icon-ok-circle"></i> 设为未用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableCards"><i class="layui-icon layui-icon-close-fill"></i> 设为已用</button>
<!-- 新增:导出卡密按钮 -->
<button class="layui-btn layui-btn-primary" id="btnExportCards"><i class="layui-icon layui-icon-export"></i> 导出卡密</button>
<!-- 新增:导出选中卡密按钮 -->
<button class="layui-btn layui-btn-primary" id="btnExportSelectedCards"><i class="layui-icon layui-icon-export"></i> 导出选中</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="cardFilterForm" lay-filter="cardFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">卡密</label>
<div class="layui-input-inline">
<input type="text" name="keyword" placeholder="卡号/批次/备注/任务号" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">类型</label>
<div class="layui-input-inline">
<select name="card_type" id="filterCardTypeSelect">
<option value="">全部</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="0">未使用</option>
<option value="1">已使用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchCards">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetCards">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">卡密列表</div>
<div class="layui-card-body">
<table id="cardsTable" lay-filter="cardsTableFilter"></table>
<script type="text/html" id="tpl-cards-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容:新增卡密 -->
<div id="cardFormModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="cardForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">前缀</label>
<div class="layui-input-block">
<input type="text" name="prefix" placeholder="可选,生成卡号时使用的前缀" autocomplete="off" class="layui-input" />
</div>
</div>
<!-- 新增生成数量默认1最大500位置在前缀之后、大小写之前 -->
<div class="layui-form-item">
<label class="layui-form-label">数量</label>
<div class="layui-input-block">
<input type="number" name="count" min="1" max="500" value="1" placeholder="一次生成的数量默认1最大500" class="layui-input" />
</div>
</div>
<!-- 新增:生成大小写选项(默认小写),位置在长度之前 -->
<div class="layui-form-item">
<label class="layui-form-label">规则</label>
<div class="layui-input-block">
<select name="uppercase">
<option value="lower" selected>小写</option>
<option value="upper">大写</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">长度</label>
<div class="layui-input-block">
<input type="number" name="length" min="1" max="64" value="18" placeholder="生成卡号的总长度包含前缀默认18" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<select name="card_type" id="cardTypeSelect">
<option value="">请选择类型</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="0">未使用</option>
<option value="1">已使用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea name="remark" placeholder="可填写备注信息" class="layui-textarea"></textarea>
</div>
</div>
<!-- 移除:内置“操作/提交/取消”按钮,统一由 layer.open 的 btn 控制 -->
</form>
</div>
<!-- 隐藏的表单弹层内容:编辑卡密 -->
<div id="cardEditFormModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="cardEditForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<select name="card_type" id="cardEditTypeSelect">
<option value="">请选择类型</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="0">未使用</option>
<option value="1">已使用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">任务号</label>
<div class="layui-input-block">
<input type="text" name="task_no" placeholder="可选,支持填写/清空任务号" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea name="remark" placeholder="可填写备注信息" class="layui-textarea"></textarea>
</div>
</div>
<!-- 移除:内置“操作/提交/取消”按钮,统一由 layer.open 的 btn 控制 -->
</form>
</div>
<!-- 新增:导出条件弹窗(隐藏) -->
<div id="cardExportModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="cardExportForm" lay-filter="cardExportForm">
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="">全部</option>
<option value="0">未使用</option>
<option value="1">已使用</option>
<option value="2">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<select name="card_type" id="exportCardTypeSelect">
<option value="">全部</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">批次</label>
<div class="layui-input-block">
<input type="text" name="batch" placeholder="按批次模糊匹配" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<input type="text" name="remark" placeholder="按备注模糊匹配" autocomplete="off" class="layui-input" />
</div>
</div>
</form>
</div>
</section>
<script>
layui.use(['table', 'form', 'layer'], () => {
const { table, form, layer, $ } = layui;
let currentFormLayerIndex; // 保存当前表单弹窗的索引
let cardTypes = []; // 存储卡密类型数据
// 中文注释:以下三个标志用于协调类型列表和表格渲染的先后关系,避免出现“未知类型”
let cardTypesLoaded = false; // 类型列表是否已加载完成
let tableFirstRendered = false; // 表格是否已完成首次渲染
let tableReloadedAfterTypes = false; // 类型加载后是否已触发表格的二次渲染
// 格式化时间的辅助函数
const formatDateTime = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return String(date.getFullYear()) + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
// 获取卡密类型名称
// 中文注释:根据 card_type_id 在缓存的 cardTypes 中查找对应的类型名称;
// 为避免后端返回的 id 与前端数据在类型上不一致(字符串/数字)导致匹配失败,这里统一转换为数字再比较
const getCardTypeName = (cardTypeId) => {
const idNum = Number(cardTypeId);
const cardType = cardTypes.find(type => Number(type.id) === idNum);
return cardType ? cardType.name : '未知类型';
};
// 渲染表格
const cardsTable = table.render({
elem: '#cardsTable',
id: 'cardsTable',
url: '/admin/api/cards/list',
parseData: function(res) {
// 后端返回的数据结构:{items, total, page, page_size, pages}
return {
code: res.code,
msg: res.msg || '',
count: res.data ? res.data.total : 0,
data: res.data ? res.data.items : []
};
},
request: {
pageName: 'page', // 页码的参数名称默认page
limitName: 'page_size' // 每页数据量的参数名称默认limit
},
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
// 中文注释:表格首次渲染完成后,如果类型已经加载,则进行一次刷新以正确显示类型名称
done: function() {
if (!tableFirstRendered) {
tableFirstRendered = true;
if (cardTypesLoaded && !tableReloadedAfterTypes) {
tableReloadedAfterTypes = true;
cardsTable.reload();
}
}
},
cols: [[
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'card_number', title: '卡号', minWidth: 150 },
{
field: 'card_type_id',
title: '类型',
width: 100,
templet: (d) => d.card_type_name || getCardTypeName(d.card_type_id)
},
{
field: 'status',
title: '状态',
width: 80,
templet: (d) => {
if (d.status === 0) return '<span style="color: #5FB878;">未使用</span>';
if (d.status === 1) return '<span style="color: #FF5722;">已使用</span>';
return '<span style="color: #999;">禁用</span>';
}
},
{
field: 'task_no',
title: '任务号',
minWidth: 140,
templet: (d) => d.task_no || '-'
},
{ field: 'batch', title: '批次', minWidth: 60 },
{ field: 'remark', title: '备注', minWidth: 100 },
{
field: 'used_at',
title: '使用时间',
width: 180,
templet: (d) => formatDateTime(d.used_at)
},
{
field: 'created_at',
title: '创建时间',
width: 180,
templet: (d) => formatDateTime(d.created_at)
},
{ fixed: 'right', title: '操作', toolbar: '#tpl-cards-ops', width: 120 }
]]
});
// 加载卡密类型数据
const loadCardTypes = () => {
fetch('/admin/api/cards/types?all=1')
.then(response => response.json())
.then(data => {
if (data.code === 0) {
cardTypes = data.data || [];
// 填充筛选下拉框
const filterSelect = $('#filterCardTypeSelect');
filterSelect.empty().append('<option value="">全部</option>');
cardTypes.forEach(type => {
if (type.status === 1) { // 只显示启用的类型
filterSelect.append(`<option value="${type.id}">${type.name}</option>`);
}
});
// 填充新增表单下拉框
const cardTypeSelect = $('#cardTypeSelect');
cardTypeSelect.empty().append('<option value="">请选择类型</option>');
cardTypes.forEach(type => {
if (type.status === 1) { // 只显示启用的类型
cardTypeSelect.append(`<option value="${type.id}">${type.name}</option>`);
}
});
// 填充编辑表单下拉框
const cardEditTypeSelect = $('#cardEditTypeSelect');
cardEditTypeSelect.empty().append('<option value="">请选择类型</option>');
cardTypes.forEach(type => {
if (type.status === 1) { // 只显示启用的类型
cardEditTypeSelect.append(`<option value="${type.id}">${type.name}</option>`);
}
});
// 新增:填充导出弹窗下拉框(显示全部状态的类型,方便条件筛选)
const exportTypeSelect = $('#exportCardTypeSelect');
exportTypeSelect.empty().append('<option value="">全部</option>');
cardTypes.forEach(type => {
exportTypeSelect.append(`<option value="${type.id}">${type.name}</option>`);
});
form.render('select');
// 中文注释:标记类型加载完成;如表格已首次渲染,则进行一次性刷新以正确显示类型名称
cardTypesLoaded = true;
if (tableFirstRendered && !tableReloadedAfterTypes) {
tableReloadedAfterTypes = true;
cardsTable.reload();
}
// 卡密类型加载完成,表格会根据需要自动进行一次刷新
}
})
.catch(error => {
console.error('加载卡密类型失败:', error);
});
};
// 初始化加载卡密类型
loadCardTypes();
// 监听表格工具条
table.on('tool(cardsTableFilter)', (obj) => {
const { data, event } = obj;
if (event === 'edit') {
editCard(data);
} else if (event === 'del') {
deleteCard(data.id);
}
});
// 新增卡密
$('#btnAddCard').on('click', () => {
showCardForm();
});
// 显示新增卡密表单弹窗
// 中文注释:弹出新增/编辑表单的公共方法,采用 layer.open + btn/yes/btn2 的“确认框风格”
// data 为空表示新增;存在表示编辑。通过 yes 回调直接调用提交流程函数。
const showCardForm = (data = null) => {
const title = data ? '编辑卡密' : '新增卡密';
const modalId = data ? '#cardEditFormModal' : '#cardFormModal';
const formId = data ? '#cardEditForm' : '#cardForm';
const areaHeight = data ? '420px' : '600px';
currentFormLayerIndex = layer.open({
type: 1,
title: title,
content: $(modalId),
area: ['500px', areaHeight],
btn: ['提交', '取消'],
btnAlign: 'c',
yes: () => {
if (data) {
doEditCardSubmit();
} else {
doCreateCardSubmit();
}
return false;
},
btn2: (index) => {
layer.close(index);
},
success: () => {
form.render();
if (data) {
$(formId + ' input[name="id"]').val(data.id);
$(formId + ' select[name="card_type"]').val(data.card_type_id);
$(formId + ' select[name="status"]').val(data.status);
$(formId + ' textarea[name="remark"]').val(data.remark || '');
// 中文注释:编辑模式下,回填已有的任务号(若无则为空字符串)
$(formId + ' input[name="task_no"]').val(data.task_no || '');
form.render('select');
} else {
$(formId)[0].reset();
$(formId + ' input[name="id"]').val('');
// 中文注释:新增模式下显式清空任务号,避免出现上一次编辑残留
$(formId + ' input[name="task_no"]').val('');
form.render();
}
}
});
};
// 编辑卡密
const editCard = (data) => {
showCardForm(data);
};
// 提交新增卡密表单
// 提交逻辑函数化,供弹窗按钮直接调用,避免依赖模板内按钮
// 中文注释:提交“新增卡密”表单,完成校验、请求与反馈
const doCreateCardSubmit = () => {
const uppercaseValue = $('#cardForm select[name="uppercase"]').val();
const formData = {
prefix: $('#cardForm input[name="prefix"]').val() || '',
count: parseInt($('#cardForm input[name="count"]').val()) || 1,
uppercase: uppercaseValue === 'upper',
length: parseInt($('#cardForm input[name="length"]').val()) || 18,
card_type_id: parseInt($('#cardForm select[name="card_type"]').val()),
status: parseInt($('#cardForm select[name="status"]').val()),
remark: $('#cardForm textarea[name="remark"]').val() || ''
};
// 校验
if (!formData.card_type_id) {
layer.msg('请选择卡密类型', { icon: 2 });
return;
}
if (formData.count < 1 || formData.count > 500) {
layer.msg('生成数量必须在1-500之间', { icon: 2 });
return;
}
if (formData.length < 1 || formData.length > 64) {
layer.msg('卡号长度必须在1-64之间', { icon: 2 });
return;
}
const loadIndex = layer.load(2);
fetch('/admin/api/cards/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
layer.close(currentFormLayerIndex);
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('新增卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
};
// 中文注释:提交“编辑卡密”表单,完成校验、请求与反馈
const doEditCardSubmit = () => {
const idValue = $('#cardEditForm input[name="id"]').val();
const taskNoRaw = $('#cardEditForm input[name="task_no"]').val();
const hasTaskNoField = true; // 中文注释:该字段始终存在,通过值是否为空决定清空或设置
const formData = {
id: idValue ? parseInt(idValue) : 0,
card_type_id: parseInt($('#cardEditForm select[name="card_type"]').val()),
status: parseInt($('#cardEditForm select[name="status"]').val()),
remark: $('#cardEditForm textarea[name="remark"]').val() || ''
};
// 当任务号输入框有值或被清空时,也要传递 task_no 字段(允许清空)
if (hasTaskNoField) {
formData.task_no = (taskNoRaw || '').trim();
}
// 校验
if (!formData.id) {
layer.msg('卡密ID不能为空', { icon: 2 });
return;
}
if (!formData.card_type_id) {
layer.msg('请选择卡密类型', { icon: 2 });
return;
}
const loadIndex = layer.load(2);
fetch('/admin/api/cards/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
layer.close(currentFormLayerIndex);
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('编辑卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
};
// 删除卡密
const deleteCard = (id) => {
layer.confirm('确定要删除这个卡密吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
fetch('/admin/api/cards/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: parseInt(id) })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('删除卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 批量删除卡密
$('#btnBatchDeleteCards').on('click', () => {
const checkStatus = table.checkStatus('cardsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要删除的卡密', { icon: 2 });
return;
}
layer.confirm(`确定要删除选中的 ${data.length} 个卡密吗?`, {
icon: 3,
title: '批量删除确认'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
const ids = data.map(item => item.id);
fetch('/admin/api/cards/batch_delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('批量删除卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
});
// 批量启用卡密
$('#btnBatchEnableCards').on('click', () => {
batchUpdateStatus(0, '设为未用');
});
// 批量禁用卡密
$('#btnBatchDisableCards').on('click', () => {
batchUpdateStatus(1, '设为已用');
});
// 批量更新状态
const batchUpdateStatus = (status, statusText) => {
const checkStatus = table.checkStatus('cardsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要操作的卡密', { icon: 2 });
return;
}
layer.confirm(`确定要${statusText}选中的 ${data.length} 个卡密吗?`, {
icon: 3,
title: `批量${statusText}确认`
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
const ids = data.map(item => item.id);
fetch('/admin/api/cards/batch_update_status', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids, status: status })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error(`批量${statusText}卡密失败:`, error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 搜索功能
$('#btnSearchCards').on('click', () => {
const formData = form.val('cardFilterForm');
const where = {};
if (formData.card_type) {
where.card_type_id = formData.card_type;
}
if (formData.status !== '') {
where.status = formData.status;
}
if (formData.keyword && formData.keyword.trim() !== '') {
// 中文注释:将关键字作为 keyword 传给后端,由后端在 card_number、remark、batch 三个字段中进行模糊匹配
where.keyword = formData.keyword.trim();
}
table.reload('cardsTable', { where, page: { curr: 1 } });
});
$('#btnResetCards').on('click', () => {
$('#cardFilterForm')[0].reset();
form.render();
table.reload('cardsTable', { where: {}, page: { curr: 1 } });
});
// =============== 导出卡密逻辑 ===============
// 显示“导出卡密”弹窗
// 中文注释:弹出导出条件选择弹窗,允许管理员按状态/类型/批次/备注筛选导出
const showExportDialog = () => {
layer.open({
type: 1,
title: '导出卡密',
content: $('#cardExportModal'),
area: ['520px', '360px'],
btn: ['导出', '取消'],
btnAlign: 'c',
yes: (index) => {
doExportCards();
layer.close(index); // 关闭导出弹窗
layer.msg('卡密导出中...', { icon: 1 });
return false;
},
success: () => {
form.render();
}
});
};
// 绑定按钮事件
$('#btnExportCards').on('click', () => {
showExportDialog();
});
// 导出选中卡密
// 中文注释导出当前表格中选中的卡密无需弹窗筛选直接根据选中的卡密ID进行导出
$('#btnExportSelectedCards').on('click', () => {
const checkStatus = table.checkStatus('cardsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要导出的卡密', { icon: 2 });
return;
}
layer.confirm(`确定要导出选中的 ${data.length} 个卡密吗?`, {
icon: 3,
title: '导出选中确认'
}, (index) => {
layer.close(index);
const ids = data.map(item => item.id);
const params = new URLSearchParams();
params.set('ids', ids.join(','));
const url = '/admin/api/cards/export_selected?' + params.toString();
triggerDownload(url);
layer.msg(`正在导出 ${data.length} 个卡密...`, { icon: 1 });
});
});
// 执行导出
// 中文注释根据表单条件拼接导出URL并以下载方式触发导出CSV 文件)
const doExportCards = () => {
const formData = form.val('cardExportForm');
const params = new URLSearchParams();
if (formData.status !== '') params.set('status', formData.status);
if (formData.card_type) params.set('card_type_id', formData.card_type);
if (formData.batch && formData.batch.trim() !== '') params.set('batch', formData.batch.trim());
if (formData.remark && formData.remark.trim() !== '') params.set('remark', formData.remark.trim());
const url = '/admin/api/cards/export' + (params.toString() ? ('?' + params.toString()) : '');
triggerDownload(url);
};
// 触发下载
// 中文注释:通过创建临时 <a> 元素点击,保持当前页面不跳转,触发后端附件下载
const triggerDownload = (url) => {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
});
</script>
{{ end }}

View File

@@ -0,0 +1,236 @@
{{ define "dashboard.html" }}
<section>
<h2>系统信息</h2>
<div class="layui-row layui-col-space15" style="margin-top:12px">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">基本信息</div>
<div class="layui-card-body">
<div class="system-info-grid">
<div class="system-info-item">
<div class="system-info-label">版本</div>
<div class="system-info-value">{{ .Version }}</div>
</div>
<div class="system-info-item">
<div class="system-info-label">运行模式</div>
<div class="system-info-value">{{ .Mode }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">运行状态</div>
<div class="layui-card-body">
<div class="system-info-grid">
<div class="system-info-item">
<div class="system-info-label">数据库</div>
<div class="system-info-value">{{ .DBType }}</div>
</div>
<div class="system-info-item">
<div class="system-info-label">运行时长</div>
<div class="system-info-value">{{ .Uptime }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 卡密统计区域 -->
<section style="margin-top:16px">
<div class="layui-row layui-col-space15">
<!-- 当日卡密统计 -->
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">当日卡密统计 <span class="layui-badge layui-bg-blue" style="margin-left:8px">总数:<span id="today-total">-</span></span></div>
<div class="layui-card-body">
<div id="chart-today-by-status" style="width:100%;height:320px"></div>
</div>
</div>
</div>
<!-- 所有卡密统计 -->
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">所有卡密统计 <span class="layui-badge layui-bg-blue" style="margin-left:8px">总数:<span id="all-total">-</span></span></div>
<div class="layui-card-body">
<div id="chart-all-by-status" style="width:100%;height:320px"></div>
</div>
</div>
</div>
</div>
<!-- 30天走势图 -->
<div class="layui-row layui-col-space15" style="margin-top:16px">
<div class="layui-col-md12">
<div class="layui-card">
<div class="layui-card-header">近30天卡密走势</div>
<div class="layui-card-body">
<div id="chart-trend-30days" style="width:100%;height:360px"></div>
</div>
</div>
</div>
</div>
</section>
<script>
// 仪表盘统计脚本(采用箭头函数与中文注释)
layui.use(['layer', 'util'], function(){
const layer = layui.layer;
const util = layui.util;
const $ = layui.$;
// 全局引用ECharts CDN 地址
const echartsCdn = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js';
// 工具函数:加载 ECharts 库(若已加载则直接回调)
// 功能:通过全局的 loadScript 方法按需加载图表库,避免重复加载
const ensureECharts = (cb) => {
if (window.echarts) { cb && cb(); return; }
if (typeof loadScript === 'function') {
loadScript(echartsCdn, () => cb && cb());
} else {
// 兜底:直接插入 <script>
const s = document.createElement('script');
s.src = echartsCdn;
s.onload = () => cb && cb();
document.head.appendChild(s);
}
};
// 工具函数:状态码 -> 名称 映射
// 说明卡密状态映射0=未使用1=已使用2=禁用
const getStatusText = (s) => {
const map = {0:'未使用',1:'已使用',2:'禁用'};
const k = Number(s);
return map[k] ?? String(s);
};
// 工具函数:状态码 -> 颜色 映射(与徽章风格一致,尽量贴近 Layui 配色)
const getStatusColor = (s) => {
switch (Number(s)) {
case 0: return '#1E9FFF'; // 蓝色 - 未使用
case 1: return '#5FB878'; // 绿色 - 已使用
case 2: return '#FF5722'; // 红色 - 禁用
default: return '#909399'; // 灰色 - 默认
}
};
// 函数:渲染饼图
// 说明:接收状态分布对象(键为状态码,值为数量),绘制环形图
const renderPie = (domId, byStatus) => {
const el = document.getElementById(domId);
if (!el) return;
const chart = echarts.init(el);
const codes = [0,1,2]; // 卡密状态0=未使用1=已使用2=禁用
const data = codes.map(code => ({
name: getStatusText(code),
value: Number((byStatus && byStatus[code]) || 0),
itemStyle: { color: getStatusColor(code) }
}));
chart.setOption({
tooltip: { trigger: 'item' },
legend: { top: 'bottom' },
series: [{
name: '按状态分布',
type: 'pie',
radius: ['38%', '68%'],
avoidLabelOverlap: true,
label: { formatter: '{b}: {c} ({d}%)' },
data
}]
});
// 自适应
window.addEventListener('resize', () => chart.resize());
return chart;
};
// 函数:渲染 30 天折线图
// 说明三条序列total/used/unused对应后台返回的数组
const renderTrend = (domId, trend) => {
const el = document.getElementById(domId);
if (!el) return;
const chart = echarts.init(el);
const dates = (trend && trend.dates) || [];
const total = (trend && trend.total) || [];
const used = (trend && trend.used) || [];
const unused = (trend && trend.unused) || [];
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['总数', '已使用', '未使用'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: dates },
yAxis: { type: 'value' },
series: [
{ name: '总数', type: 'line', smooth: true, data: total, itemStyle: { color: '#909399' } },
{ name: '已使用', type: 'line', smooth: true, data: used, itemStyle: { color: getStatusColor(1) } },
{ name: '未使用', type: 'line', smooth: true, data: unused, itemStyle: { color: getStatusColor(0) } }
]
});
window.addEventListener('resize', () => chart.resize());
return chart;
};
// 函数:拉取概览并渲染
// 说明:请求 /admin/api/cards/stats_overview更新总数文本并渲染两个饼图
const loadAndRenderOverview = () => {
$.get('/admin/api/cards/stats_overview', (res) => {
if (!res || res.code !== 0) { layer.msg(res && res.msg ? res.msg : '获取统计概览失败'); return; }
const data = res.data || {};
$('#today-total').text((data.today && data.today.total) ?? '-');
$('#all-total').text((data.all && data.all.total) ?? '-');
// 渲染饼图
renderPie('chart-today-by-status', data.today ? data.today.by_status : {});
renderPie('chart-all-by-status', data.all ? data.all.by_status : {});
});
};
// 函数:拉取 30 天数据并渲染折线图
// 说明:请求 /admin/api/cards/trend_30days渲染趋势图
const loadAndRenderTrend = () => {
$.get('/admin/api/cards/trend_30days', (res) => {
if (!res || res.code !== 0) { layer.msg(res && res.msg ? res.msg : '获取30天趋势失败'); return; }
renderTrend('chart-trend-30days', res.data || {});
});
};
// 函数:刷新基本信息和运行状态
// 说明:请求后台获取最新的系统信息并更新页面显示
const refreshSystemInfo = () => {
$.get('/admin/api/system/info', (res) => {
if (res && res.code === 0 && res.data) {
const data = res.data;
// 更新运行时长
if (data.uptime) {
$('.system-info-item').each(function() {
const label = $(this).find('.system-info-label').text();
if (label === '运行时长') {
$(this).find('.system-info-value').text(data.uptime);
}
});
}
}
}).fail(() => {
console.log('获取系统信息失败');
});
};
// 入口:确保 ECharts 已加载后开始渲染
ensureECharts(() => {
loadAndRenderOverview();
loadAndRenderTrend();
// 立即刷新一次系统信息
refreshSystemInfo();
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{{ .Title }} - {{ .SystemName }}</title>
<link rel="stylesheet" href="/static/css/admin.css" />
<script type="module" src="./static/lib/include.js"></script>
</head>
<body>
<div class="layui-layout layui-layout-admin" id="app">
<div class="layui-header">
<!-- 头部区域可配合layui 已有的水平导航) -->
<ul class="layui-nav layui-layout-left">
<!-- 移动端显示 -->
<li class="layui-nav-item layui-show-xs-inline-block" lay-header-event="menuLeft">
<i class="layui-icon layui-icon-spread-left"></i>
</li>
</ul>
<ul class="layui-nav layui-layout-right">
<!-- 刷新页面按钮 -->
<li class="layui-nav-item" lay-unselect>
<a href="javascript:;" id="refresh-btn" style="background-color: unset" title="刷新页面">
<i class="layui-icon layui-icon-refresh-3" style="font-size: 20px"></i>
</a>
</li>
<li class="layui-nav-item">
<i id="change-theme" class="layui-icon layui-icon-theme" style="font-size: 20px"></i>
</li>
<li class="layui-nav-item" lay-unselect>
<a href="javascript:;" id="logout-btn" style="background-color: unset" title="退出登录">
<i class="layui-icon layui-icon-logout" style="font-size: 20px"></i>
</a>
</li>
</ul>
</div>
<div class="layui-side layui-bg-black">
<div class="layui-side-scroll">
<!-- 左侧导航区域 -->
<div class="layui-logo layui-bg-black logo-enhanced">{{ .SystemName }}</div>
<ul class="layui-nav layui-nav-tree" lay-shrink="all" lay-unselect lay-filter="nav-side" id="ws-nav-side">
<li class="layui-nav-item">
<a class="" href="javascript:;">系统管理</a>
<dl class="layui-nav-child">
<dd><a data-path="dashboard" href="javascript:;">仪表盘</a></dd>
<dd><a data-path="user" href="javascript:;">个人资料</a></dd>
<dd><a data-path="settings" href="javascript:;">系统设置</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">应用管理</a>
<dl class="layui-nav-child">
<dd><a data-path="apps" href="javascript:;">应用列表</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">卡密管理</a>
<dl class="layui-nav-child">
<dd><a data-path="logintypes" href="javascript:;">登录类型</a></dd>
<dd><a data-path="cardtypes" href="javascript:;">卡密类型</a></dd>
<dd><a data-path="cards" href="javascript:;">卡密列表</a></dd>
</dl>
</li>
</ul>
</div>
</div>
<div class="layui-body">
<!-- 内容主体区域 -->
<wc-include id="router-view" allow-scripts></wc-include>
</div>
<div class="layui-footer">{{ .FooterText }}</div>
</div>
<script type="module" src="./static/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,259 @@
{{/* 管理员登录页面模板使用layui构建的登录界面 */}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="https://unpkg.com/layui@2.10.1/dist/css/layui.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 400px;
margin: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px 20px;
text-align: center;
color: #fff;
}
.login-header h1 {
margin: 0;
font-size: 24px;
font-weight: 300;
}
.login-header p {
margin: 8px 0 0;
opacity: 0.8;
font-size: 14px;
}
.login-form {
padding: 40px 30px;
}
.layui-form-item {
margin-bottom: 25px;
}
.layui-input {
border: 1px solid #e6e6e6;
border-radius: 4px;
padding: 12px 15px;
font-size: 14px;
transition: all 0.3s;
}
.layui-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.layui-btn-fluid {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 4px;
padding: 12px;
font-size: 16px;
letter-spacing: 1px;
transition: all 0.3s;
}
.layui-btn-fluid:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
/* 修复登录按钮文字垂直位置偏下使用flex进行垂直水平居中并设置固定高度 */
.login-btn {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 16px;
line-height: normal; /* 避免与高度不一致导致的文字偏移 */
font-weight: 500;
}
.error-msg {
color: #ff5722;
font-size: 12px;
margin-top: 5px;
display: none;
}
.login-footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
border-top: 1px solid #f0f0f0;
}
/* 响应式设计 - 移动端适配 */
@media (max-width: 768px) {
.login-container {
margin: 10px;
border-radius: 4px;
}
.login-header {
padding: 25px 15px;
}
.login-header h1 {
font-size: 20px;
}
.login-form {
padding: 30px 20px;
}
.layui-form-item {
margin-bottom: 20px;
}
}
@media (max-width: 480px) {
.login-container {
margin: 5px;
border-radius: 0;
min-height: calc(100vh - 10px);
display: flex;
flex-direction: column;
}
.login-header {
padding: 20px 15px;
}
.login-header h1 {
font-size: 18px;
}
.login-form {
padding: 25px 15px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.layui-input {
padding: 15px;
font-size: 16px; /* 防止iOS缩放 */
}
.login-btn {
height: 48px;
font-size: 16px;
}
}
/* 超小屏幕适配 */
@media (max-width: 320px) {
.login-form {
padding: 20px 10px;
}
.login-header {
padding: 15px 10px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>{{ .SystemName }}</h1>
<p>管理员登录</p>
</div>
<div class="login-form">
<form class="layui-form" id="loginForm">
<div class="layui-form-item">
<div class="layui-input-wrap">
<div class="layui-input-prefix">
<i class="layui-icon layui-icon-username"></i>
</div>
<input type="text" name="username" placeholder="请输入用户名" lay-verify="required" lay-reqtext="请输入用户名" class="layui-input" autocomplete="off">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-wrap">
<div class="layui-input-prefix">
<i class="layui-icon layui-icon-password"></i>
</div>
<input type="password" name="password" placeholder="请输入密码" lay-verify="required" lay-reqtext="请输入密码" class="layui-input" autocomplete="off">
</div>
</div>
<div class="layui-form-item">
<input type="checkbox" name="remember" title="记住登录状态" lay-skin="primary">
</div>
<div class="layui-form-item">
<button class="layui-btn layui-btn-fluid login-btn" lay-submit lay-filter="login">立即登录</button>
</div>
<div class="error-msg" id="errorMsg"></div>
</form>
</div>
<div class="login-footer">
<p>{{ .FooterText }}</p>
</div>
</div>
<script src="https://unpkg.com/layui@2.10.1/dist/layui.js"></script>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
// 登录提交回调:向 /admin/login 发送请求,并依据 code===0 判断成功与否
form.on('submit(login)', function(data){
var loadIndex = layer.load(1, {
shade: [0.1, '#fff']
});
// 发送登录请求
fetch('/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data.field)
})
.then(response => response.json())
.then(result => {
layer.close(loadIndex);
// 根据统一接口code === 0 表示成功
const isOk = result && result.code === 0;
if (isOk) {
layer.msg('登录成功', {
icon: 1,
time: 1500
}, function(){
const redirect = (result.data && result.data.redirect) || '/admin';
window.location.href = redirect;
});
} else {
const msg = (result && (result.msg || result.message)) || '登录失败,请检查用户名和密码';
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('errorMsg').textContent = msg;
layer.msg(msg, {icon: 2});
}
})
.catch(error => {
layer.close(loadIndex);
console.error('登录错误:', error);
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('errorMsg').textContent = '网络错误,请稍后重试';
layer.msg('网络错误,请稍后重试', {icon: 2});
});
return false; // 阻止表单跳转
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,422 @@
{{ define "login_types.html" }}
<section>
<h2>登录方式管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddLoginType"><i class="layui-icon layui-icon-add-1"></i> 新增方式</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableLoginTypes"><i class="layui-icon layui-icon-ok-circle"></i> 批量启用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableLoginTypes"><i class="layui-icon layui-icon-close-fill"></i> 批量禁用</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteLoginTypes"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="loginTypeFilterForm" lay-filter="loginTypeFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">名称</label>
<div class="layui-input-inline">
<input type="text" name="keyword" placeholder="登录方式名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchLoginTypes">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetLoginTypes">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">登录方式列表</div>
<div class="layui-card-body">
<table id="loginTypesTable" lay-filter="loginTypesTableFilter"></table>
<script type="text/html" id="tpl-logintypes-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容 -->
<div id="loginTypeFormModal" style="display:none;padding:16px">
<!-- 参考demo表单2样式添加layui-form-pane类实现方框风格 -->
<form class="layui-form layui-form-pane" id="loginTypeForm" lay-filter="loginTypeForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">名称</label>
<div class="layui-input-block">
<input type="text" name="name" required lay-verify="required" placeholder="请输入登录方式名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">验证类型</label>
<div class="layui-input-block">
<input type="text" name="verify_types" placeholder="请输入验证类型,多个用逗号分隔" autocomplete="off" class="layui-input" />
</div>
</div>
<!-- 操作按钮移除:统一由 layer.open 的 btn 控制“提交/取消” -->
</form>
</div>
</section>
<script>
// 登录类型管理页面的JavaScript脚本
layui.use(['table', 'form', 'layer'], function(){
const table = layui.table;
const form = layui.form;
const layer = layui.layer;
const $ = layui.$;
// 表格实例
let tableIns;
// 初始化表格
const initTable = () => {
tableIns = table.render({
elem: '#loginTypesTable',
url: '/admin/api/login_types/list',
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
cols: [[
{type: 'checkbox'},
{field: 'id', title: 'ID', width: 80, sort: true},
{field: 'name', title: '名称'},
{field: 'status', title: '状态', width: 100, templet: function(d){
return d.status === 1 ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">禁用</span>';
}},
{field: 'verify_types', title: '验证类型'},
{field: 'created_at', title: '创建时间', width: 180, templet: function(d){
return formatDateTime(d.created_at);
}},
{field: 'updated_at', title: '更新时间', width: 180, templet: function(d){
return formatDateTime(d.updated_at);
}},
{title: '操作', toolbar: '#tpl-logintypes-ops', width: 150, fixed: 'right'}
]],
parseData: function(res){
// 后端已返回正确格式,直接使用
return {
"code": res.code,
"msg": res.msg || '',
"count": res.data ? res.data.total : 0,
"data": res.data ? res.data.items : []
};
},
request: {
pageName: 'page',
limitName: 'page_size'
},
where: {}
});
};
// 格式化日期时间
const formatDateTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
// 重载表格数据
const reloadTable = (where = {}) => {
tableIns.reload({
where: where,
page: {
curr: 1
}
});
};
// 获取选中的行数据
const getCheckData = () => {
const checkStatus = table.checkStatus('loginTypesTable');
return checkStatus.data;
};
// 当前表单弹窗索引
let currentFormLayerIndex = null;
// 显示表单弹层(统一使用 layer.open 的按钮作为确认/取消)
const showFormModal = (title, data = {}) => {
// 重置表单
$('#loginTypeForm')[0].reset();
// 填充表单数据
if (data.id) {
$('input[name="id"]').val(data.id);
$('input[name="name"]').val(data.name);
$('select[name="status"]').val(data.status);
$('input[name="verify_types"]').val(data.verify_types);
}
// 刷新表单渲染
form.render();
// 显示弹层并保存索引
currentFormLayerIndex = layer.open({
type: 1,
title: title,
content: $('#loginTypeFormModal'),
area: ['500px', '300px'],
btn: ['提交', '取消'],
btnAlign: 'c',
yes: () => {
// 点击“提交”时执行提交
doLoginTypeSubmit();
},
btn2: (index) => {
// 点击“取消”时关闭弹层
layer.close(index);
},
closeBtn: 1
});
};
// 提交表单(通过 layer.open 的“提交”按钮触发)
const doLoginTypeSubmit = () => {
// 读取表单数据并校验
const idVal = $('input[name="id"]').val();
const isEdit = idVal && idVal !== '';
const name = $('input[name="name"]').val().trim();
const status = parseInt($('select[name="status"]').val() || '0');
const verifyTypes = $('input[name="verify_types"]').val().trim();
if (!name) {
layer.msg('请输入登录方式名称', { icon: 2 });
return;
}
const url = isEdit ? '/admin/api/login_types/update' : '/admin/api/login_types/create';
const requestData = {
name: name,
status: status,
verify_types: verifyTypes
};
if (isEdit) requestData.id = parseInt(idVal);
$.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
success: function(res) {
if (res.code === 0) {
layer.msg(isEdit ? '更新成功' : '创建成功', { icon: 1, time: 1000 });
layer.close(currentFormLayerIndex);
reloadTable();
} else {
// 失败时对提示信息做截断,避免过长
const raw = res.msg || '操作失败';
const shortMsg = raw.length > 100 ? (raw.slice(0, 100) + '...') : raw;
layer.msg(shortMsg, { icon: 2 });
}
},
// 优先展示后端返回的业务错误信息,避免统一显示“网络错误”
error: (xhr) => {
// 失败时对提示信息做截断,避免过长
const raw = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '网络错误,请重试';
const shortMsg = raw.length > 100 ? (raw.slice(0, 100) + '...') : raw;
layer.msg(shortMsg, { icon: 2 });
}
});
};
// 删除单个记录
// 说明删除前二次确认后端返回400/500也能显示具体错误信息
const deleteItem = (id) => {
layer.confirm('确定要删除这条记录吗?', {icon: 3, title: '提示'}, function(index){
$.ajax({
url: '/admin/api/login_types/delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: id}),
success: function(res) {
if (res.code === 0) {
layer.msg('删除成功', { icon: 1, time: 3000 });
reloadTable();
} else {
// 删除失败:使用折行展示错误信息,便于阅读(不再截断)
const raw = res.msg || '删除失败';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// 将常见分隔符替换为换行,结合 white-space: pre-wrap 生效
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
},
// 解析后端JSON错误响应展示msg内容支持折行
error: (xhr) => {
const raw = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '网络错误,请重试';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
});
layer.close(index);
});
};
// 批量操作
// 参数operation 用于提示文案url 为接口地址confirmMsg 为确认提示语
const batchOperation = (operation, url, confirmMsg) => {
const checkData = getCheckData();
if (checkData.length === 0) {
layer.msg('请选择要操作的数据', { icon: 2 });
return;
}
const ids = checkData.map(item => item.id);
layer.confirm(confirmMsg, {icon: 3, title: '提示'}, function(index){
$.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ids: ids}),
// 统一成功/失败提示移除残留的diff标记
success: function(res) {
if (res.code === 0) {
layer.msg(operation + '成功', { icon: 1 });
reloadTable();
} else {
// 批量失败:使用折行展示长信息(例如占用明细),便于阅读
const raw = res.msg || operation + '失败';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
},
// 出错时同样尝试展示后端返回的msg支持折行
error: (xhr) => {
const raw = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '网络错误,请重试';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
});
layer.close(index);
});
};
// 事件绑定
// 新增按钮
$('#btnAddLoginType').on('click', function(){
showFormModal('新增登录方式');
});
// 批量启用按钮
$('#btnBatchEnableLoginTypes').on('click', function(){
batchOperation('批量启用', '/admin/api/login_types/batch_enable', '确定要启用选中的登录方式吗?');
});
// 批量禁用按钮
$('#btnBatchDisableLoginTypes').on('click', function(){
batchOperation('批量禁用', '/admin/api/login_types/batch_disable', '确定要禁用选中的登录方式吗?');
});
// 批量删除按钮
$('#btnBatchDeleteLoginTypes').on('click', function(){
batchOperation('批量删除', '/admin/api/login_types/batch_delete', '确定要删除选中的登录方式吗?删除后不可恢复!');
});
// 查询按钮
$('#btnSearchLoginTypes').on('click', function(){
const formData = form.val('loginTypeFilterForm');
const where = {};
if (formData.keyword && formData.keyword.trim() !== '') {
where.keyword = formData.keyword.trim();
}
if (formData.status && formData.status !== '') {
where.status = formData.status;
}
reloadTable(where);
});
// 重置按钮
$('#btnResetLoginTypes').on('click', function(){
$('#loginTypeFilterForm')[0].reset();
form.render();
reloadTable();
});
// Layui表单提交事件
// 删除 Layui 表单提交监听(由 layer.open 的“提交”按钮统一触发)
// form.on('submit(loginTypeSubmit)', function(data){
// submitForm();
// return false; // 阻止表单跳转
// });
// 删除表单取消按钮事件(由 layer.open 的“取消”按钮统一处理)
// $('#btnCancelLoginType').on('click', function(){
// layer.close(currentFormLayerIndex);
// });
// 表格工具栏事件
table.on('tool(loginTypesTableFilter)', function(obj){
const data = obj.data;
const layEvent = obj.event;
if (layEvent === 'edit') {
showFormModal('编辑登录方式', data);
} else if (layEvent === 'del') {
deleteItem(data.id);
}
});
// 初始化页面
initTable();
});
</script>
{{ end }}

View File

@@ -0,0 +1,277 @@
{{ define "settings.html" }}
<section>
<h2>系统设置</h2>
<!-- 基本信息设置 -->
<div class="layui-card" style="margin-top: 16px;">
<div class="layui-card-header">基本信息设置</div>
<div class="layui-card-body">
<form class="layui-form" id="basicForm">
<div class="layui-form-item">
<label class="layui-form-label">站点标题</label>
<div class="layui-input-block">
<input type="text" name="site_title" lay-verify="required" placeholder="请输入站点标题" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">关键词</label>
<div class="layui-input-block">
<input type="text" name="site_keywords" placeholder="请输入站点关键词,多个关键词用逗号分隔" class="layui-input" />
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">站点描述</label>
<div class="layui-input-block">
<textarea name="site_description" placeholder="请输入站点描述" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点Logo</label>
<div class="layui-input-block">
<input type="text" name="site_logo" placeholder="/assets/logo.svg" class="layui-input" />
</div>
</div>
</form>
</div>
</div>
<!-- 系统配置设置 -->
<div class="layui-card" style="margin-top: 16px;">
<div class="layui-card-header">系统配置</div>
<div class="layui-card-body">
<form class="layui-form" id="systemForm">
<div class="layui-form-item">
<label class="layui-form-label">关闭系统</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; justify-content: flex-start; gap: 10px;">
<input type="checkbox" name="maintenance_mode" lay-skin="switch" lay-text="关闭系统|开启系统" title="关闭系统|开启系统">
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">默认角色</label>
<div class="layui-input-block">
<select name="default_user_role">
<option value="0">管理员</option>
<option value="1">普通用户</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">会话超时</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<input type="number" name="session_timeout" placeholder="3600" min="300" max="86400" class="layui-input" style="width: 120px;" />
<span class="layui-form-mid">300-86400秒</span>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- 页脚与备案信息 -->
<div class="layui-card" style="margin-top: 16px;">
<div class="layui-card-header">页脚与备案</div>
<div class="layui-card-body">
<form class="layui-form" id="footerForm">
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">页脚文本</label>
<div class="layui-input-block">
<textarea name="footer_text" placeholder="© 2025 凌动技术 保留所有权利" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">ICP备案</label>
<div class="layui-input-block">
<input type="text" name="icp_record" placeholder="京ICP备12345678号" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备案链接</label>
<div class="layui-input-block">
<input type="url" name="icp_record_link" placeholder="https://beian.miit.gov.cn" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">公安备案</label>
<div class="layui-input-block">
<input type="text" name="psb_record" placeholder="京公网安备11010802012345号" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备案链接</label>
<div class="layui-input-block">
<input type="url" name="psb_record_link" placeholder="http://www.beian.gov.cn/portal/registerSystemInfo" class="layui-input" />
</div>
</div>
</form>
</div>
</div>
<!-- 操作按钮 -->
<div class="layui-form-item" style="margin-top: 24px;">
<div class="layui-input-block">
<button type="button" class="layui-btn" id="saveAllBtn" lay-submit lay-filter="saveAll">保存所有设置</button>
<button type="button" class="layui-btn layui-btn-primary" id="resetBtn">重置</button>
</div>
</div>
</section>
<script>
layui.use(['jquery', 'form', 'layer'], function() {
const { $, form, layer } = layui;
// 缓存上次加载的设置值,用于“重置”恢复
let originalSettings = {};
/**
* 加载后台所有设置并回填到三个表单
* - 从 /admin/api/settings 获取 name:value 映射
* - 处理开关型字段maintenance_mode
* - 渲染 layui 组件
*/
const loadSettings = async () => {
try {
const res = await fetch('/admin/api/settings', {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
if (data.code !== 0) {
layer.msg(data.msg || '加载设置失败', { icon: 2 });
return;
}
originalSettings = data.data || {};
fillForms(originalSettings);
} catch (err) {
console.error('获取设置失败:', err);
layer.msg('网络错误,无法加载设置', { icon: 2 });
}
};
/**
* 将 settings 数据回填到各表单控件
* - 文本/文本域/下拉:直接赋值
* - 开关:根据 "1"/"0" 置为选中/未选中
*/
const fillForms = (settings = {}) => {
// 基本信息
$('[name="site_title"]').val(settings.site_title || '');
$('[name="site_keywords"]').val(settings.site_keywords || '');
$('[name="site_description"]').val(settings.site_description || '');
$('[name="site_logo"]').val(settings.site_logo || '');
// 系统配置
const maintenanceChecked = (settings.maintenance_mode || '0') === '1';
$('[name="maintenance_mode"]').prop('checked', maintenanceChecked);
$('[name="default_user_role"]').val(settings.default_user_role || '1');
$('[name="session_timeout"]').val(settings.session_timeout || '3600');
// 页脚与备案
$('[name="footer_text"]').val(settings.footer_text || '');
$('[name="icp_record"]').val(settings.icp_record || '');
$('[name="icp_record_link"]').val(settings.icp_record_link || '');
$('[name="psb_record"]').val(settings.psb_record || '');
$('[name="psb_record_link"]').val(settings.psb_record_link || '');
// 渲染 layui 组件
form.render();
};
/**
* 收集某个表单下所有可用控件的值
* - 统一将 checkbox 转为 "1"/"0"
* - 其他控件转为字符串,避免后端类型不一致
*/
const collectForm = (selector) => {
const obj = {};
const $form = $(selector);
$form.find('input, textarea, select').each(function() {
const $el = $(this);
const name = $el.attr('name');
if (!name) return; // 无 name 不纳入
const type = ($el.attr('type') || '').toLowerCase();
let value = '';
if (type === 'checkbox') {
value = $el.prop('checked') ? '1' : '0';
} else {
value = ($el.val() ?? '').toString();
}
obj[name] = value;
});
return obj;
};
/**
* 汇总三个表单的字段为一个扁平对象
*/
const collectAllSettings = () => {
return {
...collectForm('#basicForm'),
...collectForm('#systemForm'),
...collectForm('#footerForm'),
};
};
/**
* 处理“保存所有设置”点击
* - 二次确认后提交
* - 显示加载中,防重复提交
* - 成功后提示并刷新缓存的 originalSettings
*/
const handleSaveAll = () => {
const payload = collectAllSettings();
layer.confirm('确认保存所有设置?', { icon: 3, title: '提示' }, (idx) => {
layer.close(idx);
const btn = $('#saveAllBtn');
btn.prop('disabled', true).addClass('layui-btn-disabled');
const loadIdx = layer.load(2, { content: '正在保存...' });
fetch('/admin/api/settings/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(resp => resp.json())
.then(res => {
if (res.code === 0) {
layer.msg(res.msg || '保存成功', { icon: 1, time: 1000 });
originalSettings = { ...payload };
} else {
layer.msg(res.msg || '保存失败', { icon: 2 });
}
})
.catch(err => {
console.error('保存设置失败:', err);
layer.msg('网络错误,保存失败', { icon: 2 });
})
.finally(() => {
layer.close(loadIdx);
btn.prop('disabled', false).removeClass('layui-btn-disabled');
});
});
};
/**
* 处理“重置”点击
* - 恢复为上次加载的 originalSettings
*/
const handleReset = () => {
fillForms(originalSettings);
layer.msg('已恢复到上次加载的值', { icon: 1, time: 800 });
};
// 事件绑定
$('#saveAllBtn').off('click').on('click', handleSaveAll);
$('#resetBtn').off('click').on('click', handleReset);
// 初始化:加载设置
loadSettings();
});
</script>
{{ end }}

View File

@@ -0,0 +1,256 @@
{{ define "user.html" }}
<div class="layui-card">
<div class="layui-card-header">个人资料</div>
<div class="layui-card-body">
<form class="layui-form" id="accountForm" lay-filter="accountForm" onsubmit="return false">
<!-- 按照要求纵向排序ID、角色、用户名、旧密码、新密码、确认新密码 -->
<div class="layui-form-item">
<label class="layui-form-label">ID</label>
<div class="layui-input-block">
<input type="text" name="id" disabled readonly class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">角色</label>
<div class="layui-input-block">
<!-- 角色禁用与只读,仅作展示用途,显示中文标签“管理员/普通成员” -->
<input type="text" name="role" disabled readonly class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="username" placeholder="请输入用户名(不修改可留空或保持不变)" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">旧密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="old_password" placeholder="不修改可留空" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">新密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="new_password" placeholder="不修改可留空至少6位" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="confirm_password" placeholder="不修改可留空" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="submitAccount">保存更改</button>
<!-- 将原先 type="reset" 改为自定义按钮,避免浏览器重置成初始空值 -->
<button type="button" id="btnReset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
<script>
// 使用自执行函数创建局部作用域,避免与其他页面脚本发生全局命名冲突
(() => {
// 工具方法:将数值角色转为中文标签
// 0 => 管理员1 => 普通成员
const roleToText = (role) => {
// 将可能的字符串数值转为数字
const r = typeof role === 'string' ? parseInt(role, 10) : role
return r === 0 ? '管理员' : '普通成员'
}
// 如果未加载 layui则按需加载兼容用户直接访问片段页 /admin/user
// 说明:当 window.layui 不存在时,动态引入 Layui 的 CSS 和 JS加载完成后再执行页面逻辑
const ensureLayui = () => new Promise((resolve) => {
if (window.layui) return resolve(window.layui)
const css = document.createElement('link')
css.rel = 'stylesheet'
css.href = 'https://unpkg.com/layui@2.10.1/dist/css/layui.css'
document.head.appendChild(css)
const script = document.createElement('script')
script.src = 'https://unpkg.com/layui@2.10.1/dist/layui.js'
script.onload = () => resolve(window.layui)
document.head.appendChild(script)
})
// 在确保 Layui 可用后再执行页面逻辑
ensureLayui().then(() => {
layui.use(['form', 'layer'], () => {
const form = layui.form
const layer = layui.layer
// 记录初始用户名,用于判断是否需要更新
let initialUsername = ''
// 缓存最近一次加载到表单中的资料,用于“重置”恢复
let lastProfile = null
// 加载个人资料填充ID/用户名/角色(角色显示中文标签并禁用)
// 返回:无;副作用:设置 initialUsername、lastProfile 与表单值
const loadProfile = async () => {
try {
const res = await fetch('/admin/api/user/profile')
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '加载失败')
const payload = data.data || {}
initialUsername = payload.username || ''
// 将角色转换为中文展示,并缓存为最近一次加载的“默认值”
const display = { ...payload, role: roleToText(payload.role) }
lastProfile = display
form.val('accountForm', display)
} catch (e) {
layer.msg(e.message || '加载个人资料失败', { icon: 2 })
}
}
// 校验密码表单:当任一密码字段填写时,要求三个字段均填写且有效
// 返回:{ ok: boolean, msg?: string }
const validatePassword = (fields) => {
const oldPwd = (fields.old_password || '').trim()
const newPwd = (fields.new_password || '').trim()
const confirmPwd = (fields.confirm_password || '').trim()
const anyFilled = !!(oldPwd || newPwd || confirmPwd)
if (!anyFilled) return { ok: true }
if (!oldPwd || !newPwd || !confirmPwd) return { ok: false, msg: '请完整填写旧密码/新密码/确认新密码' }
if (newPwd.length < 6) return { ok: false, msg: '新密码长度不能少于6位' }
if (newPwd !== confirmPwd) return { ok: false, msg: '两次输入的新密码不一致' }
if (oldPwd === newPwd) return { ok: false, msg: '新密码不能与旧密码相同' }
return { ok: true }
}
// 更新用户名:传输 username 与 old_password当仅修改用户名时必须提供当前密码同时修改密码时沿用同一 old_password
// 返回Promise<void>
const updateUsername = async (username, oldPassword) => {
const payload = { username }
if (oldPassword) payload.old_password = oldPassword
const res = await fetch('/admin/api/user/profile/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '保存资料失败')
}
// 更新密码:仅传输旧/新/确认三个字段
// 返回Promise<any> 后端响应数据,用于可能的重定向处理
const updatePassword = async (fields) => {
const payload = {
old_password: fields.old_password,
new_password: fields.new_password,
confirm_password: fields.confirm_password
}
const res = await fetch('/admin/api/user/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '修改密码失败')
return data
}
// 提交综合更新:
// 规则:
// - 用户名:仅当与 initialUsername 不同且非空时更新
// - 密码:当任一密码字段填写时,要求完整校验并更新;若均未填则不更新
// - 若两者均无改动,则提示“未修改任何内容”
form.on('submit(submitAccount)', async (obj) => {
const fields = obj.field
const desiredUsername = (fields.username || '').trim()
const needUpdateUsername = desiredUsername && desiredUsername !== initialUsername
// 判定密码相关输入:
// - wantChangePassword输入了新密码或确认密码视为尝试修改密码将要求三个字段都填写
// - onlyOldProvided仅输入了旧密码用于支持“仅修改用户名需要当前密码”的场景
const hasOld = !!(fields.old_password && fields.old_password.trim())
const hasNewOrConfirm = !!((fields.new_password && fields.new_password.trim()) || (fields.confirm_password && fields.confirm_password.trim()))
const wantChangePassword = hasNewOrConfirm
const onlyOldProvided = hasOld && !hasNewOrConfirm
if (!needUpdateUsername && !wantChangePassword) {
layer.msg('未修改任何内容', { icon: 0 })
return false
}
// 修改密码场景:需进行严格校验(旧/新/确认均必填)
if (wantChangePassword) {
const pwdCheck = validatePassword(fields)
if (!pwdCheck.ok) {
layer.msg(pwdCheck.msg, { icon: 2 })
return false
}
}
// 仅修改用户名:要求输入当前密码
if (needUpdateUsername && !wantChangePassword && !hasOld) {
layer.msg('修改用户名需要输入当前密码', { icon: 2 })
return false
}
try {
// 始终先更新用户名,再更新密码(避免改密后跳转导致无法继续)
if (needUpdateUsername) {
await updateUsername(desiredUsername, hasOld ? fields.old_password : '')
initialUsername = desiredUsername
}
if (wantChangePassword) {
const pwdResp = await updatePassword(fields)
// 修改密码后通常需要重新登录,优先使用后端返回的 redirect否则默认登录页
const redirect = pwdResp && pwdResp.data && pwdResp.data.redirect ? pwdResp.data.redirect : '/admin/login'
layer.msg('密码修改成功,即将跳转到登录页', { icon: 1, time: 1200 }, () => {
window.location.href = redirect
})
} else {
// 未修改密码,仅修改资料
await loadProfile()
layer.msg('保存成功', { icon: 1 })
}
} catch (e) {
layer.msg(e.message || '保存失败', { icon: 2 })
}
return false
})
// 绑定“重置”按钮:将表单恢复为最近一次加载到表单中的资料
// 逻辑:
// - 如有 lastProfile直接回填
// - 回填时同时清空三个密码字段;
// - 如暂无缓存(极小概率),则重新请求资料
const bindReset = () => {
const btn = document.getElementById('btnReset')
if (!btn) return
btn.addEventListener('click', () => {
if (lastProfile) {
form.val('accountForm', { ...lastProfile, old_password: '', new_password: '', confirm_password: '' })
layer.msg('已恢复为当前资料', { icon: 1 })
} else {
loadProfile()
}
})
}
// 初始化加载
bindReset()
loadProfile()
})
})
})()
</script>
{{ end }}