Arquitetura de Plataforma de Comissão
Plataformas de comissão multi-inquilino onde cada usuário (artista, criador, freelancer) possui uma página pública com capacidade de realizar pedidos. Esta skill cobre a decisão arquitetural de oferecer tanto templates hospedados integrados quanto permitir que criadores com seus próprios sites possam integrar.
Sistema de Página em Três Camadas
O padrão arquitetural central: suportar criadores em três níveis de habilidade técnica e independência.
┌─────────────────────────────────────────────────┐
│ Camada 3: Hospedagem de Página Personalizada │
│ Envie seu próprio HTML/CSS/JS → servido em │
│ /artist/alice (arquivos estáticos de uploads/) │
├─────────────────────────────────────────────────┤
│ Camada 2: Widget Incorporável │
│ Cole uma tag <script> no seu site existente │
│ → Botão "Encomendar Comissão" + formulário │
├─────────────────────────────────────────────────┤
│ Camada 1: Templates Integrados │
│ Escolha uma variante de layout, zero configuração│
│ → Página completa em /artist/alice │
└─────────────────────────────────────────────────┘
Camada 1: Templates Integrados
Para quem é: Criadores que não possuem site próprio ou não querem mantê-lo.
Implementação: Componentes Vue com temas via CSS Variables.
// Rota: /artist/:subdomain
routes.get('/artist/:subdomain', async (req, reply) => {
const artist = await artistService.getBySubdomain(req.params.subdomain)
if (artist.page_type === 'default') {
// Renderiza SPA Vue integrado (ou SSR)
// O campo page_theme controla qual variante de template
return reply.sendFile('index.html')
}
// ... fallback para camadas 2-3 tratadas em outro lugar
})
Template = Layout × Paleta (eixos ortogonais) — a decisão de modelagem mais importante. Se dois "templates" diferem principalmente na cor, eles NÃO são dois templates — eles são um layout usando duas cores. Agrupar a cor na dimensão do template infla a contagem de templates com código de estrutura duplicado (os usuários notarão: "essas páginas parecem a mesma em cores diferentes"). Divida em eixos que o artista escolhe independentemente:
| Eixo | Quem escolhe | Exemplos | Controla |
|---|---|---|---|
| Layout (estrutura) | Artista | classic (estúdio de duas colunas) / gallery (museu em tela cheia) / folio (landing page única) / atelier (estúdio acolhedor em estilo livro) | Tratamento de abertura, lógica de colunas, formato da galeria, navegação |
| Paleta (clima) | Artista | paper / ink / dusk / moss — cada uma definida com variantes clara E escura | Cores de fundo/superfície/texto da página (variáveis CSS --pal-*) |
| Acento + modo | Visitante | Seletor de acento de 5 cores + preferência claro/escuro | --color-primary, html.dark |
Regras-chave:
- Uma paleta é um clima com variantes clara+escura, nunca "o tema escuro". O artista escolhe
ink; visitantes em modo claro veem cinza claro, visitantes em modo escuro veem carvão — o clima permanece consistente, o brilho segue o visitante. - A cor de acento (botões/links/destaques) sempre usa a variável de acento do visitante (
--color-primary), nunca as variáveis de paleta — os dois sistemas permanecem ortogonais e ambos continuam funcionando. - 4 layouts × 4 paletas = 16 aparências distintas a partir de 8 definições.
- Layouts devem diferir estruturalmente (abertura diferente, lógica de colunas diferente) ou devem ser mesclados em um único layout.
Arquétipos de layout que funcionam — cada um abre com o ativo mais distintivo do artista (sua arte), não um herói genérico centrado em nome+bio+botões:
| Layout | Abertura | Toque característico |
|---|---|---|
| gallery | Obra de arte em tela cheia, nome como pequena placa no canto | Galeria editorial alternando grande/pequeno, zoom ao passar o mouse, clique para pré-visualizar |
| classic | Banner com obra principal, nome sobreposto à arte | Duas colunas no desktop, cartão informativo fixo com CTA de comissão sempre visível |
| folio | Tela dividida: texto à esquerda, obras empilhadas à direita | Destaque de navegação baseado em rolagem, hamburger no mobile + CTA fixo na parte inferior |
| atelier | Espalhado em estilo livro acolhedor, tipografia serifada (Noto Serif SC) | Fundo com textura de papel, margens editoriais, espaçamento generoso, foco na arte com clima literário |
Arquitetura de kit compartilhado (comprovada): templates nunca leem nomes de campos do backend diretamente — um composable adaptador (useArtistData(props)) fornece imgUrl(), statusText() (i18n), socialLinks, heroArtwork; componentes Tpl* reutilizáveis recebem variantes via props (TplHero variant="banner|fullscreen|split", TplGallery layout="grid|editorial|masonry") com slots como pontos de extensão (#addons na grade de níveis). Veja references/template-system.md para o padrão completo, incluindo correções de temporização de componentes assíncronos e mapeamento de IDs legados.
Sobreposição de cor de acento definida pelo artista (padrão R49): O artista pode definir uma cor de acento persistente (lista branca de 5 cores, armazenada como artists.accent_color TEXT) que substitui a escolha do ThemePicker do visitante em sua página pública. Implementação em ArtistHome.vue:
const ACCENT_INDEX = { '#34dbcb': '1', '#34c2db': '2', '#3498db': '3', '#346edb': '4', '#3445db': '5' }
const accentOverride = computed(() => {
const raw = previewAccent.value || artist.value?.accentColor
return raw ? (ACCENT_INDEX[String(raw).toLowerCase()] || null) : null
})
// watch(immediate) + onUnmounted: salvar/restaurar document.documentElement.dataset.accent
Decisões-chave: (1) Injeção de atributo global data-accent — NENHUMA alteração de variável CSS por template necessária (theme.css já possui seletores data-accent="1"~"5" com variantes clara+escura). Adicionar suporte a acento em todos os 4 templates = zero alterações de arquivo de template. (2) Acento do artista tem prioridade sobre o ThemePicker do visitante enquanto na página do artista; a escolha do visitante é restaurada em onUnmounted. (3) Backend valida contra lista branca de 5 cores + null (limpar); normalização toLowerCase() na escrita. (4) UI de configurações: botões circulares de amostra (32px) com ✓ de selecionado + botão "默认" (limpar). NÃO usar el-color-picker (cor livre quebraria a lista branca). (5) Decisão C61: acento afeta apenas páginas voltadas ao cliente, NÃO o painel do artista.
Modo de pré-visualização via parâmetros de URL (padrão R50): O botão de configurações "预览主页" abre /artist/:subdomain?_tpl=X&_pal=Y&_accent=Z em uma nova janela. ArtistHome.vue lê route.query._tpl/_pal/_accent como substituições da camada de renderização (nunca escreve na camada de dados). Um banner de aviso fixo ("预览模式 — 修改尚未保存") é exibido quando qualquer parâmetro de pré-visualização está presente. Os computados de template/paleta/acento recaem em: previewParam || artist.field || default. Seguro para acesso público — parâmetros de pré-visualização afetam apenas a renderização, todos os dados vêm da API pública.
Linha do tempo de atividades substituindo lista plana de notas (padrão R40): Ao mesclar registros de mudança de status (notas do sistema) e notas do artista em uma única visão cronológica, use el-timeline com diferenciação de tipo/oco:
<el-timeline-item :type="note.created_by === 'system' ? 'info' : (note.image_path ? 'success' : 'primary')"
:hollow="note.created_by === 'system'" :timestamp="formatDate(note.created_at)" placement="top">
Indicadores de tipo: 🔄 mudança de status / 📝 nota / 🖼 nota com imagem. Notas do sistema recebem classe tl-item--system (cor suave, fonte menor). Botão de excluir (R46) aparece apenas para notas não do sistema, revelado ao passar o mouse com fallback de toque @media (hover: none). A caixa de entrada é movida para o final da linha do tempo. Zero alterações no backend — as notas do sistema já existem em order_notes com created_by='system'. A barra de operações (avançar/retroceder/cancelar) permanece FORA do cartão da linha do tempo.
Substituição por arrastar com confirmação dependente do contexto (R53 vs R55): Dois recursos de arrastar-para-substituir no mesmo aplicativo têm REQUISITOS de confirmação DIFERENTES — NÃO os unifique:
| R53 Imagem de foco do quadro de fila | R55 Imagem de exemplo de nível | |
|---|---|---|
| Confirmação de substituição | Não (a imagem antiga permanece na galeria do pedido) | Sim — ElMessageBox.confirm (imagem antiga é perdida) |
| Motivo | Substituir o foco ≠ excluir a imagem | Substituir o exemplo = imagem antiga irrecuperável |
Implementação: infraestrutura de arrastar compartilhada (dragover.prevent/dragleave com contains(relatedTarget) anti-flicker/drop.prevent + input de arquivo oculto), mas a função de upload verifica if (row.example_image) { await ElMessageBox.confirm(...) } apenas para R55. Ambas reutilizam as APIs de upload existentes. O el-image preview-src-list deve ser removido da imagem de foco do R53 para evitar que ele engula o evento de clique (mesma armadilha do R18). |
Extrair composable de formulário de pedido (padrão R58-1): Quando o formulário de pedido ultrapassa ~400 linhas (carregamento de dados + precificação + validação + upload + envio + recuperação de rascunho), extraia TODA a lógica de negócios em um composable compartilhado (useOrderForm.js). O componente de página torna-se puro layout (~300 linhas de template + estilo, o script é apenas const of = useOrderForm(subdomain, formRef) + desestruturação). O composable retorna um objeto plano de refs/reactives/métodos agrupados por preocupação: carregamento de dados (artist, tiers, rulesContent, loading), estado do formulário + regras de validação, estado de envio (submitting, showSuccess, resultNo, submit), imagens de referência (refFileList, handleRefUpload, handleRefRemove), precificação (addonSelections, pricePreview, pricingExpanded, selectedTier, onTierChange) e HTML sanitizado das regras. Por que isso importa para multi-template: quando a página de pedido suporta múltiplos templates de layout (passo a passo, estilo recibo, estilo revista), cada template é uma casca de layout fina que chama o MESMO composable — zero duplicação de lógica. O composable é o "núcleo", os templates são "pele". Este é o mesmo padrão de adaptador de useArtistData para a página inicial, aplicado ao formulário de pedido. Detalhe de implementação chave: o composable recebe formRef (a ref do el-form do template) como parâmetro porque a validação (formRef.value.validate()) deve chamar o elemento form do template — o composable possui a lógica, mas NÃO o DOM.
Layout de formulário de pedido passo a passo (padrão R58-2, comprovado): O layout de pedido padrão é um fluxo guiado de três etapas construído inteiramente sobre useOrderForm() (zero alterações no composable do R58-1 — valida a extração):
- Indicador de etapa: três pontos numerados + linhas de conexão. Atual: ampliado + primário. Concluída: ✓ + preenchimento primário. Pendente: cinza oco. Mobile (≤860px): apenas pontos.
- Etapa 1 — Escolher nível: grade de cartões responsiva (
repeat(auto-fill, minmax(240px, 1fr))) substituindoel-select. Selecionado: transição--ease-bounce+ carimbo giratório ✓ (@keyframes tier-stamp-in: escala 0→1.2→1, rotação -15°→0°). A divulgação progressiva do R14 permanece dentro da etapa 1. - Etapa 2 — Descrever + enviar: textarea + tags de inspiração (R58-4: chips padrão codificados, clique anexa com vírgula inteligente + truncamento de 2000 caracteres) + upload de referência (arquivo + colar inalterados).
- Etapa 3 — Contato: QQ + nome + aceitação das regras.
- Cartão de resumo fixo: coluna direita em widescreen (280px fixo), nível + detalhamento + total + parcelas em tempo real. Mobile: parte inferior da etapa ativa.
- Confirmação de recibo (R58-3): enviar → validar →
el-dialogde recibo de café (teleportado para o body, CSS global namespacereceipt-*emtemplates.css). Papel#fdfbf5, Courier monoespaçado, bordas em zigue-zague (repeating-conic-gradient), separadores tracejados, decoração de código de barras, chips de parcelas. Tema claro fixo (metáfora do papel). Cancelar retorna à etapa 3; confirmar chamasubmit(). - Copiar informações do pedido (R58-5): diálogo de sucesso "复制约稿信息" → área de transferência multilinha (número do pedido + nível + complementos + total).
- Navegação entre etapas: "下一步" valida a etapa atual antes de avançar; "上一步" sempre permitido; sem pular etapas.
Página de pedido multi-template (arquitetura R58): A página de pedido suporta templates de layout selecionáveis pelo artista, independentemente do template da página inicial. Backend (comprovado, migração v16): artists.order_template_id TEXT DEFAULT 'default' — validação de lista branca na camada de serviço (ORDER_TEMPLATES = ['default'], estender o array ao adicionar templates). GET /api/artists/:subdomain retorna orderTemplateId: artist.order_template_id || 'default'. PUT /api/artist/profile aceita orderTemplateId (mapeado via keyMap para order_template_id). Valores inválidos geram INVALID_ORDER_TEMPLATE (409). String vazia recai em 'default'. Frontend: A página de configurações do artista mostra um seletor de template em estilo cartão (mesmo padrão de UI da seleção de template da página inicial). Roteamento do lado do cliente: OrderForm.vue lê artist.orderTemplateId e renderiza o componente de layout correspondente via <component :is="..."> ou um mapa de registro de template. Todos os componentes de layout compartilham useOrderForm() — eles diferem APENAS no arranjo visual (indicadores de etapa, layout do cartão, estilo do diálogo de confirmação). Adicionar um novo template de pedido = um novo arquivo .vue com uma casca de layout + uma entrada no array da lista branca do backend, zero alterações de lógica. Pré-visualização: reutiliza o mecanismo de parâmetro de URL _tpl da pré-visualização da página inicial.
Padrão de contato QQ (R58-6): Duas opções para contato QQ: "跳转QQ" (window.open('tencent://message/?uin=' + encodeURIComponent(qq), '_self')) + "复制QQ" (navigator.clipboard.writeText(qq) com toast). Fallback da área de transferência: em caso de falha (permissões negadas, não-HTTPS), exibe ElMessage.warning(qq) — o próprio número QQ como mensagem de aviso, para que o usuário possa copiar manualmente do toast. Aplicado em dois lugares: diálogo de sucesso do lado do cliente (contactQq do artista) e detalhe do pedido do lado do artista (qq do cliente). i18n: chaves separadas por contexto (orderForm.jumpQq vs orderDetail.jumpQq) porque a mensagem do toast difere ("QQ号已复制" vs "客户QQ已复制").
Padrões de atualização de CSS (lote v0.16, comprovados):
- Easing bounce global:
--ease-bounce: cubic-bezier(.34, 1.56, .64, 1)em:root. Use apenas para transições de transform/box-shadow (nunca opacity — não pode ultrapassar 1.0). Fallback no código consumidor:var(--ease-bounce, cubic-bezier(0.22, 1, 0.36, 1)). - Modelo físico de botão de três estados:
.el-button:hover:not(:disabled):not(.is-loading) { transform: translateY(-3px); box-shadow: deeper }+:active { transform: translateY(-1px) scale(0.98) }. As exclusões:not()impedem que botões desabilitados/carregando animem. - Layout fluido com
clamp()(padrão C73-1): elementos sensíveis (fontes, padding, gap, tamanhos de cartão) usamclamp(tamanhoMin, escalaFluida, tamanhoMax). A escala fluida deriva decalc(valorMin + (valorMax - valorMin) * ((100vw - 375px) / (1440px - 375px))). Exemplo para fonte primária:--fs-primary: clamp(0.875rem, calc(0.75rem + 0.5vw), 1.125rem). Evite tipografia baseada emvwpuro (incontrolável em telas muito largas/muito estreitas). Este padrão substitui consultas de mídia para elementos individuais — consultas de mídia são reservadas para mudanças de layout (quebra de coluna, alternância de navegação). - Animação de entrada de cartão com base no índice
:nth-child(padrão C73-2): cartões em uma grade (níveis, portfólio) entram com atraso escalonado:animation: card-in 0.6s var(--ease-bounce) both; animation-delay: calc(var(--i, 0) * 80ms). O índice--ié definido porstyle="--i: 0"inline no template (Vue:style="{ '--i': index }"). Nenhum JS de interseção necessário para animações de entrada simples.
Camada 2: Widget Incorporável (<iframe> incorporado)
Para quem é: Criadores com site próprio que desejam um formulário de comissão sem redirecionar os visitantes.
Implementação: widget baseado em <iframe> carregado via POST /api/embed/token (token de uso único, 15 min de expiração). O iframe é dimensionado para max-width: 100%; height: 500px (pode ser ajustado pelo criador via atributos data-*). O widget de comissão completo (seleção de nível, formulário, checkout) é servido como uma SPA Vue independente (dist/embed.html), comunicando-se com o servidor principal via tokens de API em curto prazo.
Geração de token do widget:
// POST /api/embed/token
app.post('/api/embed/token', async (req, reply) => {
const { artistSubdomain } = req.body
const token = crypto.randomBytes(32).toString('hex')
// Armazenar token com tempo de expiração + artistSubdomain
await redis.set(`embed:${token}`, artistSubdomain, 'EX', 900) // 15 min
return { token }
})
Script de integração do cliente: o criador coloca esta tag em sua página:
<script src="https://suaplataforma.com/embed.js" data-artist="subdomain-do-artista" defer></script>
Isso renderiza um invólucro de iframe estilizado com fallbacks de acessibilidade.
Camada 3: Hospedagem de Página Personalizada
Para quem é: Criadores que desejam controle total sobre sua página pública na plataforma.
Implementação: Armazenamento de arquivos estáticos enviados, servidos em /artist/:subdomain.
Rota fallback:
// Fallback em routes.get('/artist/:subdomain')
if (artist.page_type === 'custom') {
const filePath = path.join(config.uploadsDir, artist.subdomain, 'index.html')
if (fs.existsSync(filePath)) {
return reply.sendFile(filePath)
}
// Fallback para 404: servir página de erro personalizada ou padrão
}
Fluxo de upload: Os criadores enviam um arquivo ZIP contendo index.html, style.css, script.js e quaisquer ativos estáticos (imagens, fontes) através de um endpoint de upload na página de configurações. O backend extrai e valida o conteúdo antes de servir.
Validação (itens obrigatórios):
- Arquivo raiz
index.html— repositório principal da página. - Sem caminhos de diretório maliciosos (impedir traversal com
path.resolve + path.relative). - Tamanho máximo: 10MB por envio.
- HTML sanitizado (tags permitidas: divisões, seções, cabeçalhos, parágrafos, listas, links, imagens, mídia incorporada; script/style permitido apenas em arquivos dedicados; nenhum
eval()no conteúdo injetado).
Estrutura de diretórios no backend:
uploads/
└── {subdomain}/
├── index.html
├── style.css
└── assets/
└── logo.png
Endpoints da API
| Método | Rota | Descrição |
|---|---|---|
GET | /api/artists/:subdomain | Obter dados públicos do artista (inclui pageType, orderTemplateId, etc.) |
PUT | /api/artist/profile | Atualizar perfil do artista (inclui pageType, orderTemplateId, accentColor, etc.) |
POST | /api/embed/token | Gerar token de uso único para widget incorporado |
POST | /api/upload/custom-page | Enviar arquivos ZIP da página personalizada |
POST | /api/order | Criar um novo pedido de comissão |
Variáveis CSS e Temas
O sistema de temas funciona através de variáveis CSS definidas em :root e substituídas por dados do artista/seleções do visitante. Paletas definem as cores base; temas claro/escuro e acentos do visitante ajustam-nas através de seletores CSS.
Exemplo de definição de paleta (estilo de cores) :
:root {
--pal-paper-light-bg: #fefcf5;
--pal-paper-light-text: #3a3a3a;
--pal-paper-dark-bg: #1e1e1e;
--pal-paper-dark-text: #e0e0e0;
/* ... */
}
/* O tema escuro substitui as cores base por variantes escuras da paleta */
html.dark {
--page-bg: var(--pal-paper-dark-bg);
--page-text: var(--pal-paper-dark-text);
}
/* As cores de acento são definidas separadamente */
[data-accent="1"] { --color-primary: #34dbcb; }
[data-accent="2"] { --color-primary: #34c2db; }
/* ... */
Considerações de Segurança
- Todos os uploads de página personalizada são verificados contra traversal de diretório e conteúdo malicioso.
- Tokens de widget expiram após 15 minutos — nenhuma exposição de longa duração.
- A sobreposição de acento do artista é validada contra uma lista branca no backend — sem cores arbitrárias.
- A pré-visualização via parâmetros de URL nunca persiste dados; é apenas uma substituição de renderização do lado do cliente.
Etapas de Migração
Ao adicionar novos recursos (por exemplo, novo template de layout, nova paleta):
- Adicionar ao array de lista branca no backend (serviço e validação).
- Adicionar arquivo de componente no frontend.
- Registrar no mapeamento de template (se aplicável).
- Adicionar opção na UI de configurações.
- Testar com parâmetros de URL de pré-visualização.
Referências: references/template-system.md, references/component-architecture.md