Files
toolbox/resources/js/components/admin/IpUserMappings.vue

343 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-medium text-gray-900">IP 用户映射</h3>
<p class="text-gray-500 mt-1">维护 IP 与用户标识的对应关系</p>
</div>
<button
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
:disabled="loading"
@click="loadMappings"
>
{{ loading ? '加载中...' : '刷新' }}
</button>
</div>
<div class="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<input
v-model="newMapping.ip_address"
type="text"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="IP 地址"
/>
<input
v-model="newMapping.user_name"
type="text"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="用户标识"
/>
<input
v-model="newMapping.remark"
type="text"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="备注(可选)"
/>
<button
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
:disabled="saving"
@click="createMapping"
>
{{ saving ? '保存中...' : '新增映射' }}
</button>
</div>
<div
v-if="unmappedIps.length"
class="mt-6 border border-amber-200 bg-amber-50 rounded-lg p-4"
>
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-amber-700">待匹配 IP来自日志</div>
<div class="text-xs text-amber-600"> {{ unmappedIps.length }} </div>
</div>
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 max-h-64 overflow-auto">
<div
v-for="item in unmappedIps"
:key="item.ip_address"
class="flex items-center justify-between gap-3 bg-white border border-amber-100 rounded px-3 py-2 text-sm"
>
<div class="flex flex-col">
<span class="font-mono text-gray-800">{{ item.ip_address }}</span>
<span class="text-xs text-gray-500">
{{ item.logs_count }} 次日志 · 最近 {{ formatTime(item.last_seen_at) }}
</span>
</div>
<button
class="px-2 py-1 text-xs text-amber-700 bg-amber-100 rounded hover:bg-amber-200"
@click="fillIp(item.ip_address)"
>
填入
</button>
</div>
</div>
</div>
<div v-if="message" class="mt-4 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
{{ message }}
</div>
<div v-if="error" class="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{{ error }}
</div>
<div v-if="loading" class="mt-4 text-sm text-gray-400">加载中...</div>
<div v-else class="mt-4 overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th class="text-left px-4 py-3 font-medium">IP 地址</th>
<th class="text-left px-4 py-3 font-medium">用户标识</th>
<th class="text-left px-4 py-3 font-medium">备注</th>
<th class="text-left px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="mapping in mappings" :key="mapping.id" class="text-gray-700">
<td class="px-4 py-3 font-mono">
<input
v-model="mapping.ip_address"
type="text"
class="w-full px-2 py-1 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</td>
<td class="px-4 py-3">
<input
v-model="mapping.user_name"
type="text"
class="w-full px-2 py-1 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</td>
<td class="px-4 py-3">
<input
v-model="mapping.remark"
type="text"
class="w-full px-2 py-1 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2">
<button
class="px-3 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
:disabled="mapping._saving"
@click="updateMapping(mapping)"
>
{{ mapping._saving ? '保存中...' : '保存' }}
</button>
<button
class="px-3 py-1 bg-red-50 text-red-700 rounded-md hover:bg-red-100 disabled:opacity-50"
:disabled="mapping._deleting"
@click="deleteMapping(mapping)"
>
{{ mapping._deleting ? '删除中...' : '删除' }}
</button>
</div>
</td>
</tr>
<tr v-if="mappings.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-gray-400">暂无映射</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
export default {
name: 'IpUserMappings',
data() {
return {
mappings: [],
unmappedIps: [],
loading: false,
saving: false,
error: '',
message: '',
newMapping: {
ip_address: '',
user_name: '',
remark: ''
}
};
},
mounted() {
this.loadMappings();
},
methods: {
formatTime(value) {
if (!value) {
return '-';
}
return value.replace('T', ' ').replace('Z', '');
},
async parseJsonResponse(response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
throw new Error(text || '响应不是JSON');
}
return response.json();
},
getErrorMessage(data, fallback) {
if (!data) {
return fallback;
}
if (data.message) {
return data.message;
}
if (data.errors && typeof data.errors === 'object') {
const firstKey = Object.keys(data.errors)[0];
const firstError = firstKey ? data.errors[firstKey]?.[0] : '';
if (firstError) {
return firstError;
}
}
return fallback;
},
fillIp(ip) {
this.newMapping.ip_address = ip;
},
async loadMappings() {
this.loading = true;
this.error = '';
this.message = '';
try {
const response = await fetch('/api/admin/ip-user-mappings', {
headers: {
Accept: 'application/json'
}
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.error = this.getErrorMessage(data, '加载失败');
return;
}
if (!data.success) {
this.error = data.message || '加载失败';
return;
}
this.mappings = (data.data.mappings || []).map((item) => ({
...item,
_saving: false,
_deleting: false
}));
this.unmappedIps = data.data.unmapped_ips || [];
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async createMapping() {
this.saving = true;
this.error = '';
this.message = '';
try {
const response = await fetch('/api/admin/ip-user-mappings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(this.newMapping)
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.error = this.getErrorMessage(data, '创建失败');
return;
}
if (!data.success) {
this.error = data.message || '创建失败';
return;
}
this.message = '已新增映射';
this.newMapping = { ip_address: '', user_name: '', remark: '' };
await this.loadMappings();
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
async updateMapping(mapping) {
mapping._saving = true;
this.error = '';
this.message = '';
try {
const response = await fetch(`/api/admin/ip-user-mappings/${mapping.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
ip_address: mapping.ip_address,
user_name: mapping.user_name,
remark: mapping.remark
})
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.error = this.getErrorMessage(data, '更新失败');
return;
}
if (!data.success) {
this.error = data.message || '更新失败';
return;
}
this.message = '已更新映射';
} catch (error) {
this.error = error.message;
} finally {
mapping._saving = false;
}
},
async deleteMapping(mapping) {
mapping._deleting = true;
this.error = '';
this.message = '';
try {
const response = await fetch(`/api/admin/ip-user-mappings/${mapping.id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json'
}
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.error = this.getErrorMessage(data, '删除失败');
return;
}
if (!data.success) {
this.error = data.message || '删除失败';
return;
}
this.message = '已删除映射';
await this.loadMappings();
} catch (error) {
this.error = error.message;
} finally {
mapping._deleting = false;
}
}
}
};
</script>