mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
Closes the index page's i18n coverage. Combined with the page-chrome
commit, every label users see on the dashboard is now sourced from
the TOML translation files.
Per file:
- IndexPage.vue: loading-spinner tip (initial + dynamic).
- BackupModal.vue: modal title, both list-item titles + descriptions
("Back up" / "Restore"), in-flight busy tips ("Importing database…"
/ "Restarting panel…").
- PanelUpdateModal.vue: modal title, update-available alert,
current/latest version row labels, "Up to date" tag + label,
primary action button. Modal.confirm now uses the translated
panelUpdateDialog / panelUpdateDialogDesc with #version#
substitution; success toast uses panelUpdateStartedPopover.
- LogModal.vue: title slot ("Logs"). The Debug/Info/Notice/Warning/
Error log-level options stay literal — they're xray's wire values,
not user-facing labels (matches the existing settings-page choice).
- XrayLogModal.vue: title + Filter label. Direct/Blocked/Proxy stay
literal for the same reason.
- VersionModal.vue: modal title + xray-switch alert + per-file
tooltip + "Update all" button + custom-geo collapse header. The
Modal.confirm flows for switchXrayVersion + updateGeofile use
translated dialog/desc with #version# / #filename# substitution.
- CpuHistoryModal.vue: title slot.
- CustomGeoSection.vue: routing-hint alert, Add / Update-all buttons,
every column title (computed for live locale), copy/edit/download/
delete tooltips, copy toast, delete-confirm modal, empty-state
text.
- CustomGeoFormModal.vue: add/edit titles, OK/cancel labels, Type/
Alias/URL field labels, alias placeholder, all three validation
toasts.
Total: ~50 strings localised across 8 index-page files. The Hello /
Welcome login headline cycle and a handful of literal xray wire
values (Direct/Blocked/Proxy/log levels) are intentionally kept
hardcoded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
177 lines
5.4 KiB
Vue
177 lines
5.4 KiB
Vue
<script setup>
|
|
import { computed, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
|
|
|
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
|
|
|
const { t } = useI18n();
|
|
|
|
const props = defineProps({
|
|
open: { type: Boolean, default: false },
|
|
});
|
|
|
|
const emit = defineEmits(['update:open']);
|
|
|
|
const rows = ref('20');
|
|
const filter = ref('');
|
|
const showDirect = ref(true);
|
|
const showBlocked = ref(true);
|
|
const showProxy = ref(true);
|
|
const loading = ref(false);
|
|
const logs = ref([]);
|
|
|
|
function escapeHtml(value) {
|
|
if (value == null) return '';
|
|
return String(value)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// Renders a `<table>` with one row per log entry. Event 1 = blocked
|
|
// (red); Event 2 = proxy (blue); Event 0 = direct.
|
|
function formatLogs(lines) {
|
|
let out = '<table class="xraylog-table"><tr>'
|
|
+ '<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>'
|
|
+ '</tr>';
|
|
|
|
// Reverse a copy — the legacy code mutated state with `.reverse()`.
|
|
[...lines].reverse().forEach((log) => {
|
|
let rowStyle = '';
|
|
if (log.Event === 1) rowStyle = ' style="color: #e04141;"';
|
|
else if (log.Event === 2) rowStyle = ' style="color: #3c89e8;"';
|
|
|
|
const emailCell = log.Email ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
|
|
|
|
out += `<tr${rowStyle}>`
|
|
+ `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>`
|
|
+ `<td>${escapeHtml(log.FromAddress)}</td>`
|
|
+ `<td>${escapeHtml(log.ToAddress)}</td>`
|
|
+ `<td>${escapeHtml(log.Inbound)}</td>`
|
|
+ `<td>${escapeHtml(log.Outbound)}</td>`
|
|
+ emailCell
|
|
+ '</tr>';
|
|
});
|
|
|
|
return out + '</table>';
|
|
}
|
|
|
|
const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
|
|
|
|
async function refresh() {
|
|
loading.value = true;
|
|
try {
|
|
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
|
|
filter: filter.value,
|
|
showDirect: showDirect.value,
|
|
showBlocked: showBlocked.value,
|
|
showProxy: showProxy.value,
|
|
});
|
|
if (msg?.success) logs.value = msg.obj || [];
|
|
await PromiseUtil.sleep(300);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
emit('update:open', false);
|
|
}
|
|
|
|
function download() {
|
|
if (!Array.isArray(logs.value) || logs.value.length === 0) {
|
|
FileManager.downloadTextFile('', 'x-ui.log');
|
|
return;
|
|
}
|
|
const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
|
const lines = logs.value.map((l) => {
|
|
try {
|
|
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
|
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
|
const eventText = eventMap[l.Event] || String(l.Event ?? '');
|
|
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
|
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
|
} catch (_e) {
|
|
return JSON.stringify(l);
|
|
}
|
|
}).join('\n');
|
|
FileManager.downloadTextFile(lines, 'x-ui.log');
|
|
}
|
|
|
|
watch(() => props.open, (next) => { if (next) refresh(); });
|
|
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
|
|
</script>
|
|
|
|
<template>
|
|
<a-modal :open="open" :closable="true" :footer="null" width="80vw" @cancel="close">
|
|
<template #title>
|
|
{{ t('pages.index.logs') }}
|
|
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
|
</template>
|
|
|
|
<a-form layout="inline">
|
|
<a-form-item>
|
|
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
|
<a-select-option value="10">10</a-select-option>
|
|
<a-select-option value="20">20</a-select-option>
|
|
<a-select-option value="50">50</a-select-option>
|
|
<a-select-option value="100">100</a-select-option>
|
|
<a-select-option value="500">500</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item :label="t('filter')">
|
|
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
|
|
</a-form-item>
|
|
<a-form-item>
|
|
<a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
|
|
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
|
|
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
|
|
</a-form-item>
|
|
<a-form-item style="margin-left: auto">
|
|
<a-button type="primary" @click="download">
|
|
<template #icon><DownloadOutlined /></template>
|
|
</a-button>
|
|
</a-form-item>
|
|
</a-form>
|
|
|
|
<div class="log-container" v-html="formattedLogs" />
|
|
</a-modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.reload-icon {
|
|
cursor: pointer;
|
|
vertical-align: middle;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
.log-container {
|
|
margin-top: 12px;
|
|
padding: 10px 12px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
max-height: 60vh;
|
|
overflow: auto;
|
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
|
border-radius: 6px;
|
|
background: rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
:global(body.dark) .log-container {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* Global so the v-html'd table picks up these styles. */
|
|
.xraylog-table {
|
|
border-collapse: collapse;
|
|
width: auto;
|
|
}
|
|
.xraylog-table td,
|
|
.xraylog-table th {
|
|
padding: 2px 15px;
|
|
}
|
|
</style>
|