macbook 에서 리눅스로 이동

This commit is contained in:
k3341095
2026-03-05 10:35:28 +09:00
commit ffd13c0fbb
83 changed files with 12262 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
export interface KpiCard {
label: string
value: string
note: string
color: string
band: string
border: string
}
export interface RunningProject {
name: string
domain: string
status: string
leads: number
}
export const useDashboardStore = defineStore('admin-dashboard', {
state: () => ({
kpiCards: [
{
label: '진행중 프로젝트',
value: '5',
note: '최근 7일 내 변경 3건',
color: 'text-cyan-300',
band: 'from-cyan-300/80 to-cyan-200/20',
border: 'border-cyan-300/25'
},
{
label: '전체 프로젝트',
value: '18',
note: '캠페인 기준 총 18개',
color: 'text-fuchsia-200',
band: 'from-fuchsia-300/80 to-fuchsia-200/20',
border: 'border-fuchsia-300/25'
},
{
label: '새로운 리드',
value: '24',
note: '오늘 신규 유입',
color: 'text-emerald-300',
band: 'from-emerald-300/80 to-emerald-200/20',
border: 'border-emerald-300/25'
}
],
trendBars: [12, 20, 16, 26, 18, 24],
runningProjects: [
{
name: '여름 프로모션',
domain: 'summer.ad-camp.kr',
status: '운영중',
leads: 42
},
{
name: '주말 특가 랜딩',
domain: 'weekend.offer.kr',
status: '테스트',
leads: 13
},
{
name: '리타겟팅 A',
domain: 'retarget.example.com',
status: '준비중',
leads: 0
}
] as RunningProject[]
})
})

View File

@@ -0,0 +1,242 @@
import { defineStore } from 'pinia'
export type PageStatus = 'live' | 'draft' | 'paused'
export type PageSortBy =
| 'updated'
| 'name'
| 'leads'
| 'visitors'
| 'variants'
export interface CampaignLabel {
id: string
name: string
}
export interface AdminPage {
id: string
campaignId: string
name: string
domain: string
routePath: string
status: PageStatus
leadCount: number
visitorCount: number
variantCount: number
updatedAt: string
}
const campaigns: CampaignLabel[] = [
{ id: 'p1', name: 'Q3 마케팅 캠페인' },
{ id: 'p2', name: '블랙프라이데이 2023' },
{ id: 'p3', name: 'SaaS 웨비나 시리즈' },
{ id: 'p4', name: 'Product Hunt 론칭' }
]
const pageSource: AdminPage[] = [
{
id: 'lp01',
campaignId: 'p1',
name: '메인 랜딩',
domain: 'summer.ad-camp.kr',
routePath: '/',
status: 'live',
leadCount: 124,
visitorCount: 2400,
variantCount: 2,
updatedAt: '2026-03-04T09:12:00.000Z'
},
{
id: 'lp02',
campaignId: 'p1',
name: '오퍼 페이지 B',
domain: 'summer.ad-camp.kr',
routePath: '/offer',
status: 'draft',
leadCount: 16,
visitorCount: 420,
variantCount: 1,
updatedAt: '2026-03-03T18:05:00.000Z'
},
{
id: 'lp03',
campaignId: 'p2',
name: '구글 전용 랜딩',
domain: 'weekend.offer.kr',
routePath: '/google',
status: 'live',
leadCount: 88,
visitorCount: 1210,
variantCount: 3,
updatedAt: '2026-03-01T13:40:00.000Z'
},
{
id: 'lp04',
campaignId: 'p2',
name: '일반 버전',
domain: 'weekend.offer.kr',
routePath: '/',
status: 'paused',
leadCount: 22,
visitorCount: 540,
variantCount: 1,
updatedAt: '2026-02-28T06:25:00.000Z'
},
{
id: 'lp05',
campaignId: 'p4',
name: '리타겟 랜딩',
domain: 'retarget.example.com',
routePath: '/',
status: 'draft',
leadCount: 0,
visitorCount: 40,
variantCount: 1,
updatedAt: '2026-03-02T22:17:00.000Z'
}
]
const statusRank: Record<PageStatus, number> = {
live: 0,
draft: 1,
paused: 2
}
const statusLabel = (status: PageStatus) => {
if (status === 'live') {
return '라이브'
}
if (status === 'paused') {
return '일시정지'
}
return '초안'
}
export const usePagesStore = defineStore('admin-pages', {
state: () => ({
pages: pageSource,
campaigns,
searchQuery: '',
campaignFilter: 'all' as 'all' | string,
statusFilter: 'all' as 'all' | PageStatus,
sortBy: 'updated' as PageSortBy
}),
getters: {
filteredPages: (state): AdminPage[] => {
const query = state.searchQuery.trim().toLowerCase()
const result = state.pages
.filter((page) => {
const text = `${page.name} ${page.domain} ${page.routePath}`.toLowerCase()
const searchOk = query === '' || text.includes(query)
const campaignOk = state.campaignFilter === 'all' || page.campaignId === state.campaignFilter
const statusOk = state.statusFilter === 'all' || page.status === state.statusFilter
return searchOk && campaignOk && statusOk
})
.sort((a, b) => {
if (state.sortBy === 'name') {
return a.name.localeCompare(b.name)
}
if (state.sortBy === 'leads') {
return b.leadCount - a.leadCount
}
if (state.sortBy === 'visitors') {
return b.visitorCount - a.visitorCount
}
if (state.sortBy === 'variants') {
return b.variantCount - a.variantCount
}
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
})
return result
},
campaignOptions: (state): CampaignLabel[] => state.campaigns
},
actions: {
setSearchQuery(value: string) {
this.searchQuery = value
},
setCampaignFilter(value: 'all' | string) {
this.campaignFilter = value
},
setStatusFilter(value: 'all' | PageStatus) {
this.statusFilter = value
},
setSortBy(value: PageSortBy) {
this.sortBy = value
},
removePage(pageId: string) {
this.pages = this.pages.filter((page) => page.id !== pageId)
},
createQuickPage() {
const campaign = this.campaigns[this.pages.length % this.campaigns.length]
this.createPage({
name: `새 페이지 ${this.pages.length + 1}`,
campaignId: campaign.id,
domain: 'ad-camp-temp.test',
routePath: `/lp-${this.pages.length + 1}`
})
},
createPage(payload: {
name: string
campaignId: string
domain: string
routePath: string
}) {
const normalizedRoute = payload.routePath.trim().startsWith('/') ? payload.routePath.trim() : `/${payload.routePath.trim()}`
const normalizedDomain = payload.domain.trim()
const newPage: AdminPage = {
id: `lp-${Date.now()}`,
campaignId: payload.campaignId,
name: payload.name.trim(),
domain: normalizedDomain,
routePath: normalizedRoute,
status: 'draft',
leadCount: 0,
visitorCount: 0,
variantCount: 1,
updatedAt: new Date().toISOString()
}
this.pages.unshift(newPage)
},
statusClass(status: PageStatus) {
if (status === 'live') {
return {
label: statusLabel(status),
chipClass: 'text-emerald-200 bg-emerald-400/10',
dotClass: 'bg-emerald-300'
}
}
if (status === 'paused') {
return {
label: statusLabel(status),
chipClass: 'text-slate-300 bg-slate-500/10',
dotClass: 'bg-slate-400'
}
}
return {
label: statusLabel(status),
chipClass: 'text-amber-200 bg-amber-400/10',
dotClass: 'bg-amber-300'
}
},
campaignName(campaignId: string) {
return (
this.campaigns.find((campaign) => campaign.id === campaignId)?.name || campaignId || '미지정'
)
},
previewUrl(page: AdminPage) {
return `https://${page.domain}${page.routePath}`
}
}
})

View File

@@ -0,0 +1,156 @@
import { defineStore } from 'pinia'
export type ProjectStatus = 'active' | 'archived' | 'draft'
export type ProjectSortBy = 'newest' | 'oldest' | 'most-leads' | 'most-variants'
export type ProjectStatusFilter = 'all' | ProjectStatus
export interface ProjectCard {
id: string
title: string
subtitle: string
status: ProjectStatus
leads: string
metricLabel: string
metricValue: string
trend: string
trendUp: boolean
variants: number
createdAt: string
}
const projectSource: ProjectCard[] = [
{
id: 'p1',
title: 'Q3 마케팅 캠페인',
subtitle: '2일 전 생성',
status: 'active',
leads: '1,240',
metricLabel: '전환율',
metricValue: '4.8%',
trend: '+12%',
trendUp: true,
variants: 3,
createdAt: '2026-03-01T09:00:00.000Z'
},
{
id: 'p2',
title: '블랙프라이데이 2023',
subtitle: '2023년 12월 1일 종료',
status: 'archived',
leads: '5,100',
metricLabel: '방문자',
metricValue: '42.5k',
trend: '종료',
trendUp: false,
variants: 3,
createdAt: '2023-12-01T03:00:00.000Z'
},
{
id: 'p3',
title: 'SaaS 웨비나 시리즈',
subtitle: '4시간 전 수정됨',
status: 'draft',
leads: '0',
metricLabel: '노출 수',
metricValue: '12k',
trend: '+18%',
trendUp: true,
variants: 2,
createdAt: '2026-03-04T01:20:00.000Z'
},
{
id: 'p4',
title: 'Product Hunt 런칭',
subtitle: '2주간 운영중',
status: 'active',
leads: '892',
metricLabel: '클릭률',
metricValue: '2.1%',
trend: '-2%',
trendUp: false,
variants: 1,
createdAt: '2026-02-12T16:30:00.000Z'
}
]
const toLeadNumber = (value: string) => {
const trimmed = value.trim().toLowerCase().replace(/,/g, '')
const numeric = Number(trimmed.replace('k', ''))
if (Number.isNaN(numeric)) {
return 0
}
return trimmed.endsWith('k') ? numeric * 1000 : numeric
}
export const useProjectsStore = defineStore('admin-projects', {
state: () => ({
projects: projectSource,
searchQuery: '',
statusFilter: 'all' as ProjectStatusFilter,
sortBy: 'newest' as ProjectSortBy
}),
getters: {
filteredProjects: (state): ProjectCard[] => {
const query = state.searchQuery.trim().toLowerCase()
const list = state.projects
.filter((project) => {
const target = `${project.title} ${project.subtitle}`.toLowerCase()
const matchQuery = query === '' || target.includes(query)
const matchStatus =
state.statusFilter === 'all' || project.status === state.statusFilter
return matchQuery && matchStatus
})
.sort((a, b) => {
if (state.sortBy === 'oldest') {
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
}
if (state.sortBy === 'most-leads') {
return toLeadNumber(b.leads) - toLeadNumber(a.leads)
}
if (state.sortBy === 'most-variants') {
return b.variants - a.variants
}
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
return list
}
},
actions: {
createProject(input: { name: string; campaignName: string }) {
const now = new Date().toISOString()
const id = `p-${Date.now().toString(36)}`
this.projects.unshift({
id,
title: input.name.trim(),
subtitle: `${input.campaignName.trim()}에서 생성`,
status: 'draft',
leads: '0',
metricLabel: '전환율',
metricValue: '-',
trend: '신규',
trendUp: true,
variants: 0,
createdAt: now
})
},
setSearchQuery(value: string) {
this.searchQuery = value
},
setStatusFilter(value: ProjectStatusFilter) {
this.statusFilter = value
},
setSortBy(value: ProjectSortBy) {
this.sortBy = value
}
}
})

View File

@@ -0,0 +1,238 @@
import { defineStore } from 'pinia'
export interface PageVariant {
id: string
pageId: string
name: string
description: string
trafficWeight: number
isActive: boolean
leadCount: number
updatedAt: string
}
export interface CreateVariantInput {
pageId: string
name: string
description: string
trafficWeight: number
}
export interface UpdateVariantInput {
id: string
pageId: string
name?: string
description?: string
trafficWeight?: number
isActive?: boolean
leadCount?: number
}
type VariantMap = Record<string, PageVariant[]>
const toVariantIso = (value = 0) => {
const date = new Date()
date.setSeconds(date.getSeconds() + value)
return date.toISOString()
}
const variantSeed: VariantMap = {
lp01: [
{
id: 'v-lp01-a',
pageId: 'lp01',
name: '일반형',
description: '현재 운영 중인 기본 랜딩 버전',
trafficWeight: 70,
isActive: true,
leadCount: 76,
updatedAt: toVariantIso(-1200)
},
{
id: 'v-lp01-b',
pageId: 'lp01',
name: '강한 메시지형',
description: 'CTA 문구를 자극적으로 강화한 변형',
trafficWeight: 30,
isActive: true,
leadCount: 48,
updatedAt: toVariantIso(-900)
}
],
lp02: [
{
id: 'v-lp02-a',
pageId: 'lp02',
name: '기본 오퍼',
description: '초안 상태 테스트용 템플릿',
trafficWeight: 100,
isActive: true,
leadCount: 16,
updatedAt: toVariantIso(-2000)
}
],
lp03: [
{
id: 'v-lp03-a',
pageId: 'lp03',
name: '구글 버전 A',
description: '광고 채널별로 분기되는 기본 버전',
trafficWeight: 50,
isActive: true,
leadCount: 58,
updatedAt: toVariantIso(-1300)
},
{
id: 'v-lp03-b',
pageId: 'lp03',
name: '구글 버전 B',
description: '강조 문구/이미지를 변경한 실험군',
trafficWeight: 30,
isActive: true,
leadCount: 22,
updatedAt: toVariantIso(-800)
},
{
id: 'v-lp03-c',
pageId: 'lp03',
name: '구글 버전 C',
description: '폼 위치를 상단으로 이동한 변형',
trafficWeight: 20,
isActive: true,
leadCount: 8,
updatedAt: toVariantIso(-500)
}
],
lp04: [
{
id: 'v-lp04-a',
pageId: 'lp04',
name: '저녁 타겟형',
description: '기본 버전',
trafficWeight: 100,
isActive: false,
leadCount: 22,
updatedAt: toVariantIso(-700)
}
],
lp05: [
{
id: 'v-lp05-a',
pageId: 'lp05',
name: '리타겟 기본형',
description: '리마케팅 유입용 기본 템플릿',
trafficWeight: 100,
isActive: true,
leadCount: 0,
updatedAt: toVariantIso(-1000)
}
]
}
const clampTrafficWeight = (value: number) => {
if (!Number.isFinite(value)) {
return 1
}
if (value < 1) {
return 1
}
if (value > 100) {
return 100
}
return Math.round(value)
}
export const useVariantsStore = defineStore('admin-variants', {
state: () => ({
variantsByPage: variantSeed as VariantMap
}),
getters: {
listByPage: (state) => (pageId: string) => {
const list = state.variantsByPage[pageId] ?? []
return [...list].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
},
countByPage: (state) => (pageId: string) => {
return state.variantsByPage[pageId]?.length ?? 0
},
totalLeadsByPage: (state) => (pageId: string) => {
return (state.variantsByPage[pageId] ?? []).reduce((sum, item) => sum + item.leadCount, 0)
}
},
actions: {
createVariant(payload: CreateVariantInput) {
const normalizedName = payload.name.trim()
const pageId = payload.pageId.trim()
const description = payload.description.trim()
if (pageId.length === 0 || normalizedName.length === 0) {
return
}
const pageVariants = this.variantsByPage[pageId] ?? []
const newVariant: PageVariant = {
id: `v-${Date.now().toString(36)}`,
pageId,
name: normalizedName,
description,
trafficWeight: clampTrafficWeight(payload.trafficWeight),
isActive: true,
leadCount: 0,
updatedAt: new Date().toISOString()
}
this.variantsByPage[pageId] = [newVariant, ...pageVariants]
},
updateVariant(payload: UpdateVariantInput) {
const pageId = payload.pageId.trim()
const variants = this.variantsByPage[pageId]
if (!variants) {
return
}
const idx = variants.findIndex((item) => item.id === payload.id)
if (idx === -1) {
return
}
const target = variants[idx]
variants[idx] = {
...target,
...payload,
updatedAt: new Date().toISOString(),
pageId
}
},
toggleActive(pageId: string, variantId: string) {
const variants = this.variantsByPage[pageId]
if (!variants) {
return
}
const idx = variants.findIndex((item) => item.id === variantId)
if (idx === -1) {
return
}
variants[idx] = {
...variants[idx],
isActive: !variants[idx].isActive,
updatedAt: new Date().toISOString()
}
},
removeVariant(pageId: string, variantId: string) {
const variants = this.variantsByPage[pageId]
if (!variants) {
return
}
this.variantsByPage[pageId] = variants.filter((variant) => variant.id !== variantId)
}
}
})