mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - add live admin dashboard statistics (#27)
This commit is contained in:
@@ -152,24 +152,23 @@ describe('standalone admin panel contracts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).toContain("'--admin-accent': accentColor")
|
||||
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||
expect(source).toContain(
|
||||
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-color-value"')
|
||||
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||
expect(source).toContain('class="admin-panel-font-select"')
|
||||
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||
expect(source).not.toContain('type="color"')
|
||||
expect(source).not.toContain("'sky-phone-admin-accent'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).not.toContain("t('appearance.")
|
||||
expect(source).toContain('--admin-accent: #00b8e4')
|
||||
expect(source).toContain('class="admin-panel-statistics-layout"')
|
||||
expect(source).toContain('class="admin-panel-activity-grid"')
|
||||
expect(source).toContain('class="admin-panel-coverage-list"')
|
||||
expect(source).toContain('statisticsTimer = window.setInterval')
|
||||
expect(source).toContain('15_000')
|
||||
expect(server).toContain('AS `messages_today`')
|
||||
expect(server).toContain('AS `calls_today`')
|
||||
expect(server).toContain('AS `active_devices`')
|
||||
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChartNoAxesCombined,
|
||||
BadgeDollarSign,
|
||||
BriefcaseBusiness,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Clipboard,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
PhoneCall,
|
||||
PhoneForwarded,
|
||||
Save,
|
||||
@@ -81,17 +80,6 @@ type AdminTab =
|
||||
type ConfiguratorScope = 'config' | 'media'
|
||||
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
|
||||
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
|
||||
type AccentChannel = 'blue' | 'green' | 'red'
|
||||
type AdminFontFamily =
|
||||
| 'classic'
|
||||
| 'georgia'
|
||||
| 'inter'
|
||||
| 'mono'
|
||||
| 'system'
|
||||
| 'tahoma'
|
||||
| 'trebuchet'
|
||||
| 'verdana'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const admin = useAdminStore()
|
||||
const phone = usePhoneStore()
|
||||
@@ -109,61 +97,11 @@ const revealDialogImei = ref('')
|
||||
const deviceAction = ref<DeviceAction | null>(null)
|
||||
const deviceActionInput = ref('')
|
||||
const discardDialog = ref(false)
|
||||
const fontMenuOpen = ref(false)
|
||||
const pendingAction = ref<PendingAction | null>(null)
|
||||
const toast = ref('')
|
||||
const toastTone = ref<'error' | 'success'>('success')
|
||||
let toastTimer: number | undefined
|
||||
|
||||
const accentOptions = [
|
||||
{ color: '#74d66f', key: 'emerald' },
|
||||
{ color: '#4f9cff', key: 'blue' },
|
||||
{ color: '#a875ff', key: 'violet' },
|
||||
{ color: '#f0a24b', key: 'orange' },
|
||||
{ color: '#ef6969', key: 'red' },
|
||||
] as const
|
||||
const accentChannels = ['red', 'green', 'blue'] as const
|
||||
const fontFamilyOptions: Array<{
|
||||
key: AdminFontFamily
|
||||
value: string
|
||||
}> = [
|
||||
{ key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' },
|
||||
{
|
||||
key: 'system',
|
||||
value:
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
},
|
||||
{ key: 'classic', value: 'Arial, Helvetica, sans-serif' },
|
||||
{ key: 'verdana', value: 'Verdana, Geneva, sans-serif' },
|
||||
{ key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' },
|
||||
{
|
||||
key: 'trebuchet',
|
||||
value: '"Trebuchet MS", "Lucida Grande", sans-serif',
|
||||
},
|
||||
{ key: 'georgia', value: 'Georgia, "Times New Roman", serif' },
|
||||
{
|
||||
key: 'mono',
|
||||
value:
|
||||
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
]
|
||||
const accentColor = ref('#74d66f')
|
||||
const adminFontFamily = ref<AdminFontFamily>('inter')
|
||||
const adminFontSize = ref(100)
|
||||
const adminFontSizePreview = ref(100)
|
||||
const activeAdminFontFamily = computed(
|
||||
() =>
|
||||
fontFamilyOptions.find((option) => option.key === adminFontFamily.value)
|
||||
?.value ?? fontFamilyOptions[0].value,
|
||||
)
|
||||
const accentRgb = computed(() => {
|
||||
const color = accentColor.value.replace('#', '')
|
||||
return {
|
||||
red: Number.parseInt(color.slice(0, 2), 16),
|
||||
green: Number.parseInt(color.slice(2, 4), 16),
|
||||
blue: Number.parseInt(color.slice(4, 6), 16),
|
||||
}
|
||||
})
|
||||
let statisticsTimer: number | undefined
|
||||
|
||||
const filteredPlayers = computed(() => {
|
||||
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
@@ -595,7 +533,9 @@ function queueAction(action: PendingAction): void {
|
||||
|
||||
function selectTab(nextTab: AdminTab): void {
|
||||
tab.value = nextTab
|
||||
fontMenuOpen.value = false
|
||||
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
|
||||
void admin.load()
|
||||
}
|
||||
if (nextTab === 'configurator' && !admin.configurator) {
|
||||
void admin.loadConfigurator().then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
@@ -603,61 +543,13 @@ function selectTab(nextTab: AdminTab): void {
|
||||
}
|
||||
}
|
||||
|
||||
function selectAccent(color: string): void {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return
|
||||
accentColor.value = color.toLowerCase()
|
||||
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
|
||||
function formatStatistic(value: number): string {
|
||||
return value.toLocaleString(phone.lang)
|
||||
}
|
||||
|
||||
function updateAccent(event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value)
|
||||
else input.value = accentColor.value.toUpperCase()
|
||||
}
|
||||
|
||||
function updateAccentChannel(channel: AccentChannel, event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const value = Math.min(255, Math.max(0, Number(input.value) || 0))
|
||||
const channels = { ...accentRgb.value, [channel]: value }
|
||||
selectAccent(
|
||||
`#${channels.red.toString(16).padStart(2, '0')}${channels.green
|
||||
.toString(16)
|
||||
.padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`,
|
||||
)
|
||||
}
|
||||
|
||||
function accentChannelGradient(channel: AccentChannel): string {
|
||||
const { red, green, blue } = accentRgb.value
|
||||
if (channel === 'red') {
|
||||
return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))`
|
||||
}
|
||||
if (channel === 'green') {
|
||||
return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))`
|
||||
}
|
||||
return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))`
|
||||
}
|
||||
|
||||
function selectAdminFontFamily(family: AdminFontFamily): void {
|
||||
adminFontFamily.value = family
|
||||
fontMenuOpen.value = false
|
||||
window.localStorage.setItem('sky-phone-admin-font-family', family)
|
||||
}
|
||||
|
||||
function closeFontMenu(event: FocusEvent): void {
|
||||
const menu = event.currentTarget as HTMLElement
|
||||
if (!menu.contains(event.relatedTarget as Node | null)) {
|
||||
fontMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdminFontSize(event: Event): void {
|
||||
const size = Number((event.currentTarget as HTMLInputElement).value)
|
||||
adminFontSize.value = Math.min(115, Math.max(90, size))
|
||||
adminFontSizePreview.value = adminFontSize.value
|
||||
window.localStorage.setItem(
|
||||
'sky-phone-admin-font-size',
|
||||
String(adminFontSize.value),
|
||||
)
|
||||
function statisticPercentage(value: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.min(100, Math.round((value / total) * 100))
|
||||
}
|
||||
|
||||
async function runAction(action: PendingAction): Promise<void> {
|
||||
@@ -918,10 +810,6 @@ function auditDescription(entry: AdminAuditEntry): string {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
if (fontMenuOpen.value) {
|
||||
fontMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
if (revealDialogImei.value) {
|
||||
revealDialogImei.value = ''
|
||||
return
|
||||
@@ -941,46 +829,21 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
const storedAccent = window.localStorage.getItem('sky-phone-admin-accent')
|
||||
if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) {
|
||||
accentColor.value = storedAccent.toLowerCase()
|
||||
}
|
||||
const storedFontFamily = window.localStorage.getItem(
|
||||
'sky-phone-admin-font-family',
|
||||
)
|
||||
if (
|
||||
storedFontFamily &&
|
||||
fontFamilyOptions.some((option) => option.key === storedFontFamily)
|
||||
) {
|
||||
adminFontFamily.value = storedFontFamily as AdminFontFamily
|
||||
}
|
||||
const storedFontSize = Number(
|
||||
window.localStorage.getItem('sky-phone-admin-font-size'),
|
||||
)
|
||||
if (storedFontSize >= 90 && storedFontSize <= 115) {
|
||||
adminFontSize.value = storedFontSize
|
||||
adminFontSizePreview.value = storedFontSize
|
||||
}
|
||||
void refreshData()
|
||||
statisticsTimer = window.setInterval(() => {
|
||||
if (tab.value === 'overview' && !admin.loading) void admin.load()
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
if (statisticsTimer) window.clearInterval(statisticsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-panel-overlay"
|
||||
role="dialog"
|
||||
:aria-label="t('name')"
|
||||
:style="{
|
||||
'--admin-accent': accentColor,
|
||||
'--admin-font-family': activeAdminFontFamily,
|
||||
'--admin-font-scale': String(adminFontSize / 100),
|
||||
}"
|
||||
>
|
||||
<div class="admin-panel-overlay" role="dialog" :aria-label="t('name')">
|
||||
<div class="admin-panel-window">
|
||||
<header class="admin-panel-header">
|
||||
<div class="admin-panel-brand">
|
||||
@@ -1384,22 +1247,22 @@ onBeforeUnmount(() => {
|
||||
<article>
|
||||
<UsersRound :size="18" />
|
||||
<span>{{ t('overview.online') }}</span>
|
||||
<strong>{{ admin.stats.online }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.online) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<Smartphone :size="18" />
|
||||
<span>{{ t('overview.devices') }}</span>
|
||||
<strong>{{ admin.stats.devices }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.devices) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<Database :size="18" />
|
||||
<span>{{ t('overview.accounts') }}</span>
|
||||
<strong>{{ admin.stats.accounts }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.accounts) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<ScrollText :size="18" />
|
||||
<span>{{ t('overview.audit') }}</span>
|
||||
<strong>{{ admin.audit.length }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.auditEntries) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1408,146 +1271,100 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="admin-panel-section-card__heading">
|
||||
<div>
|
||||
<span>{{ t('appearance.eyebrow') }}</span>
|
||||
<h2>{{ t('appearance.title') }}</h2>
|
||||
<p>{{ t('appearance.body') }}</p>
|
||||
<span>{{ t('statistics.eyebrow') }}</span>
|
||||
<h2>{{ t('statistics.title') }}</h2>
|
||||
<p>{{ t('statistics.body') }}</p>
|
||||
</div>
|
||||
<Palette :size="20" />
|
||||
<ChartNoAxesCombined :size="20" />
|
||||
</div>
|
||||
<div class="admin-panel-accent-picker">
|
||||
<button
|
||||
v-for="option in accentOptions"
|
||||
:key="option.color"
|
||||
type="button"
|
||||
:class="{ 'is-active': accentColor === option.color }"
|
||||
:aria-label="t('appearance.colors.' + option.key)"
|
||||
:title="t('appearance.colors.' + option.key)"
|
||||
@click="selectAccent(option.color)"
|
||||
>
|
||||
<span :style="{ backgroundColor: option.color }"></span>
|
||||
<strong>{{ t('appearance.colors.' + option.key) }}</strong>
|
||||
<Check v-if="accentColor === option.color" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-panel-appearance-controls">
|
||||
<section class="admin-panel-appearance-control is-color">
|
||||
<div>
|
||||
<strong>{{ t('appearance.controls.customColor') }}</strong>
|
||||
<small>{{
|
||||
t('appearance.controls.customColorBody')
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="admin-panel-rgb-picker">
|
||||
<label class="admin-panel-color-value">
|
||||
<span :style="{ backgroundColor: accentColor }"></span>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="text"
|
||||
maxlength="7"
|
||||
:value="accentColor.toUpperCase()"
|
||||
:aria-label="t('appearance.controls.hex')"
|
||||
@change="updateAccent"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-rgb-fields">
|
||||
<label v-for="channel in accentChannels" :key="channel">
|
||||
<span>{{ t(`appearance.controls.${channel}`) }}</span>
|
||||
<input
|
||||
class="admin-panel-rgb-range"
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:style="{
|
||||
background: accentChannelGradient(channel),
|
||||
}"
|
||||
:aria-label="t(`appearance.controls.${channel}`)"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:aria-label="`${t(`appearance.controls.${channel}`)} ${t('appearance.controls.value')}`"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-statistics-layout">
|
||||
<section class="admin-panel-activity-statistics">
|
||||
<div class="admin-panel-statistics-heading">
|
||||
<div>
|
||||
<strong>{{ t('statistics.today') }}</strong>
|
||||
<small>{{ t('statistics.todayBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel-activity-grid">
|
||||
<article>
|
||||
<MessageSquare :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.messagesToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.messagesToday') }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<PhoneCall :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.callsToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.callsToday') }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<ScrollText :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.auditToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.auditToday') }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontFamily') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontFamilyBody') }}</small>
|
||||
</span>
|
||||
<div
|
||||
class="admin-panel-font-select"
|
||||
@focusout="closeFontMenu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="fontMenuOpen"
|
||||
@click="fontMenuOpen = !fontMenuOpen"
|
||||
<section class="admin-panel-coverage-statistics">
|
||||
<div class="admin-panel-statistics-heading">
|
||||
<div>
|
||||
<strong>{{ t('statistics.coverage') }}</strong>
|
||||
<small>{{ t('statistics.coverageBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel-coverage-list">
|
||||
<article
|
||||
v-for="metric in [
|
||||
{
|
||||
key: 'linkedDevices',
|
||||
value: admin.stats.linkedDevices,
|
||||
},
|
||||
{
|
||||
key: 'simDevices',
|
||||
value: admin.stats.simDevices,
|
||||
},
|
||||
{
|
||||
key: 'activeDevices',
|
||||
value: admin.stats.activeDevices,
|
||||
},
|
||||
]"
|
||||
:key="metric.key"
|
||||
>
|
||||
<span :style="{ fontFamily: activeAdminFontFamily }">
|
||||
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }}
|
||||
</span>
|
||||
<ChevronDown :size="14" />
|
||||
</button>
|
||||
<div
|
||||
v-if="fontMenuOpen"
|
||||
class="admin-panel-font-select__menu"
|
||||
role="listbox"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
>
|
||||
<button
|
||||
v-for="option in fontFamilyOptions"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="adminFontFamily === option.key"
|
||||
:class="{ 'is-active': adminFontFamily === option.key }"
|
||||
:style="{ fontFamily: option.value }"
|
||||
@click="selectAdminFontFamily(option.key)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ t(`statistics.${metric.key}`) }}</strong>
|
||||
<span>{{
|
||||
t(`appearance.controls.fonts.${option.key}`)
|
||||
t('statistics.ofDevices', {
|
||||
count: formatStatistic(metric.value),
|
||||
total: formatStatistic(admin.stats.devices),
|
||||
})
|
||||
}}</span>
|
||||
<Check
|
||||
v-if="adminFontFamily === option.key"
|
||||
:size="13"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="admin-panel-statistics-progress"
|
||||
role="progressbar"
|
||||
:aria-label="t(`statistics.${metric.key}`)"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="100"
|
||||
:aria-valuenow="
|
||||
statisticPercentage(metric.value, admin.stats.devices)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: `${statisticPercentage(metric.value, admin.stats.devices)}%`,
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontSize') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontSizeBody') }}</small>
|
||||
</span>
|
||||
<div class="admin-panel-font-size-control">
|
||||
<input
|
||||
v-model.number="adminFontSizePreview"
|
||||
type="range"
|
||||
min="90"
|
||||
max="115"
|
||||
step="5"
|
||||
:aria-label="t('appearance.controls.fontSize')"
|
||||
@change="updateAdminFontSize"
|
||||
/>
|
||||
<strong>{{ adminFontSizePreview }}%</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -2488,7 +2305,8 @@ onBeforeUnmount(() => {
|
||||
--admin-text: #f0f3f0;
|
||||
--admin-muted: #818781;
|
||||
--admin-dim: #555b55;
|
||||
--admin-green: var(--admin-accent, #74d66f);
|
||||
--admin-accent: #00b8e4;
|
||||
--admin-green: var(--admin-accent);
|
||||
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
|
||||
--admin-toggle-on: #63d471;
|
||||
--admin-row-hover: linear-gradient(
|
||||
@@ -2512,10 +2330,7 @@ onBeforeUnmount(() => {
|
||||
padding: 2.5vh 2.5vw;
|
||||
color: var(--admin-text);
|
||||
background: transparent;
|
||||
font-family: var(
|
||||
--admin-font-family,
|
||||
var(--sky-font-family, Inter, sans-serif)
|
||||
);
|
||||
font-family: var(--sky-font-family, Inter, sans-serif);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -2529,7 +2344,6 @@ onBeforeUnmount(() => {
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.72),
|
||||
inset 0 1px rgba(255, 255, 255, 0.018);
|
||||
zoom: var(--admin-font-scale, 1);
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
@@ -2653,15 +2467,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready {
|
||||
color: #74d66f;
|
||||
color: var(--admin-green);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 5px rgba(116, 214, 111, 0.42));
|
||||
filter: drop-shadow(
|
||||
0 0 5px color-mix(in srgb, var(--admin-green) 42%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready:hover:not(:disabled) {
|
||||
color: color-mix(in srgb, #74d66f 82%, white);
|
||||
color: color-mix(in srgb, var(--admin-green) 82%, white);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 7px rgba(116, 214, 111, 0.65));
|
||||
filter: drop-shadow(
|
||||
0 0 7px color-mix(in srgb, var(--admin-green) 65%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
@@ -3352,293 +3170,127 @@ button:disabled {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker {
|
||||
.admin-panel-statistics-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-activity-statistics,
|
||||
.admin-panel-coverage-statistics {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background: #0b0d0c;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button {
|
||||
min-height: 38px;
|
||||
.admin-panel-statistics-heading {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-statistics-heading > div {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button:hover,
|
||||
.admin-panel-accent-picker button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker strong {
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin-top: 8px;
|
||||
background: #0a0c0b;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control.is-color {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(170px, 0.7fr) minmax(320px, 1.3fr);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control > div:first-child,
|
||||
.admin-panel-appearance-control > span:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control strong {
|
||||
.admin-panel-statistics-heading strong {
|
||||
color: #d9ddd9;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control small {
|
||||
.admin-panel-statistics-heading small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-picker {
|
||||
.admin-panel-activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-activity-grid article {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(230px, 1fr);
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 2px 6px;
|
||||
padding: 10px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-color-value {
|
||||
min-width: 96px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
background: #1a1d1b;
|
||||
}
|
||||
|
||||
.admin-panel-color-value > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 48%, transparent);
|
||||
}
|
||||
|
||||
.admin-panel-color-value input {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #cbd0cb;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(90px, 1fr) 42px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number'] {
|
||||
width: 42px !important;
|
||||
max-width: 42px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 8px;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-inner-spin-button,
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range::-webkit-slider-thumb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid #f3f5f3;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 0 2px #171a18;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-font-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button svg {
|
||||
color: var(--admin-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button[aria-expanded='true'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.09);
|
||||
background: #101211;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button:hover,
|
||||
.admin-panel-font-select__menu button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu svg {
|
||||
.admin-panel-activity-grid svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) 38px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
.admin-panel-activity-grid strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control input {
|
||||
width: 100%;
|
||||
.admin-panel-activity-grid span {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
color: var(--admin-muted);
|
||||
font-size: 7px;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list article {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list article > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list strong,
|
||||
.admin-panel-coverage-list span {
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list strong {
|
||||
color: #d9ddd9;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list span {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-panel-statistics-progress {
|
||||
height: 3px;
|
||||
accent-color: var(--admin-green);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #1d201e;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control > strong {
|
||||
color: var(--admin-green);
|
||||
text-align: right;
|
||||
.admin-panel-statistics-progress > span {
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: inherit;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 52%, transparent);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-section-card__heading {
|
||||
|
||||
@@ -15,7 +15,18 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
const EMPTY_STATS: AdminStats = {
|
||||
accounts: 0,
|
||||
activeDevices: 0,
|
||||
auditEntries: 0,
|
||||
auditToday: 0,
|
||||
callsToday: 0,
|
||||
devices: 0,
|
||||
linkedDevices: 0,
|
||||
messagesToday: 0,
|
||||
online: 0,
|
||||
simDevices: 0,
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
|
||||
@@ -848,40 +848,21 @@ const adminPanelFallbackLocales = {
|
||||
auditFeature: 'Review sensitive admin actions',
|
||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||
},
|
||||
appearance: {
|
||||
eyebrow: 'Appearance',
|
||||
title: 'Interface',
|
||||
body: 'Personalize colors and typography across the admin workspace.',
|
||||
colors: {
|
||||
emerald: 'Emerald',
|
||||
blue: 'Blue',
|
||||
violet: 'Violet',
|
||||
orange: 'Orange',
|
||||
red: 'Red',
|
||||
},
|
||||
controls: {
|
||||
customColor: 'Custom color',
|
||||
customColorBody: 'Choose any RGB accent or enter its channel values.',
|
||||
red: 'R',
|
||||
green: 'G',
|
||||
blue: 'B',
|
||||
hex: 'HEX color',
|
||||
value: 'value',
|
||||
fontFamily: 'Font family',
|
||||
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
|
||||
fontSize: 'Font size',
|
||||
fontSizeBody: 'Scale text and controls for comfortable readability.',
|
||||
fonts: {
|
||||
inter: 'Inter',
|
||||
system: 'System',
|
||||
classic: 'Classic',
|
||||
verdana: 'Verdana',
|
||||
tahoma: 'Tahoma',
|
||||
trebuchet: 'Trebuchet',
|
||||
georgia: 'Georgia',
|
||||
mono: 'Monospace',
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
eyebrow: 'Live data',
|
||||
title: 'Phone statistics',
|
||||
body: 'Current usage and device status, refreshed every 15 seconds.',
|
||||
today: 'Activity today',
|
||||
todayBody: 'Phone and admin activity since midnight.',
|
||||
messagesToday: 'Messages',
|
||||
callsToday: 'Calls',
|
||||
auditToday: 'Admin actions',
|
||||
coverage: 'Device coverage',
|
||||
coverageBody: 'Current share of all stored phones.',
|
||||
linkedDevices: 'Account linked',
|
||||
simDevices: 'SIM assigned',
|
||||
activeDevices: 'Updated in 24 hours',
|
||||
ofDevices: '{count} of {total} devices',
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
|
||||
@@ -4740,7 +4740,18 @@ function adminMockBootstrap() {
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
stats: {
|
||||
accounts: 24,
|
||||
activeDevices: 19,
|
||||
auditEntries: 37,
|
||||
auditToday: 4,
|
||||
callsToday: 18,
|
||||
devices: 31,
|
||||
linkedDevices: 22,
|
||||
messagesToday: 146,
|
||||
online: 2,
|
||||
simDevices: 27,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ Locales["de"] = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
@@ -2175,29 +2175,3 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Erlaubte Jobs",
|
||||
Websites = "Webseiten",
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
|
||||
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
|
||||
Locales["de"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Eigene Farbe",
|
||||
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX-Farbe",
|
||||
value = "Wert",
|
||||
fontFamily = "Schriftart",
|
||||
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
|
||||
fontSize = "Schriftgröße",
|
||||
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Klassisch",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ Locales["en"] = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
@@ -2175,29 +2175,3 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Allowed Jobs",
|
||||
Websites = "Websites",
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
|
||||
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
|
||||
Locales["en"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Custom color",
|
||||
customColorBody = "Choose any RGB accent or enter its channel values.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX color",
|
||||
value = "value",
|
||||
fontFamily = "Font family",
|
||||
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
|
||||
fontSize = "Font size",
|
||||
fontSizeBody = "Scale text and controls for comfortable readability.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Classic",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ Locales["es"] = {
|
||||
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
|
||||
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" },
|
||||
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
|
||||
appearance = { eyebrow = "Apariencia", title = "Color de acento", body = "Cambia el acento en todo el espacio de trabajo del administrador.", colors = { emerald = "Esmeralda", blue = "Azul", violet = "Violeta", orange = "Naranja", red = "Rojo" } },
|
||||
statistics = { eyebrow = "Datos en vivo", title = "Estadísticas del teléfono", body = "Uso actual y estado de los dispositivos, actualizado cada 15 segundos.", today = "Actividad de hoy", todayBody = "Actividad telefónica y administrativa desde medianoche.", messagesToday = "Mensajes", callsToday = "Llamadas", auditToday = "Acciones de administrador", coverage = "Cobertura de dispositivos", coverageBody = "Proporción actual de todos los teléfonos guardados.", linkedDevices = "Cuenta vinculada", simDevices = "SIM asignada", activeDevices = "Actualizado en 24 horas", ofDevices = "{count} de {total} dispositivos" },
|
||||
configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } },
|
||||
players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." },
|
||||
search = { players = "Nombre, identificación, trabajo o número de búsqueda", apps = "Aplicaciones de búsqueda", clear = "Limpiar búsqueda" },
|
||||
@@ -2175,30 +2175,3 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Trabajos permitidos",
|
||||
Websites = "Las páginas web",
|
||||
}
|
||||
|
||||
Locales["es"].Nui.AdminPanel.appearance.title = "Interfaz"
|
||||
Locales["es"].Nui.AdminPanel.appearance.body = "Personaliza los colores y la tipografía en el espacio de trabajo del administrador."
|
||||
Locales["es"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Color personalizado",
|
||||
customColorBody = "Seleccione cualquier acento RGB o ingrese sus valores de canal.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "EN EL CASO DE B",
|
||||
hex = "El color HEX",
|
||||
value = "el valor",
|
||||
fontFamily = "Familia de fuentes",
|
||||
fontFamilyBody = "Seleccione la tipografía utilizada por el panel de administración completo.",
|
||||
fontSize = "Tamaño de fuente",
|
||||
fontSizeBody = "Escala el texto y los controles para una fácil legibilidad.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "El sistema",
|
||||
classic = "Clásico",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Arbúsqueda",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospacio",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -463,16 +463,35 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
local totals = Bridge.Database.Query([[
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `account_id` IS NOT NULL) AS `linked_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `sim_id` IS NOT NULL) AS `sim_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`
|
||||
WHERE `updated_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) AS `active_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_sms_messages`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `messages_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_calls`
|
||||
WHERE `started_at` >= CURRENT_DATE) AS `calls_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`) AS `audit_entries`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `audit_today`
|
||||
]], {})
|
||||
local stats = totals[1] or {}
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
||||
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
||||
devices = tonumber(stats.devices) or 0,
|
||||
accounts = tonumber(stats.accounts) or 0,
|
||||
linkedDevices = tonumber(stats.linked_devices) or 0,
|
||||
simDevices = tonumber(stats.sim_devices) or 0,
|
||||
activeDevices = tonumber(stats.active_devices) or 0,
|
||||
messagesToday = tonumber(stats.messages_today) or 0,
|
||||
callsToday = tonumber(stats.calls_today) or 0,
|
||||
auditEntries = tonumber(stats.audit_entries) or 0,
|
||||
auditToday = tonumber(stats.audit_today) or 0,
|
||||
},
|
||||
audit = load_audit(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user