Economize 20% hoje mesmo Use o código WELCOME ao finalizar a compra. BEM-VINDO

Crie um aplicativo personalizado para celular (NUI + React) para QBCore/ESX…

Meta
Crie um smartphone pronto para produção no jogo FiveM usando NUI + React. Você criará um recurso, conectará eventos QBCore/ESX, persistirá dados no MySQL e enviará uma interface de usuário fluida que respeite os orçamentos de desempenho.


Pré-requisitos

  1. Um servidor FiveM em execução com txAdmin e MySQL (oxmysql ou mysql-async).
  2. Node.js 18+ e pnpm ou npm no seu PC de desenvolvimento.
  3. Uma estrutura instalada: QBCore ou ESX.
  4. Bibliotecas recomendadas: boi_lib (retornos de chamada, notificações), inventário de bois (opcional para item de telefone), boi_alvo (opcional para interações mundiais).
  5. Conhecimento básico de React.

Documentos

Leitura interna (FiveMX)


Arquitetura

  1. Recurso meu_telefone com fxmanifest.lua, cliente, servidor, e interface do usuário pacote.
  2. Interface do usuário: Aplicativo React criado com Vite em /ui/dist. NUI fala com Lua via postar mensagem + RegistrarNUICallback.
  3. Dados: Tabelas MySQL para contatos telefônicos, mensagens telefônicas, ligações telefônicas.
  4. Cola para estrutura: QBCore ou O manipulador utilizável de itens ESX alterna o telefone e os retornos de chamada do servidor carregam/salvam dados.

Fluxo de eventos

  1. O jogador pressiona a tecla ou usa o item telefone → 2) SetNuiFocus(verdadeiro, verdadeiro) e EnviarNUIMessage({ ação = 'abrir' }) → 3) React mostra UI → 4) UI solicita dados via buscar('https://meu_telefone/xyz') (NUI) → 5) RegistrarNUICallback('xyz', ...) executa no cliente/servidor → 6) O servidor lê/grava no banco de dados → 7) A resposta retorna para a interface do usuário → 8) Feche o telefone e libere o foco.

Etapa 1 — Estruturar o recurso

Layout de pasta

recursos/ [local]/ meu_telefone/ cliente fxmanifest.lua/ servidor main.lua/ interface do usuário main.lua/ index.html src/ main.tsx App.tsx api.ts styles.css

fxmanifest.lua

fx_version 'cerulean' jogo 'gta5' ui_page 'ui/dist/index.html' arquivos { 'ui/dist/**' } client_scripts { 'client/main.lua' } server_scripts { '@oxmysql/lib/MySQL.lua', 'server/main.lua' } lua54 'yes'

Etapa 2 — Criar o React NUI

Inicializar um aplicativo Vite React dentro meu_telefone/ui e construir para interface do usuário/dist.

cd meu_telefone/ui pnpm criar vite@latest . --template react-ts pnpm i

Configuração do Vite (garantir que os ativos cheguem em distância)

// vite.config.ts importar { defineConfig } de 'vite' importar reagir de '@vitejs/plugin-react' exportar padrão defineConfig({ plugins: [react()], construir: { outDir: 'dist', emptyOutDir: true }, base: '' })

Ponte NUI

// src/api.ts
export async function nui<T>(event: string, data?: unknown): Promise<T> {
  const res = await fetch(`https://my_phone/${event}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data ?? {})
  })
  return await res.json()
}

Monte React + ouvinte de mensagens

// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<App />)

window.addEventListener('message', (e) => {
  if (e.data?.action === 'open') document.body.classList.add('open')
  if (e.data?.action === 'close') document.body.classList.remove('open')
})

Interface de usuário básica

// src/App.tsx
import { useEffect, useState } from 'react'
import { nui } from './api'

type Contact = { id: number; name: string; number: string }

export default function App() {
  const [contacts, setContacts] = useState<Contact[]>([])
  const [visible, setVisible] = useState(false)

  useEffect(() => {
    const handler = (e: MessageEvent) => {
      if (e.data?.action === 'open') {
        setVisible(true)
        nui<Contact[]>('contacts:list').then(setContacts)
      }
      if (e.data?.action === 'close') setVisible(false)
    }
    window.addEventListener('message', handler)
    return () => window.removeEventListener('message', handler)
  }, [])

  if (!visible) return null
  return (
    <div className="phone">
      <header>Phone</header>
      <section>
        {contacts.map(c => (
          <div key={c.id} className="row">
            <div>{c.name}</div>
            <div>{c.number}</div>
          </div>
        ))}
      </section>
      <footer>
        <button onClick={() => nui('ui:close')}>Close</button>
      </footer>
    </div>
  )
}

índice.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>meu_telefone</title>
    <link rel="stylesheet" href="/src/styles.css" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Crie a interface do usuário:

construção pnpm

Etapa 3 — Cliente: abrir/fechar, foco NUI, retornos de chamada

-- client/main.lua local open = false local function openPhone() se aberto então return end open = true SetNuiFocus(true, true) SendNUIMessage({ action = 'open' }) end local function closePhone() se não estiver aberto então return end open = false SetNuiFocus(false, false) SendNUIMessage({ action = 'close' }) end -- Atalho de teclado (exemplo F1) RegisterCommand('myphone', function() se aberto então closePhone() else openPhone() end end) RegisterKeyMapping('myphone', 'Toggle Phone', 'keyboard', 'F1') -- NUI → retornos de chamada do jogo RegisterNUICallback('ui:close', function(_, cb) closePhone() cb({ ok = true }) end) -- lista contatos pergunta ao servidor RegisterNUICallback('contacts:list', function(_, cb) lib.callback('my_phone:server:getContacts', false, function(linhas) cb(linhas) fim) fim)

Dica: habilite o NUI devtools no console de jogo com nui_devTools. Abrir http://localhost:13172 no seu navegador Chromium para inspecionar a interface do usuário.


Etapa 4 — Servidor: esquema de banco de dados + retornos de chamada

SQL (MySQL)

CRIAR TABELA SE NÃO EXISTIR phone_contacts ( id INT AUTO_INCREMENT CHAVE PRIMÁRIA, citizenid VARCHAR(64) NÃO NULO, nome VARCHAR(64) NÃO NULO, número VARCHAR(32) NÃO NULO, ÍNDICE(citizenid) ); CRIAR TABELA SE NÃO EXISTIR phone_messages ( id BIGINT AUTO_INCREMENT CHAVE PRIMÁRIA, proprietário VARCHAR(64) NÃO NULO, peer VARCHAR(64) NÃO NULO, corpo TEXTO NÃO NULO, created_at TIMESTAMP PADRÃO TIMESTAMP ATUAL, ÍNDICE(proprietário), ÍNDICE(peer) );

Servidor com oxmysql + ox_lib

-- server/main.lua local QBCore = exports['qb-core'] e exports['qb-core']:GetCoreObject() ESX = ESX ou nil se não for QBCore então TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end) end -- Carregar contatos para o personagem conectado lib.callback.register('my_phone:server:getContacts', function(source) local citizenid se QBCore então local Player = QBCore.Functions.GetPlayer(source) citizenid = Player e Player.PlayerData.citizenid senão local xPlayer = ESX.GetPlayerFromId(source) citizenid = xPlayer e xPlayer.identifier end se não for citizenid então return {} end local rows = MySQL.query.await('SELECT id, name, number FROM phone_contacts WHERE citizenid = ?', { citizenid }) retornar linhas ou {} fim) -- Salvar um contato lib.callback.register('my_phone:server:addContact', function(source, contact) if type(contact) ~= 'table' then return { ok = false } fim local nome, número = contato.nome, contato.número se não for nome ou não for número então return { ok = false } fim local citizenid se QBCore então local Player = QBCore.Functions.GetPlayer(source) citizenid = Player e Player.PlayerData.citizenid senão local xPlayer = ESX.GetPlayerFromId(source) citizenid = xPlayer e xPlayer.identifier fim se não for citizenid então return { ok = false } fim MySQL.insert.await('INSERT INTO phone_contacts (citizenid, nome, número) VALUES (?, ?, ?)', { citizenid, nome, número }) return { ok = true } fim)

Etapa 5 — Integração do Framework (item + permissões)

QBCore

Adicione um item de telefone utilizável e alterne a interface do usuário quando usado.

-- server/main.lua (somente QBCore) se QBCore então QBCore.Functions.CreateUseableItem('telefone', função(origem, item) TriggerClientEvent('meu_telefone:cliente:alternar', origem) fim) fim
-- client/main.lua RegisterNetEvent('my_phone:client:toggle', function() if IsPauseMenuActive() then return end if IsPedInAnyVehicle(PlayerPedId(), false) then -- regra opcional -- mostrar uma notificação via ox_lib lib.notify({ title = 'Phone', description = 'Sem telefone ao dirigir.', type = 'error' }) return end if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end end)

ESX

-- server/main.lua (somente ESX) se ESX e não QBCore então ESX.RegisterUsableItem('phone', function(playerId) TriggerClientEvent('my_phone:client:toggle', playerId) end) end

Se você usar inventário de bois, crie o item lá e confie em seus manipuladores utilizáveis. Você ainda pode acionar o mesmo evento do cliente.


Etapa 6 — Principais recursos

Implemente pequenas fatias e envie incrementalmente.

Contatos

  1. Chamadas de interface do usuário contatos:lista → o servidor retorna linhas.
  2. Adicionar formulário “Adicionar contato” → ligar adicionar contato.
  3. Adicionar “Remover contato” → servidor exclui por eu ia com verificação de propriedade do cidadão.

Mensagens (SMS)

  1. Mesa mensagens telefônicas dono de lojas, colega, corpo.
  2. A IU abre um chat, faz chamadas mensagens:lista e mensagens:enviar.
  3. O servidor insere uma mensagem e, opcionalmente, emite um evento do cliente para o peer se estiver online.

Esboço do servidor

lib.callback.register('my_phone:server:messages:list', function(source, peer)
  local cid = GetCitizenId(source)
  return MySQL.query.await('SELECT * FROM phone_messages WHERE owner=? AND peer=? ORDER BY id DESC LIMIT 200', { cid, peer }) or {}
end)

RegisterNetEvent('my_phone:server:messages:send', function(peer, body)
  local src = source
  local cid = GetCitizenId(src)
  if type(body) ~= 'string' or #body == 0 or #body > 500 then return end
  MySQL.insert.await('INSERT INTO phone_messages (owner, peer, body) VALUES (?, ?, ?)', { cid, peer, body })
  TriggerClientEvent('my_phone:client:messages:push', src, peer, body)
  -- optional: find target player by phone number and push live event
end)

O cliente recebe

RegisterNetEvent('my_phone:client:messages:push', function(peer, body) SendNUIMessage({ action = 'message:new', peer = peer, body = body }) end)

Chamadas (MVP opcional)

  • Armazene apenas registros de chamadas. O Real Audio usa seu plugin de voz (pma-voice, mumble, SaltyChat) e está fora deste MVP.
  • Adicione o teclado da interface → ao discar, registre uma chamada efetuada; ao atender, registre uma chamada recebida. Você pode integrar posteriormente com a API de um plugin de voz.

Etapa 7 — Segurança, UX, desempenho

Segurança

  1. Nunca confie na entrada NUI. Valide os tipos e o comprimento no servidor.
  2. Verifique a propriedade de cada consulta com identidade de cidadão ou identificador.
  3. Evite expor identificadores a outros clientes. Use retransmissões de servidor.

UX

  1. Cancele o uso do telefone enquanto estiver caído, algemado ou dirigindo, se as regras do seu servidor exigirem.
  2. Mantenha a interface do usuário ágil. Use atualizações otimistas e reconcilie na confirmação do servidor.

Desempenho

  1. Mantenha o NUI ocioso. Evite loops setInterval no React. Use efeitos e eventos.
  2. Mantenha os pacotes pequenos. Carregue telas pesadas com preguiça. Envie ativos compactados.
  3. Use o Resmon para obter um orçamento com média de menos de 0,01–0,02 ms. Consulte o guia FiveMX no link acima.

Etapa 8 — Teste e depuração

  1. Iniciar recurso em servidor.cfg antes de scripts dependentes.
garantir meu_telefone
  1. No jogo, pressione F8 → executar nui_devTools → aberto http://localhost:13172 e escolha sua página NUI.
  2. Inspecione a aba de rede. Cada chamada NUI → Lua é acionada https://my_phone/<name> pontos finais.
  3. Usar /meu telefone comando e confirmar alternância de foco.
  4. Execute o Resmon e verifique se a CPU permanece baixa enquanto o telefone está aberto e fechado.

Etapa 9 — Embalagem e atualizações

  1. Comprometer-se interface do usuário/ fonte e interface do usuário/dist/ construir.
  2. Em CI, execute pnpm --filter construção de interface do usuário e somente navio distância em lançamentos.
  3. Controle a versão das suas migrações de SQL. Nunca descarte dados de usuários sem fazer backups.

Etapa 10 — Extensões que você pode adicionar em seguida

  1. Bancário: link para o recurso bancário do seu servidor; exponha saldo e transferências.
  2. Tweets/Anúncios: feed global com limites de taxa e moderação.
  3. Mercado: listagens com custódia.
  4. Aplicativos de emprego: ganchos de MDT da polícia/EMS.
  5. Fotos: integração de captura de tela via endpoint do servidor, não URLs de dados.
  6. Configurações: temas dinâmicos, toques, planos de fundo.

Solução de problemas

O telefone abre atrás do menu de pausa
Desabilite as verificações durante a pausa e reabra quando a atividade retornar.

O retorno de chamada NUI não dispara

  • Garantir RegisterNUICallback('evento', ...) os nomes correspondem ao caminho de busca da IU.
  • Confirmar versão_fx é cerúleo e página_ui aponta para ui/dist/index.html.
  • Verifique o console F8 para erros CORS ou JSON.

Itens não utilizáveis

  • QBCore: confirmar QBCore.Funções.CriarItemUtilizável corre e telefone existe na sua lista de itens.
  • ESX: confirmar ESX.RegisterUsableItem('telefone', ...) registros após cargas de estoque.

Erros de banco de dados

  • Certifique-se de que o oxmysql tenha iniciado antes deste recurso.
  • Verifique os tamanhos das colunas e as codificações para nomes Unicode.

Trechos de referência (copiar e colar)

Ajudante

função GetCitizenId(fonte) se QBCore então local P = QBCore.Functions.GetPlayer(fonte) retorna P e P.PlayerData.citizenid senão local xP = ESX.GetPlayerFromId(fonte) retorna xP e xP.identifier fim fim

Adicionar contato da IU

// UI
async function addContact(name: string, number: string) {
  const res = await nui<{ ok: boolean }>('contacts:add', { name, number })
  if (res.ok) {
    const next = await nui<any[]>('contacts:list')
    // update state
  }
}
-- cliente RegisterNUICallback('contatos:adicionar', função(dados, cb) lib.callback('meu_telefone:servidor:adicionarContato', falso, função(resp) cb(resp) fim, dados) fim)

O que você construiu

  • Um MVP de telefone focado em contatos e mensagens.
  • Uma ponte NUI limpa que funciona em ambas as estruturas.
  • Uma camada de banco de dados que você pode expandir com segurança.

Envie, meça o desempenho e repita.


Leitura adicional


Meta

  • Resposta de destino: ≤0,02 ms em média enquanto ocioso.
  • Orçamento do pacote: ≤250 KB compactados para MVP.
  • FPS da interface do usuário: 60.
Lucas
Lucas

Eu sou Luke, sou um gamer e adoro escrever sobre FiveM, GTA e roleplay. Eu administro uma comunidade de roleplay e tenho cerca de 10 anos de experiência em administração de servidores.

Artigos: 570