mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-05 21:15:52 +08:00
465 lines
26 KiB
HTML
465 lines
26 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>插件管理</title>
|
||
<link rel="stylesheet" href="/lib/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
|
||
<style>
|
||
body { padding: 20px; background-color: #f8f9fa; font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||
.card { margin-bottom: 20px; border: none; box-shadow: 0 4px 6px rgba(0,0,0,0.05); border-radius: 12px; transition: transform 0.2s; }
|
||
.card:hover { transform: translateY(-2px); }
|
||
.card-header { background-color: #fff; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-radius: 12px 12px 0 0 !important; }
|
||
.status-badge { padding: 6px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 500; }
|
||
.status-enabled { background-color: #e6f4ea; color: #1e7e34; }
|
||
.status-disabled { background-color: #fce8e6; color: #c5221f; }
|
||
.card-body { padding: 20px; }
|
||
.card-title { font-weight: 600; margin-bottom: 0; }
|
||
.btn-primary { background-color: #4361ee; border-color: #4361ee; }
|
||
.btn-primary:hover { background-color: #3f37c9; border-color: #3f37c9; }
|
||
.category-header { margin-top: 30px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #e9ecef; color: #495057; font-weight: 700; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app" class="container-fluid">
|
||
<h3 class="mb-4"><i class="fas fa-plug me-2"></i>插件管理</h3>
|
||
<div class="d-flex gap-2 mb-3">
|
||
<button class="btn btn-success" :disabled="loading" @click="enableAllPlugins">
|
||
<i class="fas fa-toggle-on me-2"></i>一键启用所有插件
|
||
</button>
|
||
<button class="btn btn-outline-danger" :disabled="loading" @click="disableAllPlugins">
|
||
<i class="fas fa-toggle-off me-2"></i>一键关闭所有插件
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="loading" class="text-center py-5">
|
||
<div class="spinner-border text-primary" role="status">
|
||
<span class="visually-hidden">Loading...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else>
|
||
<div v-for="(group, category) in categorizedPlugins" :key="category">
|
||
<h4 class="category-header">{{ category }}</h4>
|
||
<div class="row">
|
||
<div class="col-md-6 col-lg-2" v-for="plugin in group" :key="plugin.name">
|
||
<div class="card h-100">
|
||
<div class="card-header">
|
||
<h5 class="card-title">{{ plugin.name }}</h5>
|
||
<span class="status-badge" :class="plugin.isEnabled ? 'status-enabled' : 'status-disabled'">
|
||
{{ plugin.isEnabled ? '已启用' : '已禁用' }}
|
||
</span>
|
||
</div>
|
||
<div class="card-body d-flex flex-column">
|
||
<p class="card-text text-muted mb-4">{{ plugin.description }}</p>
|
||
<div class="mt-auto">
|
||
<button class="btn btn-primary w-100" @click="editConfig(plugin)">
|
||
<i class="fas fa-cog me-2"></i>配置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Config Modal -->
|
||
<div class="modal fade" id="configModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">配置插件: {{ currentPlugin?.name }}</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="alert alert-info py-2">
|
||
<i class="fas fa-info-circle me-2"></i>修改配置后将自动保存并立即生效。
|
||
</div>
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<ul class="nav nav-tabs border-0">
|
||
<li class="nav-item">
|
||
<a class="nav-link" :class="{ active: editMode === 'visual' }" href="#" @click.prevent="switchMode('visual')">
|
||
<i class="fas fa-eye me-2"></i>可视化视图
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" :class="{ active: editMode === 'json' }" href="#" @click.prevent="switchMode('json')">
|
||
<i class="fas fa-code me-2"></i>JSON 编辑
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Visual Editor -->
|
||
<div v-if="editMode === 'visual'" class="border rounded p-3 bg-light" style="max-height: 60vh; overflow-y: auto;">
|
||
<div v-for="(value, key) in configObject" :key="key" class="mb-3">
|
||
<label class="form-label fw-bold">
|
||
{{ currentPlugin.descriptions && currentPlugin.descriptions[key] ? currentPlugin.descriptions[key] : formatLabel(key) }}
|
||
<small class="text-muted fw-normal">({{ key }})</small>
|
||
</label>
|
||
|
||
<!-- Boolean -->
|
||
<div v-if="typeof value === 'boolean'" class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" v-model="configObject[key]">
|
||
<label class="form-check-label">{{ configObject[key] ? '已启用' : '已禁用' }}</label>
|
||
</div>
|
||
|
||
<!-- Number -->
|
||
<input v-else-if="typeof value === 'number'" type="number" class="form-control" v-model.number="configObject[key]">
|
||
|
||
<!-- Array -->
|
||
<div v-else-if="Array.isArray(value)">
|
||
<!-- Object Array Mode -->
|
||
<div v-if="isObjectList(key, value, currentPlugin.name)">
|
||
<div v-if="value.length > 0">
|
||
<div class="card mb-2" v-for="(item, index) in value" :key="index">
|
||
<div v-if="typeof item !== 'object' || item === null" class="card-body p-2 bg-light text-danger">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<span><i class="fas fa-exclamation-triangle me-2"></i>数据格式错误 ({{ item }})</span>
|
||
<button class="btn btn-sm btn-outline-danger" @click="removeItem(key, index)">删除</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="card-body p-2 bg-white">
|
||
<div class="d-flex justify-content-between mb-2 border-bottom pb-1">
|
||
<span class="fw-bold small text-secondary">
|
||
{{ currentPlugin.descriptions && currentPlugin.descriptions[key + '$Item'] ? currentPlugin.descriptions[key + '$Item'] : '项目' }} #{{ index + 1 }}
|
||
</span>
|
||
<button class="btn btn-sm btn-outline-danger py-0" @click="removeItem(key, index)">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
<div class="row g-2">
|
||
<div v-for="(subVal, subKey) in item" :key="subKey" class="col-12">
|
||
<label class="form-label small text-muted mb-0">
|
||
{{ currentPlugin.descriptions && currentPlugin.descriptions[subKey] ? currentPlugin.descriptions[subKey] : formatLabel(subKey) }}
|
||
<span class="fw-normal" style="font-size: 0.8em">({{ subKey }})</span>
|
||
</label>
|
||
<input v-if="typeof subVal === 'number'" type="number" class="form-control form-control-sm" v-model.number="item[subKey]" :min="subKey === 'IntervalSeconds' ? 3 : null">
|
||
<div v-else-if="typeof subVal === 'boolean'" class="form-check form-switch ms-1">
|
||
<input class="form-check-input" type="checkbox" v-model="item[subKey]">
|
||
</div>
|
||
<input v-else-if="isDateField(subKey)" type="date" class="form-control form-control-sm" :value="formatDate(item[subKey])" @input="item[subKey] = $event.target.value">
|
||
<input v-else type="text" class="form-control form-control-sm" v-model="item[subKey]">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="alert alert-light text-center small text-muted">
|
||
暂无配置项
|
||
</div>
|
||
<button class="btn btn-sm btn-outline-primary" @click="addItemObject(key, getTemplate(key, currentPlugin.name))">
|
||
<i class="fas fa-plus me-1"></i>添加项
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Primitive Array Mode -->
|
||
<div v-else>
|
||
<div class="input-group mb-2" v-for="(item, index) in value" :key="index">
|
||
<input type="text" class="form-control" v-model="configObject[key][index]">
|
||
<button class="btn btn-outline-danger" @click="removeItem(key, index)">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
<button class="btn btn-sm btn-outline-primary" @click="addItem(key)">
|
||
<i class="fas fa-plus me-1"></i>添加项
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- String/Other -->
|
||
<input v-else type="text" class="form-control" v-model="configObject[key]">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON Editor -->
|
||
<div v-else class="mb-3">
|
||
<textarea class="form-control" v-model="configJson" rows="15" style="font-family: 'Consolas', monospace; font-size: 0.9rem; background-color: #f8f9fa;"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer justify-content-between">
|
||
<button type="button" class="btn btn-outline-danger" @click="resetConfig">
|
||
<i class="fas fa-undo me-2"></i>恢复默认
|
||
</button>
|
||
<div>
|
||
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" @click="saveConfig">
|
||
<i class="fas fa-save me-2"></i>保存配置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/lib/js/bootstrap.bundle.min.js"></script>
|
||
<script src="/lib/js/vue.global.prod.js"></script>
|
||
<script>
|
||
const { createApp, ref, onMounted, computed } = Vue;
|
||
createApp({
|
||
setup() {
|
||
const plugins = ref([]);
|
||
const loading = ref(true);
|
||
const currentPlugin = ref(null);
|
||
const configJson = ref('');
|
||
const configObject = ref({});
|
||
const editMode = ref('visual'); // 'visual' or 'json'
|
||
let modal = null;
|
||
|
||
const loadPlugins = async () => {
|
||
try {
|
||
const res = await fetch('/api/plugins');
|
||
if (res.ok) {
|
||
plugins.value = await res.json();
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const categorizedPlugins = computed(() => {
|
||
const groups = {};
|
||
// Sort categories if needed, or just grouping
|
||
plugins.value.forEach(p => {
|
||
const cat = p.categoryName || '其他';
|
||
if (!groups[cat]) groups[cat] = [];
|
||
groups[cat].push(p);
|
||
});
|
||
return groups;
|
||
});
|
||
|
||
const editConfig = (plugin) => {
|
||
currentPlugin.value = plugin;
|
||
// Deep copy to avoid modifying original until save
|
||
configObject.value = JSON.parse(JSON.stringify(plugin.config));
|
||
|
||
// Fix: Ensure specific object lists are clean from primitive values (e.g. if user added empty strings by mistake)
|
||
if (plugin.name === '个人BUFF系统') {
|
||
if (Array.isArray(configObject.value.VipList)) {
|
||
configObject.value.VipList = configObject.value.VipList.filter(item => typeof item === 'object' && item !== null);
|
||
} else if (Array.isArray(configObject.value.vipList)) {
|
||
configObject.value.vipList = configObject.value.vipList.filter(item => typeof item === 'object' && item !== null);
|
||
}
|
||
}
|
||
|
||
configJson.value = JSON.stringify(plugin.config, null, 4);
|
||
editMode.value = 'visual'; // Default to visual
|
||
if (!modal) modal = new bootstrap.Modal(document.getElementById('configModal'));
|
||
modal.show();
|
||
};
|
||
|
||
const switchMode = (mode) => {
|
||
if (mode === 'visual') {
|
||
try {
|
||
configObject.value = JSON.parse(configJson.value);
|
||
editMode.value = 'visual';
|
||
} catch (e) {
|
||
alert('JSON 格式错误,无法切换到可视化模式: ' + e.message);
|
||
}
|
||
} else {
|
||
configJson.value = JSON.stringify(configObject.value, null, 4);
|
||
editMode.value = 'json';
|
||
}
|
||
};
|
||
|
||
const formatLabel = (key) => {
|
||
// Convert CamelCase to Spaced Words
|
||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||
};
|
||
|
||
const addItem = (key) => {
|
||
if (Array.isArray(configObject.value[key])) {
|
||
configObject.value[key].push('');
|
||
}
|
||
};
|
||
|
||
const addItemObject = (key, templateItem) => {
|
||
if (Array.isArray(configObject.value[key])) {
|
||
// Clone structure of the template item with default empty values
|
||
const newItem = JSON.parse(JSON.stringify(templateItem));
|
||
// Optional: clear values
|
||
for (let k in newItem) {
|
||
if (typeof newItem[k] === 'string') newItem[k] = '';
|
||
if (typeof newItem[k] === 'number') newItem[k] = 0;
|
||
if (typeof newItem[k] === 'boolean') newItem[k] = false;
|
||
}
|
||
// Special default for IntervalSeconds if detected
|
||
if ('IntervalSeconds' in newItem) newItem.IntervalSeconds = 60;
|
||
|
||
configObject.value[key].push(newItem);
|
||
}
|
||
};
|
||
|
||
const removeItem = (key, index) => {
|
||
if (Array.isArray(configObject.value[key])) {
|
||
configObject.value[key].splice(index, 1);
|
||
}
|
||
};
|
||
|
||
const isObjectList = (key, value, pluginName) => {
|
||
if (pluginName === '个人BUFF系统' && (key === 'VipList' || key === 'vipList')) return true;
|
||
if (pluginName === '自动广播' && (key === 'Messages' || key === 'messages')) return true;
|
||
if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null) return true;
|
||
return false;
|
||
};
|
||
|
||
const isDateField = (key) => {
|
||
const k = key.toLowerCase();
|
||
return k === 'expiretime' || k.endsWith('date') || k.endsWith('time');
|
||
};
|
||
|
||
const formatDate = (value) => {
|
||
if (!value) return '';
|
||
if (typeof value === 'string' && value.includes('T')) {
|
||
return value.split('T')[0];
|
||
}
|
||
return value;
|
||
};
|
||
|
||
const getTemplate = (key, pluginName) => {
|
||
if (pluginName === '个人BUFF系统' && (key === 'VipList' || key === 'vipList')) {
|
||
return { SteamId: '', Name: '', Reason: '', ExpireTime: '2099-12-31T23:59:59' };
|
||
}
|
||
if (pluginName === '自动广播' && (key === 'Messages' || key === 'messages')) {
|
||
return { Message: '', IntervalSeconds: 300 };
|
||
}
|
||
const val = configObject.value[key];
|
||
if (val && val.length > 0 && typeof val[0] === 'object') {
|
||
return val[0];
|
||
}
|
||
return {};
|
||
};
|
||
|
||
const saveConfig = async () => {
|
||
try {
|
||
// Ensure config is synced from current mode
|
||
let configToSave;
|
||
if (editMode.value === 'visual') {
|
||
configToSave = configObject.value;
|
||
// Update JSON string for consistency if we reopen
|
||
configJson.value = JSON.stringify(configObject.value, null, 4);
|
||
} else {
|
||
configToSave = JSON.parse(configJson.value);
|
||
// Update Object for consistency
|
||
configObject.value = configToSave;
|
||
}
|
||
|
||
const res = await fetch(`/api/plugins/${currentPlugin.value.name}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(configToSave)
|
||
});
|
||
|
||
if (res.ok) {
|
||
alert('配置已保存');
|
||
modal.hide();
|
||
loadPlugins();
|
||
} else {
|
||
alert('保存失败');
|
||
}
|
||
} catch (e) {
|
||
alert('保存失败: ' + e.message);
|
||
}
|
||
};
|
||
|
||
const resetConfig = async () => {
|
||
if (!confirm('确定要恢复默认配置吗?当前所有修改将丢失。')) return;
|
||
|
||
try {
|
||
const res = await fetch(`/api/plugins/${currentPlugin.value.name}/reset`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (res.ok) {
|
||
alert('配置已重置');
|
||
modal.hide();
|
||
loadPlugins();
|
||
} else {
|
||
alert('重置失败');
|
||
}
|
||
} catch (e) {
|
||
alert('重置失败: ' + e.message);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 一键设置所有插件启用状态
|
||
* 参数:enable(boolean) - 是否启用
|
||
* 返回值:Promise<void>
|
||
* 异常:网络或解析异常将通过 alert 显示,同时单个插件的更新异常仅记录到控制台,避免影响整体操作
|
||
*/
|
||
const setAllPluginsEnabled = async (enable) => {
|
||
try {
|
||
const res = await fetch('/api/plugins');
|
||
if (!res.ok) {
|
||
alert('获取插件列表失败');
|
||
return;
|
||
}
|
||
const list = await res.json();
|
||
for (const p of list) {
|
||
// 深拷贝配置,避免直接修改原对象
|
||
const cfg = JSON.parse(JSON.stringify(p.config || {}));
|
||
// 兼容大小写及缺失字段的情况
|
||
if (Object.prototype.hasOwnProperty.call(cfg, 'Enabled')) {
|
||
cfg.Enabled = enable;
|
||
} else if (Object.prototype.hasOwnProperty.call(cfg, 'enabled')) {
|
||
cfg.enabled = enable;
|
||
} else {
|
||
cfg.Enabled = enable;
|
||
}
|
||
// 独立 try-catch,避免单个失败影响其他插件
|
||
try {
|
||
const r = await fetch(`/api/plugins/${p.name}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(cfg)
|
||
});
|
||
if (!r.ok) {
|
||
console.warn('更新失败:', p.name);
|
||
}
|
||
} catch (innerErr) {
|
||
console.error('更新异常:', p.name, innerErr);
|
||
}
|
||
}
|
||
alert(enable ? '已启用所有插件' : '已关闭所有插件');
|
||
loadPlugins();
|
||
} catch (e) {
|
||
alert('操作失败: ' + e.message);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 一键启用所有插件
|
||
* 返回值:Promise<void>
|
||
* 异常:参见 setAllPluginsEnabled
|
||
*/
|
||
const enableAllPlugins = async () => setAllPluginsEnabled(true);
|
||
|
||
/**
|
||
* 一键关闭所有插件
|
||
* 返回值:Promise<void>
|
||
* 异常:参见 setAllPluginsEnabled
|
||
*/
|
||
const disableAllPlugins = async () => setAllPluginsEnabled(false);
|
||
|
||
onMounted(() => {
|
||
loadPlugins();
|
||
});
|
||
|
||
return {
|
||
plugins, loading, currentPlugin, configJson, configObject, editMode, categorizedPlugins,
|
||
editConfig, saveConfig, resetConfig, switchMode, formatLabel, addItem, addItemObject, removeItem,
|
||
isObjectList, getTemplate, isDateField, formatDate,
|
||
enableAllPlugins, disableAllPlugins
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|