mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-05 13:05:52 +08:00
447 lines
21 KiB
HTML
447 lines
21 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CDK 管理</title>
|
||
<link rel="stylesheet" href="/lib/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
|
||
<script src="/lib/js/vue.global.prod.js"></script>
|
||
<script src="/lib/js/bootstrap.bundle.min.js"></script>
|
||
<style>
|
||
body { padding: 20px; background-color: #f8f9fa; }
|
||
.card { border: none; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
||
.table th { font-weight: 600; background-color: #f8f9fa; }
|
||
.status-unused { color: #28a745; font-weight: 500; }
|
||
.status-used { color: #6c757d; text-decoration: line-through; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app" class="container-fluid">
|
||
<div class="card">
|
||
<div class="card-header bg-white d-flex justify-content-between align-items-center py-3">
|
||
<h5 class="mb-0"><i class="fa fa-key text-primary me-2"></i>CDK 管理</h5>
|
||
<div>
|
||
<button class="btn btn-outline-secondary btn-sm me-2" @click="copySelected" :disabled="selected.length === 0">
|
||
<i class="fa fa-copy me-1"></i> 复制选中 ({{ selected.length }})
|
||
</button>
|
||
<button class="btn btn-danger btn-sm me-2" v-if="checkPermission('cdks.delete')" @click="deleteSelected" :disabled="selected.length === 0">
|
||
<i class="fa fa-trash me-1"></i> 删除选中 ({{ selected.length }})
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" v-if="checkPermission('cdks.add')" @click="showGenerateModal">
|
||
<i class="fa fa-plus me-1"></i> 生成 CDK
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- Filters -->
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-md-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fa fa-search"></i></span>
|
||
<input type="text" class="form-control" v-model="filters.search" @input="debounceSearch" placeholder="搜索 CDK/原因/创建人...">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<select class="form-select" v-model="filters.type" @change="fetchData(1)">
|
||
<option value="">所有类型</option>
|
||
<option value="VIP">VIP</option>
|
||
<option value="SVIP">SVIP</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<select class="form-select" v-model="filters.status" @change="fetchData(1)">
|
||
<option value="">所有状态</option>
|
||
<option value="Unused">未使用</option>
|
||
<option value="Used">已使用</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Table -->
|
||
<div class="table-responsive">
|
||
<table class="table table-hover align-middle">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 40px;">
|
||
<input type="checkbox" class="form-check-input" :checked="isAllSelected" @change="toggleAll" :disabled="list.length === 0">
|
||
</th>
|
||
<th>CDK</th>
|
||
<th>类型</th>
|
||
<th>时长 (天)</th>
|
||
<th>状态</th>
|
||
<th>生成原因</th>
|
||
<th>创建人</th>
|
||
<th>创建时间</th>
|
||
<th>兑换人</th>
|
||
<th>兑换时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in list" :key="item.code" :class="{'table-active': selected.includes(item.code)}">
|
||
<td>
|
||
<input type="checkbox" class="form-check-input" :value="item.code" v-model="selected">
|
||
</td>
|
||
<td class="font-monospace user-select-all">{{ item.code }}</td>
|
||
<td>
|
||
<span class="badge" :class="item.type === 'SVIP' ? 'bg-warning text-dark' : 'bg-info'">{{ item.type }}</span>
|
||
</td>
|
||
<td>{{ item.durationDays }}</td>
|
||
<td>
|
||
<span :class="item.status === 'Unused' ? 'status-unused' : 'status-used'">
|
||
{{ item.status === 'Unused' ? '未使用' : '已使用' }}
|
||
</span>
|
||
</td>
|
||
<td>{{ item.reason }}</td>
|
||
<td>{{ item.creator }}</td>
|
||
<td>{{ formatDate(item.createdAt) }}</td>
|
||
<td>{{ item.redeemedBy || '-' }}</td>
|
||
<td>{{ formatDate(item.redeemedAt) }}</td>
|
||
</tr>
|
||
<tr v-if="list.length === 0">
|
||
<td colspan="10" class="text-center py-4 text-muted">暂无数据</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Pagination -->
|
||
<nav v-if="totalPages > 1" class="mt-3">
|
||
<ul class="pagination justify-content-center">
|
||
<li class="page-item" :class="{ disabled: page === 1 }">
|
||
<a class="page-link" href="#" @click.prevent="fetchData(page - 1)">上一页</a>
|
||
</li>
|
||
<li class="page-item disabled"><span class="page-link">{{ page }} / {{ totalPages }}</span></li>
|
||
<li class="page-item" :class="{ disabled: page === totalPages }">
|
||
<a class="page-link" href="#" @click.prevent="fetchData(page + 1)">下一页</a>
|
||
</li>
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Generate Modal -->
|
||
<div class="modal fade" id="generateModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">生成 CDK</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form @submit.prevent="generate">
|
||
<div class="mb-3">
|
||
<label class="form-label">类型</label>
|
||
<select class="form-select" v-model="form.type" required>
|
||
<option value="VIP">VIP</option>
|
||
<option value="SVIP">SVIP</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">时长 (天)</label>
|
||
<input type="number" class="form-control" v-model.number="form.durationDays" min="1" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">数量 (1-100)</label>
|
||
<input type="number" class="form-control" v-model.number="form.count" min="1" max="100" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">生成原因</label>
|
||
<input type="text" class="form-control" v-model="form.reason" placeholder="例如:活动奖励" required>
|
||
</div>
|
||
<div class="d-grid">
|
||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||
<span v-if="loading" class="spinner-border spinner-border-sm me-1"></span>
|
||
确认生成
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Result Modal -->
|
||
<div class="modal fade" id="resultModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">生成成功</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="text-success">已成功生成 {{ resultKeys.length }} 个 CDK:</p>
|
||
<textarea class="form-control" rows="10" readonly>{{ resultKeys.join('\n') }}</textarea>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-primary" @click="copyGenerated">
|
||
<i class="fa fa-copy me-1"></i> 一键复制
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Copy Modal -->
|
||
<div class="modal fade" id="copyModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">复制成功</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="text-success">已将以下内容复制到剪贴板:</p>
|
||
<textarea class="form-control" rows="10" readonly>{{ copyContent }}</textarea>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, reactive, onMounted, computed } = Vue;
|
||
|
||
// Permission Check
|
||
window.checkPermission = function(key) {
|
||
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
|
||
if (isSuper) return true;
|
||
try {
|
||
const perms = JSON.parse(localStorage.getItem('permissions') || '[]');
|
||
return perms.includes('all') || perms.includes(key);
|
||
} catch { return false; }
|
||
};
|
||
|
||
(function() {
|
||
const required = 'cdks.view';
|
||
if (!checkPermission(required)) {
|
||
document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;color:red;font-size:1.5rem;">Access Denied: Permission "cdks.view" required.</div>';
|
||
throw new Error("Access Denied");
|
||
}
|
||
})();
|
||
|
||
createApp({
|
||
setup() {
|
||
const list = ref([]);
|
||
const page = ref(1);
|
||
const pageSize = 20;
|
||
const total = ref(0);
|
||
const totalPages = ref(1);
|
||
const loading = ref(false);
|
||
const resultKeys = ref([]);
|
||
const selected = ref([]);
|
||
|
||
const filters = reactive({
|
||
type: '',
|
||
status: '',
|
||
search: ''
|
||
});
|
||
|
||
const form = reactive({
|
||
type: 'VIP',
|
||
durationDays: 30,
|
||
count: 1,
|
||
reason: ''
|
||
});
|
||
|
||
let generateModal = null;
|
||
let resultModal = null;
|
||
let searchTimeout = null;
|
||
|
||
const isAllSelected = computed(() => {
|
||
return list.value.length > 0 && list.value.every(item => selected.value.includes(item.code));
|
||
});
|
||
|
||
onMounted(() => {
|
||
generateModal = new bootstrap.Modal(document.getElementById('generateModal'));
|
||
resultModal = new bootstrap.Modal(document.getElementById('resultModal'));
|
||
fetchData();
|
||
});
|
||
|
||
const fetchData = async (p = 1) => {
|
||
page.value = p;
|
||
// Clear selection on page change/refresh to avoid confusion
|
||
selected.value = [];
|
||
|
||
try {
|
||
let url = `/api/cdk/list?page=${page.value}&pageSize=${pageSize}`;
|
||
if (filters.type) url += `&type=${filters.type}`;
|
||
if (filters.status) url += `&status=${filters.status}`;
|
||
if (filters.search) url += `&search=${encodeURIComponent(filters.search)}`;
|
||
|
||
const res = await fetch(url);
|
||
if (res.status === 401) {
|
||
alert('无权访问');
|
||
window.location.href = '/login.html';
|
||
return;
|
||
}
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
list.value = data.list;
|
||
total.value = data.total;
|
||
totalPages.value = Math.ceil(data.total / pageSize);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
};
|
||
|
||
const debounceSearch = () => {
|
||
if (searchTimeout) clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
fetchData(1);
|
||
}, 500);
|
||
};
|
||
|
||
const toggleAll = () => {
|
||
if (isAllSelected.value) {
|
||
selected.value = [];
|
||
} else {
|
||
selected.value = list.value.map(item => item.code);
|
||
}
|
||
};
|
||
|
||
const copySelected = async () => {
|
||
if (selected.value.length === 0) return;
|
||
|
||
const codes = selected.value.join('\n');
|
||
const url = window.location.origin + '/cdk-redeem.html';
|
||
|
||
const text = `[CDK列表]
|
||
${codes}
|
||
|
||
激活地址:${url}
|
||
注意事项:
|
||
1. 每个批次的激活码同一个账号仅限激活一次。
|
||
2. 可在游戏内聊天直接发送 CDK 进行激活。`;
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
// Show modal with the copied content for verification
|
||
resultKeys.value = text.split('\n'); // Hack to reuse result modal logic partially or just alert
|
||
|
||
// Let's create a custom alert or reuse result modal with a twist
|
||
// But simply alerting is fine as requested "并弹窗显示"
|
||
// I'll show it in a modal instead of alert for better UX
|
||
showCopyModal(text);
|
||
} catch (err) {
|
||
alert('复制失败,请手动复制');
|
||
}
|
||
};
|
||
|
||
const deleteSelected = async () => {
|
||
if (selected.value.length === 0) return;
|
||
if (!confirm(`确定要删除选中的 ${selected.value.length} 个 CDK 吗?此操作不可恢复。`)) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/cdk/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ codes: selected.value })
|
||
});
|
||
|
||
if (res.ok) {
|
||
alert('删除成功');
|
||
selected.value = []; // Clear selection
|
||
fetchData(page.value); // Refresh current page
|
||
} else {
|
||
const err = await res.json();
|
||
alert('删除失败: ' + (err.error || 'Unknown error'));
|
||
}
|
||
} catch (e) {
|
||
alert('请求错误: ' + e.message);
|
||
}
|
||
};
|
||
|
||
// Add a new reactive variable for copy modal content
|
||
const copyContent = ref('');
|
||
let copyModalInstance = null;
|
||
|
||
onMounted(() => {
|
||
generateModal = new bootstrap.Modal(document.getElementById('generateModal'));
|
||
resultModal = new bootstrap.Modal(document.getElementById('resultModal'));
|
||
copyModalInstance = new bootstrap.Modal(document.getElementById('copyModal'));
|
||
fetchData();
|
||
});
|
||
|
||
const showCopyModal = (text) => {
|
||
copyContent.value = text;
|
||
copyModalInstance.show();
|
||
};
|
||
|
||
const copyGenerated = async () => {
|
||
if (resultKeys.value.length === 0) return;
|
||
|
||
const codes = resultKeys.value.join('\n');
|
||
const url = window.location.origin + '/cdk-redeem.html';
|
||
|
||
const text = `[CDK列表]
|
||
${codes}
|
||
|
||
激活地址:${url}
|
||
注意事项:
|
||
1. 每个批次的激活码同一个账号仅限激活一次。
|
||
2. 可在游戏内聊天直接发送 CDK 进行激活。`;
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
alert('已复制所有新生成的 CDK 及相关信息!');
|
||
} catch (err) {
|
||
alert('复制失败,请手动复制');
|
||
}
|
||
};
|
||
|
||
const showGenerateModal = () => {
|
||
form.type = 'VIP';
|
||
form.durationDays = 30;
|
||
form.count = 1;
|
||
form.reason = '';
|
||
generateModal.show();
|
||
};
|
||
|
||
const generate = async () => {
|
||
loading.value = true;
|
||
try {
|
||
const res = await fetch('/api/cdk/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(form)
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
generateModal.hide();
|
||
resultKeys.value = data.keys;
|
||
resultModal.show();
|
||
fetchData(1);
|
||
} else {
|
||
alert(data.error || '生成失败');
|
||
}
|
||
} catch (error) {
|
||
alert('请求失败: ' + error.message);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return '';
|
||
return new Date(dateStr).toLocaleString();
|
||
};
|
||
|
||
return {
|
||
list, page, totalPages, filters, form, loading, resultKeys, selected, isAllSelected, copyContent,
|
||
fetchData, showGenerateModal, generate, formatDate, debounceSearch, toggleAll, copySelected, copyGenerated, deleteSelected,
|
||
checkPermission // Expose to template
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|