init ana page with apis
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
135
analytics_requirements_frontend.md
Normal file
135
analytics_requirements_frontend.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 短链接分析系统 - Next.js 前端实现需求
|
||||
|
||||
## 项目概述
|
||||
|
||||
基于 Next.js 框架实现短链接分析系统的前端部分,提供丰富的数据可视化和分析功能。
|
||||
|
||||
## 技术栈要求
|
||||
技术栈要求是最新的且是稳定版本
|
||||
|
||||
## 特别说明
|
||||
- 增删改link都不是在这个项目做的
|
||||
- 这个项目时负责展示link的统计与分析的而不是管理link的
|
||||
|
||||
## 实际需求
|
||||
|
||||
|
||||
|
||||
## 短链接概览
|
||||
|
||||
- 卡片式布局展示每个短链接的关键表现指标
|
||||
- 显示每个短链接的基础信息(名称、原始URL、创建日期)
|
||||
- 突出展示三个核心指标:总访问量、独立访问用户数、平均停留时间
|
||||
- 每个指标旁边显示环比变化百分比和趋势箭头
|
||||
- 颜色编码直观表示表现好坏(绿色增长,红色下降)
|
||||
- 支持时间范围切换(7天/30天/90天)
|
||||
- 可按表现指标排序以识别表现最佳/最差的短链接
|
||||
- 帮助团队快速评估每个短链接的效果
|
||||
- 点击卡片可展开查看该短链接的详细分析
|
||||
|
||||
## 访问转化漏斗
|
||||
|
||||
- 以漏斗图形式展示用户从点击短链接到最终目标完成的全过程
|
||||
- 显示6个转化阶段:访问、停留、交互、注册、订阅、购买
|
||||
- 每个阶段显示用户数量和占比
|
||||
- 相邻阶段间显示转化率百分比
|
||||
- 底部显示三个关键指标:平均转化率、最高转化阶段、最低转化阶段
|
||||
- 根据所选项目和时间范围自动更新数据
|
||||
|
||||
## 访问趋势
|
||||
|
||||
- 柱状图形式展示一段时间内访问数量的变化
|
||||
- 横轴显示日期,纵轴显示访问数量
|
||||
- 每个柱体代表当天的访问总数
|
||||
- 悬停时显示具体访问数量
|
||||
- 自动计算最大值设置合适的比例尺
|
||||
- 使用蓝色渐变效果提高视觉吸引力
|
||||
- 帮助团队了解用户访问的时间规律
|
||||
|
||||
## 短链接表现
|
||||
|
||||
- 表格形式展示所有短链接数据
|
||||
- 每行显示一个短链接的关键指标:名称、原URL、创建者、创建日期
|
||||
- 包含流量指标:访问量、独立访问用户、跳出率、平均停留时间
|
||||
- 显示转化率评分
|
||||
- 支持按创建者和标签筛选
|
||||
- 可排序功能便于查找表现最佳短链接
|
||||
|
||||
## 平台分布
|
||||
|
||||
- 横向条形图展示不同来源平台的访问分布
|
||||
- 每个平台显示对应品牌颜色和图标
|
||||
- 显示具体数量和所占百分比
|
||||
- 条形长度直观反映各平台占比
|
||||
- 帮助团队了解哪些平台引流效果更好
|
||||
|
||||
## 链接状态分布
|
||||
|
||||
- 环形图展示短链接状态的分布情况
|
||||
- 包括三种状态:活跃、已过期、已禁用
|
||||
- 每个状态使用不同颜色直观区分
|
||||
- 显示各状态的数量和百分比
|
||||
- 提供短链接管理流程的整体视图
|
||||
|
||||
## 设备分析详情
|
||||
|
||||
- 横向渐变条展示访问设备类型分析
|
||||
- 从移动设备到桌面设备的直观展示
|
||||
- 显示移动端、平板、桌面端访问的准确百分比
|
||||
- 黑色指针标记在渐变条上的当前设备偏好位置
|
||||
- 帮助评估用户设备使用习惯和优化方向
|
||||
|
||||
## 热门链接
|
||||
|
||||
- 列表形式展示最受欢迎的短链接
|
||||
- 按访问量或转化率排序
|
||||
- 显示链接名称和访问数据
|
||||
- 标记高转化链接以引起注意
|
||||
- 帮助识别最成功的短链接类型
|
||||
|
||||
## 热门引荐来源
|
||||
|
||||
- 词云形式展示访问来源中出现频率最高的网站
|
||||
- 根据引荐量调整来源网站大小和颜色
|
||||
- 使用不同颜色区分不同类别的来源
|
||||
- 视觉化展现用户来源分布
|
||||
- 帮助团队了解用户访问的主要渠道
|
||||
|
||||
## 用户访问时间分析
|
||||
|
||||
- 24小时热力图展示一天中用户访问的高峰时段
|
||||
- 横轴显示24小时时间段,纵轴显示7天的日期
|
||||
- 颜色深浅表示访问量的多少
|
||||
- 自动标注访问高峰和低谷时段
|
||||
- 悬停时显示具体时间点的访问数据
|
||||
- 帮助优化短链接发布时间
|
||||
- 可按平台筛选查看不同来源平台的用户活跃规律
|
||||
|
||||
## 链接表现分析
|
||||
|
||||
- 散点图形式展示所有短链接的表现分布
|
||||
- 横轴表示访问量,纵轴表示转化率
|
||||
- 点的大小代表链接的停留时间
|
||||
- 点的颜色代表不同类型或标签
|
||||
- 四象限划分帮助识别高价值短链接
|
||||
- 鼠标悬停显示详细指标和链接信息
|
||||
- 支持按时间段、链接类型和创建者筛选
|
||||
- 帮助团队发现最有效的短链接模式
|
||||
|
||||
## QR码分析
|
||||
|
||||
- 展示与短链接关联的QR码使用情况
|
||||
- 显示每个QR码的扫描量和转化率
|
||||
- 支持按位置、活动或使用场景筛选
|
||||
- 提供QR码与短链接效果的对比分析
|
||||
- 帮助评估线上线下引流效果
|
||||
|
||||
## 概览卡片
|
||||
|
||||
- 展示三个核心指标的卡片式布局
|
||||
- 每个卡片包含大数字显示当前值和环比增长
|
||||
- 总访问量卡片:显示所有短链接访问总量及周环比变化
|
||||
- 平均转化率卡片:显示转化目标完成率及周环比
|
||||
- 活跃短链接卡片:显示有访问的短链接占比及周环比提升
|
||||
- 每个卡片包含对应图标和趋势指示器
|
||||
- 直观展示短链接系统的整体健康状况
|
||||
28
app/api/analytics/device-analysis/route.ts
Normal file
28
app/api/analytics/device-analysis/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDeviceAnalysis } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 获取设备分析详情
|
||||
const analysisData = await getDeviceAnalysis(
|
||||
startDate,
|
||||
endDate,
|
||||
linkId
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(analysisData);
|
||||
} catch (error) {
|
||||
console.error('Error in device-analysis API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch device analysis data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/api/analytics/funnel/route.ts
Normal file
36
app/api/analytics/funnel/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConversionFunnel } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId');
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 验证必要参数
|
||||
if (!linkId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameter: linkId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取转化漏斗数据
|
||||
const funnelData = await getConversionFunnel(
|
||||
linkId,
|
||||
startDate || undefined,
|
||||
endDate || undefined
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(funnelData);
|
||||
} catch (error) {
|
||||
console.error('Error in funnel API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch funnel data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/api/analytics/link-performance/route.ts
Normal file
36
app/api/analytics/link-performance/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinkPerformance } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId');
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 验证必要参数
|
||||
if (!linkId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameter: linkId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取链接表现数据
|
||||
const performanceData = await getLinkPerformance(
|
||||
linkId,
|
||||
startDate || undefined,
|
||||
endDate || undefined
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(performanceData);
|
||||
} catch (error) {
|
||||
console.error('Error in link-performance API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch link performance data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/api/analytics/link-status-distribution/route.ts
Normal file
28
app/api/analytics/link-status-distribution/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinkStatusDistribution } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('projectId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 获取链接状态分布数据
|
||||
const distributionData = await getLinkStatusDistribution(
|
||||
startDate,
|
||||
endDate,
|
||||
projectId
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(distributionData);
|
||||
} catch (error) {
|
||||
console.error('Error in link-status-distribution API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch link status distribution data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/api/analytics/overview-cards/route.ts
Normal file
28
app/api/analytics/overview-cards/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getOverviewCards } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('projectId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 获取概览卡片数据
|
||||
const cardsData = await getOverviewCards(
|
||||
startDate,
|
||||
endDate,
|
||||
projectId
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(cardsData);
|
||||
} catch (error) {
|
||||
console.error('Error in overview-cards API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch overview cards data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/api/analytics/overview/route.ts
Normal file
36
app/api/analytics/overview/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinkOverview } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId');
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 验证必要参数
|
||||
if (!linkId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameter: linkId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取链接概览数据
|
||||
const overviewData = await getLinkOverview(
|
||||
linkId,
|
||||
startDate || undefined,
|
||||
endDate || undefined
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(overviewData);
|
||||
} catch (error) {
|
||||
console.error('Error in overview API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch overview data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/api/analytics/platform-distribution/route.ts
Normal file
28
app/api/analytics/platform-distribution/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlatformDistribution } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 获取平台分布数据
|
||||
const distributionData = await getPlatformDistribution(
|
||||
startDate,
|
||||
endDate,
|
||||
linkId
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(distributionData);
|
||||
} catch (error) {
|
||||
console.error('Error in platform-distribution API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch platform distribution data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/analytics/popular-links/route.ts
Normal file
32
app/api/analytics/popular-links/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPopularLinks } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('projectId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
const sortBy = searchParams.get('sortBy') as 'visits' | 'uniqueVisitors' | 'conversionRate' || 'visits';
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit') as string, 10) : 10;
|
||||
|
||||
// 获取热门链接数据
|
||||
const linksData = await getPopularLinks(
|
||||
startDate,
|
||||
endDate,
|
||||
projectId,
|
||||
sortBy,
|
||||
limit
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(linksData);
|
||||
} catch (error) {
|
||||
console.error('Error in popular-links API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch popular links data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/analytics/popular-referrers/route.ts
Normal file
32
app/api/analytics/popular-referrers/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPopularReferrers } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
const type = searchParams.get('type') as 'domain' | 'full' || 'domain';
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit') as string, 10) : 10;
|
||||
|
||||
// 获取热门引荐来源数据
|
||||
const referrersData = await getPopularReferrers(
|
||||
startDate,
|
||||
endDate,
|
||||
linkId,
|
||||
type,
|
||||
limit
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(referrersData);
|
||||
} catch (error) {
|
||||
console.error('Error in popular-referrers API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch popular referrers data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/api/analytics/qr-code-analysis/route.ts
Normal file
30
app/api/analytics/qr-code-analysis/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getQrCodeAnalysis } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId') || undefined;
|
||||
const qrCodeId = searchParams.get('qrCodeId') || undefined;
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
|
||||
// 获取QR码分析数据
|
||||
const analysisData = await getQrCodeAnalysis(
|
||||
startDate,
|
||||
endDate,
|
||||
linkId,
|
||||
qrCodeId
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(analysisData);
|
||||
} catch (error) {
|
||||
console.error('Error in qr-code-analysis API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch QR code analysis data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
app/api/analytics/track/route.ts
Normal file
68
app/api/analytics/track/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { trackEvent, EventType, ConversionType } from '@/lib/analytics';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 解析请求体
|
||||
const body = await request.json();
|
||||
|
||||
// 验证必要字段
|
||||
if (!body.linkId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required field: linkId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.eventType || !Object.values(EventType).includes(body.eventType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid or missing eventType',
|
||||
validValues: Object.values(EventType)
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证转化类型(如果提供)
|
||||
if (
|
||||
body.conversionType &&
|
||||
!Object.values(ConversionType).includes(body.conversionType)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid conversionType',
|
||||
validValues: Object.values(ConversionType)
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 添加客户端IP
|
||||
const clientIp = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'0.0.0.0';
|
||||
|
||||
// 添加用户代理
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
|
||||
// 合并数据
|
||||
const eventData = {
|
||||
...body,
|
||||
ipAddress: body.ipAddress || clientIp,
|
||||
userAgent: body.userAgent || userAgent,
|
||||
};
|
||||
|
||||
// 追踪事件
|
||||
const result = await trackEvent(eventData);
|
||||
|
||||
// 返回结果
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error in track API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to track event' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/analytics/trends/route.ts
Normal file
50
app/api/analytics/trends/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getVisitTrends, TimeGranularity } from '@/lib/analytics';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 获取请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('linkId');
|
||||
const startDate = searchParams.get('startDate') || undefined;
|
||||
const endDate = searchParams.get('endDate') || undefined;
|
||||
const granularity = searchParams.get('granularity') as TimeGranularity || TimeGranularity.DAY;
|
||||
|
||||
// 验证必要参数
|
||||
if (!linkId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameter: linkId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证粒度参数
|
||||
const validGranularities = Object.values(TimeGranularity);
|
||||
if (granularity && !validGranularities.includes(granularity)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid granularity value',
|
||||
validValues: validGranularities
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取访问趋势数据
|
||||
const trendsData = await getVisitTrends(
|
||||
linkId,
|
||||
startDate || undefined,
|
||||
endDate || undefined,
|
||||
granularity
|
||||
);
|
||||
|
||||
// 返回数据
|
||||
return NextResponse.json(trendsData);
|
||||
} catch (error) {
|
||||
console.error('Error in trends API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch trends data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/api/links/[linkId]/details/route.ts
Normal file
28
app/api/links/[linkId]/details/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinkDetailsById } from '@/app/api/links/service';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: { linkId: string } }
|
||||
) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
const linkId = params.linkId;
|
||||
const link = await getLinkDetailsById(linkId);
|
||||
|
||||
if (!link) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Link not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(link);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch link details:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch link details', message: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
app/api/links/[linkId]/route.ts
Normal file
27
app/api/links/[linkId]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinkById } from '../service';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { linkId: string } }
|
||||
) {
|
||||
try {
|
||||
const { linkId } = params;
|
||||
const link = await getLinkById(linkId);
|
||||
|
||||
if (!link) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Link not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(link);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch link details:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch link details', message: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
157
app/api/links/repository.ts
Normal file
157
app/api/links/repository.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { executeQuery, executeQuerySingle } from '@/lib/clickhouse';
|
||||
import { Link, LinkQueryParams } from '../types';
|
||||
|
||||
/**
|
||||
* Find links with filtering options
|
||||
*/
|
||||
export async function findLinks({
|
||||
limit = 10,
|
||||
offset = 0,
|
||||
searchTerm = '',
|
||||
tagFilter = '',
|
||||
isActive = null,
|
||||
}: LinkQueryParams) {
|
||||
// Build WHERE conditions
|
||||
const conditions = [];
|
||||
|
||||
if (searchTerm) {
|
||||
conditions.push(`
|
||||
(lower(title) LIKE lower('%${searchTerm}%') OR
|
||||
lower(original_url) LIKE lower('%${searchTerm}%'))
|
||||
`);
|
||||
}
|
||||
|
||||
if (tagFilter) {
|
||||
conditions.push(`hasAny(tags, ['${tagFilter}'])`);
|
||||
}
|
||||
|
||||
if (isActive !== null) {
|
||||
conditions.push(`is_active = ${isActive ? 'true' : 'false'}`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0
|
||||
? `WHERE ${conditions.join(' AND ')}`
|
||||
: '';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `
|
||||
SELECT count() as total
|
||||
FROM links
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const countData = await executeQuery<{ total: number }>(countQuery);
|
||||
const total = countData.length > 0 ? countData[0].total : 0;
|
||||
|
||||
// 使用左连接获取链接数据和统计信息
|
||||
const linksQuery = `
|
||||
SELECT
|
||||
l.link_id,
|
||||
l.original_url,
|
||||
l.created_at,
|
||||
l.created_by,
|
||||
l.title,
|
||||
l.description,
|
||||
l.tags,
|
||||
l.is_active,
|
||||
l.expires_at,
|
||||
l.team_id,
|
||||
l.project_id,
|
||||
count(le.event_id) as visits,
|
||||
count(DISTINCT le.visitor_id) as unique_visits
|
||||
FROM links l
|
||||
LEFT JOIN link_events le ON l.link_id = le.link_id
|
||||
${whereClause}
|
||||
GROUP BY
|
||||
l.link_id,
|
||||
l.original_url,
|
||||
l.created_at,
|
||||
l.created_by,
|
||||
l.title,
|
||||
l.description,
|
||||
l.tags,
|
||||
l.is_active,
|
||||
l.expires_at,
|
||||
l.team_id,
|
||||
l.project_id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const links = await executeQuery<Link>(linksQuery);
|
||||
|
||||
return {
|
||||
links,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single link by ID
|
||||
*/
|
||||
export async function findLinkById(linkId: string): Promise<Link | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
l.link_id,
|
||||
l.original_url,
|
||||
l.created_at,
|
||||
l.created_by,
|
||||
l.title,
|
||||
l.description,
|
||||
l.tags,
|
||||
l.is_active,
|
||||
l.expires_at,
|
||||
l.team_id,
|
||||
l.project_id,
|
||||
count(le.event_id) as visits,
|
||||
count(DISTINCT le.visitor_id) as unique_visits
|
||||
FROM links l
|
||||
LEFT JOIN link_events le ON l.link_id = le.link_id
|
||||
WHERE l.link_id = '${linkId}'
|
||||
GROUP BY
|
||||
l.link_id,
|
||||
l.original_url,
|
||||
l.created_at,
|
||||
l.created_by,
|
||||
l.title,
|
||||
l.description,
|
||||
l.tags,
|
||||
l.is_active,
|
||||
l.expires_at,
|
||||
l.team_id,
|
||||
l.project_id
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return await executeQuerySingle<Link>(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single link by ID - only basic info without statistics
|
||||
*/
|
||||
export async function findLinkDetailsById(linkId: string): Promise<Omit<Link, 'visits' | 'unique_visits'> | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
link_id,
|
||||
original_url,
|
||||
created_at,
|
||||
created_by,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
is_active,
|
||||
expires_at,
|
||||
team_id,
|
||||
project_id
|
||||
FROM links
|
||||
WHERE link_id = '${linkId}'
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return await executeQuerySingle<Omit<Link, 'visits' | 'unique_visits'>>(query);
|
||||
}
|
||||
32
app/api/links/route.ts
Normal file
32
app/api/links/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { LinkQueryParams } from '../types';
|
||||
import { getLinks } from './service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// Parse request parameters
|
||||
const params: LinkQueryParams = {
|
||||
limit: searchParams.has('limit') ? Number(searchParams.get('limit')) : 10,
|
||||
page: searchParams.has('page') ? Number(searchParams.get('page')) : 1,
|
||||
searchTerm: searchParams.get('search') || '',
|
||||
tagFilter: searchParams.get('tag') || '',
|
||||
};
|
||||
|
||||
// Handle active status filter
|
||||
const activeFilter = searchParams.get('active');
|
||||
if (activeFilter === 'true') params.isActive = true;
|
||||
if (activeFilter === 'false') params.isActive = false;
|
||||
|
||||
// Get link data
|
||||
const result = await getLinks(params);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch links:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch links', message: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/api/links/service.ts
Normal file
42
app/api/links/service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link, LinkQueryParams, PaginatedResponse } from '../types';
|
||||
import { findLinkById, findLinkDetailsById, findLinks } from './repository';
|
||||
|
||||
/**
|
||||
* Get links with pagination information
|
||||
*/
|
||||
export async function getLinks(params: LinkQueryParams): Promise<PaginatedResponse<Link>> {
|
||||
// Convert page number to offset
|
||||
const { page, limit = 10, ...otherParams } = params;
|
||||
const offset = page ? (page - 1) * limit : params.offset || 0;
|
||||
|
||||
const result = await findLinks({
|
||||
...otherParams,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.links,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
page: result.page,
|
||||
totalPages: result.totalPages
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single link by ID with full details (including statistics)
|
||||
*/
|
||||
export async function getLinkById(linkId: string): Promise<Link | null> {
|
||||
return await findLinkById(linkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single link by ID - only basic info without statistics
|
||||
*/
|
||||
export async function getLinkDetailsById(linkId: string): Promise<Omit<Link, 'visits' | 'unique_visits'> | null> {
|
||||
return await findLinkDetailsById(linkId);
|
||||
}
|
||||
21
app/api/stats/repository.ts
Normal file
21
app/api/stats/repository.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { executeQuerySingle } from '@/lib/clickhouse';
|
||||
import { StatsOverview } from '../types';
|
||||
|
||||
/**
|
||||
* Get overview statistics for links
|
||||
*/
|
||||
export async function findStatsOverview(): Promise<StatsOverview | null> {
|
||||
const query = `
|
||||
WITH
|
||||
toUInt64(count()) as total_links,
|
||||
toUInt64(countIf(is_active = true)) as active_links
|
||||
FROM links
|
||||
SELECT
|
||||
total_links as totalLinks,
|
||||
active_links as activeLinks,
|
||||
(SELECT count() FROM link_events) as totalVisits,
|
||||
(SELECT count() FROM link_events) / NULLIF(total_links, 0) as conversionRate
|
||||
`;
|
||||
|
||||
return await executeQuerySingle<StatsOverview>(query);
|
||||
}
|
||||
15
app/api/stats/route.ts
Normal file
15
app/api/stats/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getStatsOverview } from './service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = await getStatsOverview();
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error('获取统计概览失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取统计概览失败', message: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/api/stats/service.ts
Normal file
21
app/api/stats/service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { StatsOverview } from '../types';
|
||||
import { findStatsOverview } from './repository';
|
||||
|
||||
/**
|
||||
* Get link statistics overview
|
||||
*/
|
||||
export async function getStatsOverview(): Promise<StatsOverview> {
|
||||
const stats = await findStatsOverview();
|
||||
|
||||
// Return default values if no data
|
||||
if (!stats) {
|
||||
return {
|
||||
totalLinks: 0,
|
||||
activeLinks: 0,
|
||||
totalVisits: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
19
app/api/tags/repository.ts
Normal file
19
app/api/tags/repository.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
import { Tag } from '../types';
|
||||
|
||||
/**
|
||||
* Get all tags with usage counts
|
||||
*/
|
||||
export async function findAllTags(): Promise<Tag[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
tag,
|
||||
count() as count
|
||||
FROM links
|
||||
ARRAY JOIN tags as tag
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
|
||||
return await executeQuery<Tag>(query);
|
||||
}
|
||||
15
app/api/tags/route.ts
Normal file
15
app/api/tags/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAllTags } from './service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const tags = await getAllTags();
|
||||
return NextResponse.json(tags);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch tags', message: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
9
app/api/tags/service.ts
Normal file
9
app/api/tags/service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Tag } from '../types';
|
||||
import { findAllTags } from './repository';
|
||||
|
||||
/**
|
||||
* Get all available tags
|
||||
*/
|
||||
export async function getAllTags(): Promise<Tag[]> {
|
||||
return await findAllTags();
|
||||
}
|
||||
221
app/api/types.ts
Normal file
221
app/api/types.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// 链接数据类型
|
||||
export interface Link {
|
||||
link_id: string;
|
||||
original_url: string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
is_active: boolean;
|
||||
expires_at: string | null;
|
||||
team_id: string;
|
||||
project_id: string;
|
||||
visits: number;
|
||||
unique_visits: number;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
}
|
||||
|
||||
// 链接查询参数
|
||||
export interface LinkQueryParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
page?: number;
|
||||
searchTerm?: string;
|
||||
tagFilter?: string;
|
||||
isActive?: boolean | null;
|
||||
}
|
||||
|
||||
// 标签类型
|
||||
export interface Tag {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// 统计概览类型
|
||||
export interface StatsOverview {
|
||||
totalLinks: number;
|
||||
activeLinks: number;
|
||||
totalVisits: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
// Analytics数据类型
|
||||
export interface LinkOverviewData {
|
||||
totalVisits: number;
|
||||
uniqueVisitors: number;
|
||||
averageTimeSpent: number;
|
||||
bounceCount: number;
|
||||
conversionCount: number;
|
||||
uniqueReferrers: number;
|
||||
deviceTypes: {
|
||||
mobile: number;
|
||||
tablet: number;
|
||||
desktop: number;
|
||||
other: number;
|
||||
};
|
||||
qrScanCount: number;
|
||||
totalConversionValue: number;
|
||||
}
|
||||
|
||||
export interface FunnelStep {
|
||||
name: string;
|
||||
value: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface ConversionFunnelData {
|
||||
steps: FunnelStep[];
|
||||
totalConversions: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
timestamp: string;
|
||||
visits: number;
|
||||
uniqueVisitors: number;
|
||||
}
|
||||
|
||||
export interface VisitTrendsData {
|
||||
trends: TrendPoint[];
|
||||
totals: {
|
||||
visits: number;
|
||||
uniqueVisitors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrackEventRequest {
|
||||
linkId: string;
|
||||
eventType: string;
|
||||
visitorId?: string;
|
||||
sessionId?: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
timeSpent?: number;
|
||||
conversionType?: string;
|
||||
conversionValue?: number;
|
||||
customData?: Record<string, unknown>;
|
||||
isQrScan?: boolean;
|
||||
qrCodeId?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
export interface TrackEventResponse {
|
||||
success: boolean;
|
||||
eventId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 链接表现数据
|
||||
export interface LinkPerformanceData {
|
||||
totalClicks: number;
|
||||
uniqueVisitors: number;
|
||||
averageTimeSpent: number;
|
||||
bounceRate: number;
|
||||
uniqueReferrers: number;
|
||||
conversionRate: number;
|
||||
activeDays: number;
|
||||
lastClickTime: string | null;
|
||||
deviceDistribution: {
|
||||
mobile: number;
|
||||
desktop: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 平台分布数据
|
||||
export interface PlatformItem {
|
||||
name: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface PlatformDistributionData {
|
||||
totalVisits: number;
|
||||
platforms: PlatformItem[];
|
||||
browsers: PlatformItem[];
|
||||
}
|
||||
|
||||
// 设备分析数据
|
||||
export interface DeviceItem {
|
||||
name: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface DeviceModelItem {
|
||||
type: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface DeviceAnalysisData {
|
||||
totalVisits: number;
|
||||
deviceTypes: DeviceItem[];
|
||||
deviceBrands: DeviceItem[];
|
||||
deviceModels: DeviceModelItem[];
|
||||
}
|
||||
|
||||
// 热门引荐来源数据
|
||||
export interface ReferrerItem {
|
||||
source: string;
|
||||
visitCount: number;
|
||||
uniqueVisitors: number;
|
||||
conversionCount: number;
|
||||
conversionRate: number;
|
||||
averageTimeSpent: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface PopularReferrersData {
|
||||
referrers: ReferrerItem[];
|
||||
totalVisits: number;
|
||||
}
|
||||
|
||||
// QR码分析数据
|
||||
export interface LocationItem {
|
||||
city: string;
|
||||
country: string;
|
||||
scanCount: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface DeviceDistributionItem {
|
||||
type: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface HourlyDistributionItem {
|
||||
hour: number;
|
||||
scanCount: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface QrCodeAnalysisData {
|
||||
overview: {
|
||||
totalScans: number;
|
||||
uniqueScanners: number;
|
||||
conversionCount: number;
|
||||
conversionRate: number;
|
||||
averageTimeSpent: number;
|
||||
};
|
||||
locations: LocationItem[];
|
||||
deviceDistribution: DeviceDistributionItem[];
|
||||
hourlyDistribution: HourlyDistributionItem[];
|
||||
}
|
||||
46
app/components/charts/ChartPlaceholder.tsx
Normal file
46
app/components/charts/ChartPlaceholder.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
interface ChartPlaceholderProps {
|
||||
text: string;
|
||||
height?: string;
|
||||
colorScheme?: 'blue' | 'green' | 'red' | 'purple' | 'teal' | 'orange' | 'pink' | 'yellow';
|
||||
}
|
||||
|
||||
export default function ChartPlaceholder({
|
||||
text,
|
||||
height = "h-64",
|
||||
colorScheme = 'blue'
|
||||
}: ChartPlaceholderProps) {
|
||||
const borderColor = {
|
||||
blue: 'border-accent-blue',
|
||||
green: 'border-accent-green',
|
||||
red: 'border-accent-red',
|
||||
purple: 'border-accent-purple',
|
||||
teal: 'border-accent-teal',
|
||||
orange: 'border-accent-orange',
|
||||
pink: 'border-accent-pink',
|
||||
yellow: 'border-accent-yellow',
|
||||
}[colorScheme];
|
||||
|
||||
const textColor = {
|
||||
blue: 'text-accent-blue',
|
||||
green: 'text-accent-green',
|
||||
red: 'text-accent-red',
|
||||
purple: 'text-accent-purple',
|
||||
teal: 'text-accent-teal',
|
||||
orange: 'text-accent-orange',
|
||||
pink: 'text-accent-pink',
|
||||
yellow: 'text-accent-yellow',
|
||||
}[colorScheme];
|
||||
|
||||
return (
|
||||
<div className={`${height} flex items-center justify-center bg-card-bg bg-opacity-50 rounded-md border-2 border-dashed ${borderColor}`}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-12 h-12 mb-3 rounded-full bg-background flex items-center justify-center">
|
||||
<svg className={`w-6 h-6 ${textColor}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`${textColor} text-sm font-medium`}>{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
app/components/dashboard/LinkDetailsCard.tsx
Normal file
432
app/components/dashboard/LinkDetailsCard.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LinkDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
shortUrl: string;
|
||||
originalUrl: string;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
visits: number;
|
||||
visitChange: number;
|
||||
uniqueVisitors: number;
|
||||
uniqueVisitorsChange: number;
|
||||
avgTime: string;
|
||||
avgTimeChange: number;
|
||||
conversionRate: number;
|
||||
conversionChange: number;
|
||||
status: 'active' | 'inactive' | 'expired';
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface LinkDetailsCardProps {
|
||||
linkId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function LinkDetailsCard({ linkId, onClose }: LinkDetailsCardProps) {
|
||||
const [linkDetails, setLinkDetails] = useState<LinkDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'referrers' | 'devices' | 'locations'>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
if (linkId) {
|
||||
// Simulate API call to fetch link details
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
// In a real app, this would be an API call like:
|
||||
// const response = await fetch(`/api/links/${linkId}`);
|
||||
// const data = await response.json();
|
||||
|
||||
// For demo, using mock data
|
||||
setTimeout(() => {
|
||||
setLinkDetails({
|
||||
id: linkId,
|
||||
name: "Product Launch Campaign",
|
||||
shortUrl: "short.io/prlaunch",
|
||||
originalUrl: "https://example.com/products/new-product-launch-summer-2023",
|
||||
creator: "Sarah Johnson",
|
||||
createdAt: "2023-05-15",
|
||||
visits: 3240,
|
||||
visitChange: 12.5,
|
||||
uniqueVisitors: 2180,
|
||||
uniqueVisitorsChange: 8.3,
|
||||
avgTime: "2m 45s",
|
||||
avgTimeChange: -5.2,
|
||||
conversionRate: 4.8,
|
||||
conversionChange: 1.2,
|
||||
status: 'active',
|
||||
tags: ["marketing", "product", "summer"]
|
||||
});
|
||||
setLoading(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}
|
||||
}, [linkId]);
|
||||
|
||||
if (!linkId) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="inline-block w-full max-w-4xl overflow-hidden text-left align-middle transition-all transform bg-card-bg rounded-xl shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-card-border">
|
||||
<div className="flex items-center">
|
||||
{loading ? (
|
||||
<div className="w-52 h-7 bg-progress-bg animate-pulse rounded"></div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-accent-blue/20 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6 text-accent-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 10-5.656-5.656l-1.102 1.101" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-foreground">
|
||||
{linkDetails?.name}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-text-secondary hover:text-foreground rounded-md focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg className="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 flex flex-col space-y-4 items-center justify-center">
|
||||
<div className="w-12 h-12 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
|
||||
<p className="text-text-secondary">Loading link details...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Link Info */}
|
||||
<div className="p-6 border-b border-card-border grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="col-span-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary uppercase">Short URL</span>
|
||||
<div className="flex items-center mt-1">
|
||||
<a
|
||||
href={linkDetails ? `https://${linkDetails.shortUrl}` : '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-blue hover:underline font-medium break-all"
|
||||
>
|
||||
{linkDetails?.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-text-secondary hover:text-foreground"
|
||||
onClick={() => linkDetails && navigator.clipboard.writeText(`https://${linkDetails.shortUrl}`)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-xs font-medium text-text-secondary uppercase">Original URL</span>
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href={linkDetails?.originalUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline break-all"
|
||||
>
|
||||
{linkDetails?.originalUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary uppercase">Created By</span>
|
||||
<p className="mt-1 text-foreground">{linkDetails?.creator}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary uppercase">Created At</span>
|
||||
<p className="mt-1 text-foreground">{linkDetails?.createdAt}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary uppercase">Status</span>
|
||||
<div className="mt-1">
|
||||
{linkDetails && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${linkDetails.status === 'active' ? 'bg-green-500/10 text-accent-green' :
|
||||
linkDetails.status === 'inactive' ? 'bg-gray-500/10 text-text-secondary' :
|
||||
'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
{linkDetails.status.charAt(0).toUpperCase() + linkDetails.status.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{linkDetails?.tags && linkDetails.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-text-secondary uppercase">Tags</span>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{linkDetails.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-500/10 rounded-full text-accent-blue"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Overview */}
|
||||
{linkDetails && (
|
||||
<div className="p-6 border-b border-card-border">
|
||||
<h4 className="text-lg font-medium text-foreground mb-4">Performance Metrics</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Visits */}
|
||||
<div className="bg-card-bg p-4 rounded-lg border border-card-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-text-secondary">Total Visits</h5>
|
||||
<span
|
||||
className={`inline-flex items-center text-xs font-medium rounded-full px-2 py-0.5
|
||||
${linkDetails.visitChange >= 0
|
||||
? 'bg-green-500/10 text-accent-green'
|
||||
: 'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${linkDetails.visitChange >= 0 ? 'text-accent-green' : 'text-accent-red transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{Math.abs(linkDetails.visitChange)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-bold text-foreground">{linkDetails.visits.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unique Visitors */}
|
||||
<div className="bg-card-bg p-4 rounded-lg border border-card-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-text-secondary">Unique Visitors</h5>
|
||||
<span
|
||||
className={`inline-flex items-center text-xs font-medium rounded-full px-2 py-0.5
|
||||
${linkDetails.uniqueVisitorsChange >= 0
|
||||
? 'bg-green-500/10 text-accent-green'
|
||||
: 'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${linkDetails.uniqueVisitorsChange >= 0 ? 'text-accent-green' : 'text-accent-red transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{Math.abs(linkDetails.uniqueVisitorsChange)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-bold text-foreground">{linkDetails.uniqueVisitors.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average Time */}
|
||||
<div className="bg-card-bg p-4 rounded-lg border border-card-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-text-secondary">Average Time</h5>
|
||||
<span
|
||||
className={`inline-flex items-center text-xs font-medium rounded-full px-2 py-0.5
|
||||
${linkDetails.avgTimeChange >= 0
|
||||
? 'bg-green-500/10 text-accent-green'
|
||||
: 'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${linkDetails.avgTimeChange >= 0 ? 'text-accent-green' : 'text-accent-red transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{Math.abs(linkDetails.avgTimeChange)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-bold text-foreground">{linkDetails.avgTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversion Rate */}
|
||||
<div className="bg-card-bg p-4 rounded-lg border border-card-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-text-secondary">Conversion Rate</h5>
|
||||
<span
|
||||
className={`inline-flex items-center text-xs font-medium rounded-full px-2 py-0.5
|
||||
${linkDetails.conversionChange >= 0
|
||||
? 'bg-green-500/10 text-accent-green'
|
||||
: 'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${linkDetails.conversionChange >= 0 ? 'text-accent-green' : 'text-accent-red transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{Math.abs(linkDetails.conversionChange)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-bold text-foreground">{linkDetails.conversionRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-card-border">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-4 px-6 font-medium text-sm border-b-2 ${
|
||||
activeTab === 'overview'
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground hover:border-card-border'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('referrers')}
|
||||
className={`py-4 px-6 font-medium text-sm border-b-2 ${
|
||||
activeTab === 'referrers'
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground hover:border-card-border'
|
||||
}`}
|
||||
>
|
||||
Referrers
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('devices')}
|
||||
className={`py-4 px-6 font-medium text-sm border-b-2 ${
|
||||
activeTab === 'devices'
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground hover:border-card-border'
|
||||
}`}
|
||||
>
|
||||
Devices
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('locations')}
|
||||
className={`py-4 px-6 font-medium text-sm border-b-2 ${
|
||||
activeTab === 'locations'
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
: 'border-transparent text-text-secondary hover:text-foreground hover:border-card-border'
|
||||
}`}
|
||||
>
|
||||
Locations
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">No chart data available</h3>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Charts and detailed analytics would appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'referrers' && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">No referrer data available</h3>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Information about traffic sources would appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'devices' && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">No device data available</h3>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Breakdown of devices used to access the link would appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'locations' && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">No location data available</h3>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Geographic distribution of visitors would appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 bg-card-bg flex justify-end border-t border-card-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary bg-card-bg border border-card-border rounded-md shadow-sm hover:bg-card-bg/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-blue"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
app/components/dashboard/StatsCard.tsx
Normal file
72
app/components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change: number;
|
||||
unit?: string;
|
||||
colorScheme?: 'blue' | 'green' | 'red' | 'purple';
|
||||
}
|
||||
|
||||
export default function StatsCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
unit = '',
|
||||
colorScheme = 'blue'
|
||||
}: StatsCardProps) {
|
||||
const isPositive = change >= 0;
|
||||
const hasPercentUnit = unit === '%';
|
||||
|
||||
// Color mappings based on the colorScheme
|
||||
const gradientBg = {
|
||||
blue: 'bg-gradient-blue',
|
||||
green: 'bg-gradient-green',
|
||||
red: 'bg-gradient-red',
|
||||
purple: 'bg-gradient-purple',
|
||||
}[colorScheme];
|
||||
|
||||
const accentColor = {
|
||||
blue: 'text-accent-blue',
|
||||
green: 'text-accent-green',
|
||||
red: 'text-accent-red',
|
||||
purple: 'text-accent-purple',
|
||||
}[colorScheme];
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-card-border rounded-lg overflow-hidden">
|
||||
{/* Colorful top bar */}
|
||||
<div className={`h-1 ${gradientBg}`}></div>
|
||||
|
||||
<div className="p-5">
|
||||
<h2 className="text-text-secondary text-sm font-medium mb-2">{title}</h2>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-baseline">
|
||||
<p className={`text-3xl font-bold ${accentColor}`}>
|
||||
{value}
|
||||
{hasPercentUnit && <span className={accentColor}>%</span>}
|
||||
</p>
|
||||
|
||||
<div className={`ml-3 px-2 py-0.5 rounded-md text-sm font-medium flex items-center ${
|
||||
isPositive ? 'text-accent-green' : 'text-accent-red'
|
||||
}`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full mr-1 ${
|
||||
isPositive ? 'bg-accent-green' : 'bg-accent-red'
|
||||
}`}></span>
|
||||
{isPositive ? '+' : ''}{change}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual indicator for percentages */}
|
||||
{hasPercentUnit && (
|
||||
<div className="mt-3 w-full bg-progress-bg h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`${gradientBg} h-full rounded-full`}
|
||||
style={{ width: `${Math.min(Number(value), 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
app/components/layout/Navbar.tsx
Normal file
67
app/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import ThemeToggle from "../ui/ThemeToggle";
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<header className="w-full py-4 border-b border-card-border bg-background">
|
||||
<div className="container flex items-center justify-between px-4 mx-auto">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-6 h-6 text-accent-blue"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
<span className="text-xl font-bold text-foreground">ShortURL</span>
|
||||
</Link>
|
||||
<nav className="hidden space-x-4 md:flex">
|
||||
<Link
|
||||
href="/links"
|
||||
className="text-sm text-foreground hover:text-accent-blue transition-colors"
|
||||
>
|
||||
Links
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="text-sm text-foreground hover:text-accent-blue transition-colors"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ThemeToggle />
|
||||
<button className="p-2 text-sm text-foreground rounded-md gradient-border">
|
||||
Upgrade
|
||||
</button>
|
||||
<button className="p-2 text-sm text-foreground hover:text-accent-blue">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
<path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
57
app/components/ui/Card.tsx
Normal file
57
app/components/ui/Card.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
colorScheme?: 'blue' | 'green' | 'red' | 'purple' | 'teal' | 'orange' | 'pink' | 'yellow' | 'none';
|
||||
glowEffect?: boolean;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
colorScheme = 'none',
|
||||
glowEffect = false
|
||||
}: CardProps) {
|
||||
// Only add color-specific classes if a colorScheme is specified
|
||||
const headerColor = colorScheme !== 'none' ? {
|
||||
blue: 'text-accent-blue',
|
||||
green: 'text-accent-green',
|
||||
red: 'text-accent-red',
|
||||
purple: 'text-accent-purple',
|
||||
teal: 'text-accent-teal',
|
||||
orange: 'text-accent-orange',
|
||||
pink: 'text-accent-pink',
|
||||
yellow: 'text-accent-yellow',
|
||||
}[colorScheme] : 'text-foreground';
|
||||
|
||||
const glowClass = glowEffect && colorScheme !== 'none' ? {
|
||||
blue: 'shadow-[0_0_15px_rgba(59,130,246,0.15)]',
|
||||
green: 'shadow-[0_0_15px_rgba(16,185,129,0.15)]',
|
||||
red: 'shadow-[0_0_15px_rgba(244,63,94,0.15)]',
|
||||
purple: 'shadow-[0_0_15px_rgba(139,92,246,0.15)]',
|
||||
teal: 'shadow-[0_0_15px_rgba(20,184,166,0.15)]',
|
||||
orange: 'shadow-[0_0_15px_rgba(249,115,22,0.15)]',
|
||||
pink: 'shadow-[0_0_15px_rgba(236,72,153,0.15)]',
|
||||
yellow: 'shadow-[0_0_15px_rgba(245,158,11,0.15)]',
|
||||
}[colorScheme] : '';
|
||||
|
||||
// Define the indicator dot color
|
||||
const indicatorColor = colorScheme !== 'none' ? `bg-accent-${colorScheme}` : 'bg-gray-500';
|
||||
|
||||
return (
|
||||
<div className={`bg-card-bg border border-card-border rounded-lg ${glowClass} ${className}`}>
|
||||
<div className="flex items-center border-b border-card-border p-5 pb-4">
|
||||
<h2 className={`text-lg font-medium ${headerColor}`}>{title}</h2>
|
||||
{colorScheme !== 'none' && (
|
||||
<div className={`ml-2 h-1.5 w-1.5 rounded-full ${indicatorColor}`}></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 pt-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
app/components/ui/CreateLinkModal.tsx
Normal file
242
app/components/ui/CreateLinkModal.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LinkData {
|
||||
name: string;
|
||||
originalUrl: string;
|
||||
customSlug: string;
|
||||
expiresAt: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface CreateLinkModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (linkData: LinkData) => void;
|
||||
}
|
||||
|
||||
export default function CreateLinkModal({ onClose, onSubmit }: CreateLinkModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
originalUrl: '',
|
||||
customSlug: '',
|
||||
expiresAt: '',
|
||||
tags: [] as string[],
|
||||
tagInput: ''
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && formData.tagInput.trim()) {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (formData.tagInput.trim() && !formData.tags.includes(formData.tagInput.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, prev.tagInput.trim()],
|
||||
tagInput: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter(tag => tag !== tagToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tagInput, ...submitData } = formData;
|
||||
onSubmit(submitData as LinkData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="inline-block w-full max-w-xl overflow-hidden text-left align-middle transition-all transform bg-card-bg rounded-xl shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-card-border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-accent-blue/20 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6 text-accent-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium leading-6 text-foreground">
|
||||
Create New Link
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-text-secondary rounded-md hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg className="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto max-h-[70vh]">
|
||||
{/* Link Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
||||
Link Name <span className="text-accent-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Product Launch Campaign"
|
||||
className="block w-full px-3 py-2 mt-1 text-foreground bg-card-bg border border-card-border rounded-md shadow-sm focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Original URL */}
|
||||
<div>
|
||||
<label htmlFor="originalUrl" className="block text-sm font-medium text-foreground">
|
||||
Original URL <span className="text-accent-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="originalUrl"
|
||||
name="originalUrl"
|
||||
value={formData.originalUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://example.com/your-long-url"
|
||||
className="block w-full px-3 py-2 mt-1 text-foreground bg-card-bg border border-card-border rounded-md shadow-sm focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Slug */}
|
||||
<div>
|
||||
<label htmlFor="customSlug" className="block text-sm font-medium text-foreground">
|
||||
Custom Slug <span className="text-text-secondary">(Optional)</span>
|
||||
</label>
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 py-2 text-sm text-text-secondary border border-r-0 border-card-border rounded-l-md bg-card-bg/60">
|
||||
short.io/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="customSlug"
|
||||
name="customSlug"
|
||||
value={formData.customSlug}
|
||||
onChange={handleChange}
|
||||
placeholder="custom-slug"
|
||||
className="flex-1 block w-full min-w-0 px-3 py-2 text-foreground bg-card-bg border border-card-border rounded-none rounded-r-md focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Leave blank to generate a random slug
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expiration Date */}
|
||||
<div>
|
||||
<label htmlFor="expiresAt" className="block text-sm font-medium text-foreground">
|
||||
Expiration Date <span className="text-text-secondary">(Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="expiresAt"
|
||||
name="expiresAt"
|
||||
value={formData.expiresAt}
|
||||
onChange={handleChange}
|
||||
className="block w-full px-3 py-2 mt-1 text-foreground bg-card-bg border border-card-border rounded-md shadow-sm focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Leave blank for a non-expiring link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label htmlFor="tagInput" className="block text-sm font-medium text-foreground">
|
||||
Tags <span className="text-text-secondary">(Optional)</span>
|
||||
</label>
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="tagInput"
|
||||
name="tagInput"
|
||||
value={formData.tagInput}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
placeholder="Add tag and press Enter"
|
||||
className="flex-1 block w-full min-w-0 px-3 py-2 text-foreground bg-card-bg border border-card-border rounded-l-md focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-white border border-transparent rounded-r-md shadow-sm bg-accent-blue hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-blue"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.tags.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-500/10 rounded-full text-accent-blue">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="flex-shrink-0 ml-1 text-accent-blue rounded-full hover:text-blue-400 focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Remove tag {tag}</span>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 bg-card-bg flex justify-end space-x-3 border-t border-card-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-foreground bg-card-bg/70 border border-card-border rounded-md shadow-sm hover:bg-card-bg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-blue"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-accent-blue border border-transparent rounded-md shadow-sm hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-blue"
|
||||
>
|
||||
Create Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
app/components/ui/ThemeToggle.tsx
Normal file
64
app/components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
// Initialize theme on component mount
|
||||
useEffect(() => {
|
||||
const isDarkMode = localStorage.getItem('darkMode') === 'true';
|
||||
setDarkMode(isDarkMode);
|
||||
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update theme when darkMode state changes
|
||||
const toggleTheme = () => {
|
||||
const newDarkMode = !darkMode;
|
||||
setDarkMode(newDarkMode);
|
||||
localStorage.setItem('darkMode', newDarkMode.toString());
|
||||
|
||||
if (newDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-md bg-card-bg border border-card-border hover:bg-card-bg/80 transition-colors"
|
||||
aria-label={darkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{darkMode ? (
|
||||
<svg
|
||||
className="w-5 h-5 text-accent-yellow"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 text-foreground"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
90
app/globals.css
Normal file
90
app/globals.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* Light Mode - Default */
|
||||
--background: #f8fafc;
|
||||
--foreground: #0f172a;
|
||||
|
||||
/* Card colors */
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e2e8f0;
|
||||
|
||||
/* Vibrant accent colors */
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-green: #10b981;
|
||||
--accent-red: #f43f5e;
|
||||
--accent-yellow: #f59e0b;
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-pink: #ec4899;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-orange: #f97316;
|
||||
|
||||
/* UI colors */
|
||||
--text-secondary: #64748b;
|
||||
--progress-bg: #e2e8f0;
|
||||
|
||||
/* Gradient colors */
|
||||
--gradient-blue: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
--gradient-purple: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||
--gradient-green: linear-gradient(135deg, #10b981, #059669);
|
||||
--gradient-red: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark Mode */
|
||||
--background: #0f172a;
|
||||
--foreground: #ffffff;
|
||||
|
||||
/* Card colors */
|
||||
--card-bg: #1e293b;
|
||||
--card-border: #334155;
|
||||
|
||||
/* Vibrant accent colors */
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-green: #10b981;
|
||||
--accent-red: #f43f5e;
|
||||
--accent-yellow: #f59e0b;
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-pink: #ec4899;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-orange: #f97316;
|
||||
|
||||
/* UI colors */
|
||||
--text-secondary: #94a3b8;
|
||||
--progress-bg: #334155;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
}
|
||||
|
||||
/* Colorful gradient borders */
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2px;
|
||||
background: linear-gradient(45deg, var(--accent-blue), var(--accent-purple), var(--accent-pink));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
38
app/layout.tsx
Normal file
38
app/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Navbar from "./components/layout/Navbar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ShortURL Analytics',
|
||||
description: 'Analytics dashboard for short URL management',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}
|
||||
>
|
||||
<Navbar />
|
||||
<main className="min-h-screen px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
21
app/layouts.tsx
Normal file
21
app/layouts.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Link Management & Analytics',
|
||||
description: 'Track and analyze shortened links',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
1354
app/links/[id]/page.tsx
Normal file
1354
app/links/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
473
app/links/page.tsx
Normal file
473
app/links/page.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import CreateLinkModal from '../components/ui/CreateLinkModal';
|
||||
import { Link, StatsOverview, Tag } from '../api/types';
|
||||
|
||||
// Define type for link data
|
||||
interface LinkData {
|
||||
name: string;
|
||||
originalUrl: string;
|
||||
customSlug: string;
|
||||
expiresAt: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 映射API数据到UI所需格式
|
||||
interface UILink {
|
||||
id: string;
|
||||
name: string;
|
||||
shortUrl: string;
|
||||
originalUrl: string;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
visits: number;
|
||||
visitChange: number;
|
||||
uniqueVisitors: number;
|
||||
uniqueVisitorsChange: number;
|
||||
avgTime: string;
|
||||
avgTimeChange: number;
|
||||
conversionRate: number;
|
||||
conversionChange: number;
|
||||
status: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function LinksPage() {
|
||||
const [links, setLinks] = useState<UILink[]>([]);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const [stats, setStats] = useState<StatsOverview>({
|
||||
totalLinks: 0,
|
||||
activeLinks: 0,
|
||||
totalVisits: 0,
|
||||
conversionRate: 0
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 映射API数据到UI所需格式的函数
|
||||
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
|
||||
// 生成短URL显示 - 因为数据库中没有short_url字段
|
||||
const shortUrlDisplay = generateShortUrlDisplay(apiLink.link_id, apiLink.original_url);
|
||||
|
||||
return {
|
||||
id: apiLink.link_id,
|
||||
name: apiLink.title || 'Untitled Link',
|
||||
shortUrl: shortUrlDisplay,
|
||||
originalUrl: apiLink.original_url,
|
||||
creator: apiLink.created_by,
|
||||
createdAt: new Date(apiLink.created_at).toLocaleDateString(),
|
||||
visits: apiLink.visits,
|
||||
visitChange: 0, // API doesn't provide change data yet
|
||||
uniqueVisitors: apiLink.unique_visits,
|
||||
uniqueVisitorsChange: 0,
|
||||
avgTime: '0m 0s', // API doesn't provide average time yet
|
||||
avgTimeChange: 0,
|
||||
conversionRate: 0, // API doesn't provide conversion rate yet
|
||||
conversionChange: 0,
|
||||
status: apiLink.is_active ? 'active' : 'inactive',
|
||||
tags: apiLink.tags || []
|
||||
};
|
||||
};
|
||||
|
||||
// 从link_id和原始URL生成短URL显示
|
||||
const generateShortUrlDisplay = (linkId: string, originalUrl: string): string => {
|
||||
try {
|
||||
// 尝试从原始URL提取域名
|
||||
const urlObj = new URL(originalUrl);
|
||||
const domain = urlObj.hostname.replace('www.', '');
|
||||
|
||||
// 使用link_id的前8个字符作为短代码
|
||||
const shortCode = linkId.substring(0, 8);
|
||||
|
||||
return `${domain}/${shortCode}`;
|
||||
} catch {
|
||||
// 如果URL解析失败,返回一个基于linkId的默认值
|
||||
return `short.link/${linkId.substring(0, 8)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取链接数据
|
||||
useEffect(() => {
|
||||
const fetchLinks = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 获取链接列表
|
||||
const linksResponse = await fetch('/api/links');
|
||||
if (!linksResponse.ok) {
|
||||
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
|
||||
}
|
||||
const linksData = await linksResponse.json();
|
||||
|
||||
// 获取标签列表
|
||||
const tagsResponse = await fetch('/api/tags');
|
||||
if (!tagsResponse.ok) {
|
||||
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
|
||||
}
|
||||
const tagsData = await tagsResponse.json();
|
||||
|
||||
// 获取统计数据
|
||||
const statsResponse = await fetch('/api/stats');
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
|
||||
}
|
||||
const statsData = await statsResponse.json();
|
||||
|
||||
// 处理并设置数据
|
||||
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
|
||||
setLinks(uiLinks);
|
||||
setAllTags(tagsData);
|
||||
setStats(statsData);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Data loading failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLinks();
|
||||
}, []);
|
||||
|
||||
const filteredLinks = links.filter(link =>
|
||||
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.shortUrl.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
link.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleOpenLinkDetails = (id: string) => {
|
||||
window.location.href = `/links/${id}`;
|
||||
};
|
||||
|
||||
const handleCreateLink = async (linkData: LinkData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// 在实际应用中,这里会发送 POST 请求到 API
|
||||
console.log('创建链接:', linkData);
|
||||
|
||||
// 刷新链接列表
|
||||
const response = await fetch('/api/links');
|
||||
if (!response.ok) {
|
||||
throw new Error(`刷新链接列表失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const newData = await response.json();
|
||||
const uiLinks = newData.data.map(mapApiLinkToUiLink);
|
||||
setLinks(uiLinks);
|
||||
|
||||
setShowCreateModal(false);
|
||||
} catch (err) {
|
||||
console.error('创建链接失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
if (isLoading && links.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="p-4 text-center">
|
||||
<div className="w-12 h-12 mx-auto border-4 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
||||
<p className="mt-4 text-lg text-foreground">Loading data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && links.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="p-6 text-center rounded-lg bg-red-500/10">
|
||||
<svg className="w-12 h-12 mx-auto text-accent-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="mt-4 text-xl font-bold text-foreground">Loading Failed</h2>
|
||||
<p className="mt-2 text-text-secondary">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 mt-4 text-white rounded-lg bg-accent-blue hover:bg-blue-600"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container px-4 py-8 mx-auto">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:justify-between md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Link Management</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
View and manage all your shortened links
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
className="block w-full p-2.5 pl-10 text-sm border rounded-lg bg-card-bg border-card-border text-foreground placeholder-text-secondary focus:ring-accent-blue focus:border-accent-blue"
|
||||
placeholder="Search links..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2.5 bg-accent-blue text-white rounded-lg text-sm font-medium hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
New Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full text-accent-blue bg-blue-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 10-5.656-5.656l-1.102 1.101" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Total Links</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{stats.totalLinks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full text-accent-green bg-green-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Active Links</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{stats.activeLinks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full text-accent-purple bg-purple-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Total Visits</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{stats.totalVisits.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 mr-4 rounded-full bg-amber-500/10 text-accent-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-secondary">Conversion Rate</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{(stats.conversionRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Table */}
|
||||
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left text-text-secondary">
|
||||
<thead className="text-xs uppercase border-b bg-card-bg/60 text-text-secondary border-card-border">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3">Link Info</th>
|
||||
<th scope="col" className="px-6 py-3">Visits</th>
|
||||
<th scope="col" className="px-6 py-3">Unique Visitors</th>
|
||||
<th scope="col" className="px-6 py-3">Avg Time</th>
|
||||
<th scope="col" className="px-6 py-3">Conversion</th>
|
||||
<th scope="col" className="px-6 py-3">Status</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && links.length === 0 ? (
|
||||
<tr className="border-b bg-card-bg border-card-border">
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-text-secondary">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
||||
<span className="ml-2">Loading...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredLinks.length === 0 ? (
|
||||
<tr className="border-b bg-card-bg border-card-border">
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-text-secondary">
|
||||
No links found matching your search criteria
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLinks.map((link) => (
|
||||
<tr
|
||||
key={link.id}
|
||||
className="border-b cursor-pointer bg-card-bg border-card-border hover:bg-card-bg/80"
|
||||
onClick={() => handleOpenLinkDetails(link.id)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.name}</div>
|
||||
<div className="text-xs text-accent-blue">{link.shortUrl}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.visits.toLocaleString()}</div>
|
||||
<div className={`text-xs flex items-center ${link.visitChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.visitChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.visitChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.uniqueVisitors.toLocaleString()}</div>
|
||||
<div className={`text-xs flex items-center ${link.uniqueVisitorsChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.uniqueVisitorsChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.uniqueVisitorsChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.avgTime}</div>
|
||||
<div className={`text-xs flex items-center ${link.avgTimeChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.avgTimeChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.avgTimeChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-foreground">{link.conversionRate}%</div>
|
||||
<div className={`text-xs flex items-center ${link.conversionChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
||||
<svg
|
||||
className={`w-3 h-3 mr-1 ${link.conversionChange >= 0 ? '' : 'transform rotate-180'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
{Math.abs(link.conversionChange)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
link.status === 'active'
|
||||
? 'bg-green-500/10 text-accent-green'
|
||||
: link.status === 'inactive'
|
||||
? 'bg-gray-500/10 text-text-secondary'
|
||||
: 'bg-red-500/10 text-accent-red'
|
||||
}`}
|
||||
>
|
||||
{link.status === 'active' ? 'Active' : link.status === 'inactive' ? 'Inactive' : 'Expired'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenLinkDetails(link.id);
|
||||
}}
|
||||
className="text-sm font-medium text-accent-blue hover:underline"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Section */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
||||
<h2 className="mb-4 text-lg font-medium text-foreground">Tags</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map(tagItem => (
|
||||
<span
|
||||
key={tagItem.tag}
|
||||
className="inline-flex items-center px-3 py-1 text-sm font-medium rounded-full text-accent-blue bg-blue-500/10"
|
||||
onClick={() => setSearchQuery(tagItem.tag)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{tagItem.tag}
|
||||
<span className="ml-1.5 text-xs bg-blue-500/20 px-1.5 py-0.5 rounded-full">
|
||||
{tagItem.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Link Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateLinkModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
app/page.tsx
Normal file
50
app/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24 relative overflow-hidden">
|
||||
{/* Colorful background elements */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0">
|
||||
<div className="absolute top-10 left-1/4 w-64 h-64 rounded-full bg-accent-blue opacity-10 blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-1/4 w-96 h-96 rounded-full bg-accent-purple opacity-10 blur-3xl"></div>
|
||||
<div className="absolute top-1/3 right-1/3 w-48 h-48 rounded-full bg-accent-green opacity-10 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-xl z-10 relative">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-blue flex items-center justify-center shadow-lg">
|
||||
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold ml-3 text-foreground">ShortURL <span className="text-accent-blue">Analytics</span></h1>
|
||||
</div>
|
||||
|
||||
<p className="text-text-secondary text-xl mb-10">Your complete analytics suite for tracking and optimizing short URL performance</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-center space-y-4 md:space-y-0 md:space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="bg-gradient-blue hover:opacity-90 text-white font-medium py-2.5 px-6 rounded-md text-lg transition-colors inline-flex items-center shadow-lg"
|
||||
>
|
||||
Go to Dashboard
|
||||
<svg className="ml-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/links"
|
||||
className="bg-card-bg border border-card-border hover:border-accent-purple text-foreground font-medium py-2.5 px-6 rounded-md text-lg transition-all inline-flex items-center"
|
||||
>
|
||||
View Links
|
||||
<svg className="ml-2 h-5 w-5 text-accent-purple" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
1266
lib/analytics.ts
Normal file
1266
lib/analytics.ts
Normal file
File diff suppressed because it is too large
Load Diff
47
lib/clickhouse.ts
Normal file
47
lib/clickhouse.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createClient } from '@clickhouse/client';
|
||||
|
||||
// Create configuration object using the URL approach
|
||||
const config = {
|
||||
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
||||
username: process.env.CLICKHOUSE_USER || 'default',
|
||||
password: process.env.CLICKHOUSE_PASSWORD || '',
|
||||
database: process.env.CLICKHOUSE_DATABASE || 'limq'
|
||||
};
|
||||
|
||||
// Log configuration (removing password for security)
|
||||
console.log('ClickHouse config:', {
|
||||
...config,
|
||||
password: config.password ? '****' : ''
|
||||
});
|
||||
|
||||
// Create ClickHouse client with proper URL format
|
||||
export const clickhouse = createClient(config);
|
||||
|
||||
// Log connection status
|
||||
console.log('ClickHouse client created with URL:', config.url);
|
||||
|
||||
/**
|
||||
* Execute ClickHouse query and return results
|
||||
*/
|
||||
export async function executeQuery<T>(query: string): Promise<T[]> {
|
||||
try {
|
||||
const result = await clickhouse.query({
|
||||
query,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const data = await result.json();
|
||||
return data as T[];
|
||||
} catch (error) {
|
||||
console.error('ClickHouse query error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute ClickHouse query and return a single result
|
||||
*/
|
||||
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
|
||||
const results = await executeQuery<T>(query);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
21
next.config.ts
Normal file
21
next.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// 设置需要转译的包
|
||||
transpilePackages: [],
|
||||
|
||||
// 配置实验性选项
|
||||
experimental: {
|
||||
// 禁用外部目录处理,避免monorepo问题
|
||||
externalDir: true,
|
||||
},
|
||||
|
||||
// 禁用严格模式,避免开发时重复渲染
|
||||
reactStrictMode: false,
|
||||
|
||||
// 设置输出为独立应用
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
5716
package-lock.json
generated
Normal file
5716
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "shorturl-analytics",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NEXT_TELEMETRY_DISABLED=1 next dev",
|
||||
"build": "NEXT_TELEMETRY_DISABLED=1 next build",
|
||||
"start": "NEXT_TELEMETRY_DISABLED=1 next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"next": "15.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
35
scripts/check-clickhouse.sh
Normal file
35
scripts/check-clickhouse.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}开始检查ClickHouse表结构...${NC}"
|
||||
|
||||
# 加载环境变量
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# 获取ClickHouse配置
|
||||
CLICKHOUSE_HOST=${CLICKHOUSE_HOST:-"localhost"}
|
||||
CLICKHOUSE_PORT=${CLICKHOUSE_PORT:-"8123"}
|
||||
CLICKHOUSE_USER=${CLICKHOUSE_USER:-"default"}
|
||||
CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-""}
|
||||
CLICKHOUSE_DATABASE=${CLICKHOUSE_DATABASE:-"default"}
|
||||
|
||||
echo -e "${GREEN}连接到ClickHouse: ${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}${NC}"
|
||||
|
||||
# 检查link_events表结构
|
||||
echo -e "${GREEN}检查link_events表结构:${NC}"
|
||||
curl -s "http://${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}/?user=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}" \
|
||||
-d "DESCRIBE TABLE ${CLICKHOUSE_DATABASE}.link_events"
|
||||
|
||||
# 查询一行数据样本
|
||||
echo -e "\n${GREEN}查询link_events表样本数据:${NC}"
|
||||
curl -s "http://${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}/?user=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}" \
|
||||
-d "SELECT * FROM ${CLICKHOUSE_DATABASE}.link_events LIMIT 1 FORMAT JSON"
|
||||
|
||||
echo -e "\n${YELLOW}检查完成${NC}"
|
||||
212
scripts/db/db-inspector/clickhouse-schema.js
Normal file
212
scripts/db/db-inspector/clickhouse-schema.js
Normal file
@@ -0,0 +1,212 @@
|
||||
// 检查ClickHouse数据库结构的脚本
|
||||
const { createClient } = require('@clickhouse/client');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
// 定义输出目录
|
||||
const DB_REPORTS_DIR = path.resolve(__dirname, '../db-reports');
|
||||
|
||||
// 获取ClickHouse配置
|
||||
const clickhouseHost = process.env.CLICKHOUSE_HOST || 'localhost';
|
||||
const clickhousePort = process.env.CLICKHOUSE_PORT || '8123';
|
||||
const clickhouseUser = process.env.CLICKHOUSE_USER || 'default';
|
||||
const clickhousePassword = process.env.CLICKHOUSE_PASSWORD || '';
|
||||
const clickhouseDatabase = process.env.CLICKHOUSE_DATABASE || 'default';
|
||||
|
||||
console.log('ClickHouse配置:');
|
||||
console.log(` - 主机: ${clickhouseHost}`);
|
||||
console.log(` - 端口: ${clickhousePort}`);
|
||||
console.log(` - 用户: ${clickhouseUser}`);
|
||||
console.log(` - 数据库: ${clickhouseDatabase}`);
|
||||
|
||||
// 创建ClickHouse客户端 - 使用0.2.10版本的API
|
||||
const client = createClient({
|
||||
url: `http://${clickhouseHost}:${clickhousePort}`,
|
||||
username: clickhouseUser,
|
||||
password: clickhousePassword,
|
||||
database: clickhouseDatabase
|
||||
});
|
||||
|
||||
// 获取所有表
|
||||
async function getAllTables() {
|
||||
console.log('\n获取所有表...');
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT name
|
||||
FROM system.tables
|
||||
WHERE database = '${clickhouseDatabase}'
|
||||
`;
|
||||
|
||||
const resultSet = await client.query({
|
||||
query,
|
||||
format: 'JSONEachRow'
|
||||
});
|
||||
|
||||
const tables = await resultSet.json();
|
||||
|
||||
if (!tables || tables.length === 0) {
|
||||
console.log(`数据库 ${clickhouseDatabase} 中没有找到任何表`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`数据库 ${clickhouseDatabase} 中找到以下表:`);
|
||||
tables.forEach(table => {
|
||||
console.log(` - ${table.name}`);
|
||||
});
|
||||
|
||||
return tables.map(table => table.name);
|
||||
} catch (error) {
|
||||
console.error('获取所有表时出错:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表结构
|
||||
async function getTableSchema(tableName) {
|
||||
console.log(`\n获取表 ${tableName} 的结构...`);
|
||||
|
||||
try {
|
||||
const query = `
|
||||
DESCRIBE TABLE ${clickhouseDatabase}.${tableName}
|
||||
`;
|
||||
|
||||
const resultSet = await client.query({
|
||||
query,
|
||||
format: 'JSONEachRow'
|
||||
});
|
||||
|
||||
const columns = await resultSet.json();
|
||||
|
||||
if (!columns || columns.length === 0) {
|
||||
console.log(`表 ${tableName} 不存在或没有列`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`表 ${tableName} 的列:`);
|
||||
columns.forEach(column => {
|
||||
console.log(` - ${column.name} (${column.type}, ${column.default_type === '' ? '无默认值' : `默认值: ${column.default_expression}`})`);
|
||||
});
|
||||
|
||||
return columns;
|
||||
} catch (error) {
|
||||
console.error(`获取表 ${tableName} 结构时出错:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表数据示例
|
||||
async function getTableDataSample(tableName, limit = 5) {
|
||||
console.log(`\n获取表 ${tableName} 的数据示例 (最多 ${limit} 行)...`);
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM ${clickhouseDatabase}.${tableName}
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const resultSet = await client.query({
|
||||
query,
|
||||
format: 'JSONEachRow'
|
||||
});
|
||||
|
||||
const rows = await resultSet.json();
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log(`表 ${tableName} 中没有数据`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`表 ${tableName} 的数据示例:`);
|
||||
rows.forEach((row, index) => {
|
||||
console.log(` 行 ${index + 1}:`);
|
||||
Object.entries(row).forEach(([key, value]) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error(`获取表 ${tableName} 数据示例时出错:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
let outputBuffer = '';
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
// 重定向console.log到buffer和控制台
|
||||
console.log = function() {
|
||||
// 调用原始的console.log
|
||||
originalConsoleLog.apply(console, arguments);
|
||||
|
||||
// 写入到buffer
|
||||
outputBuffer += Array.from(arguments).join(' ') + '\n';
|
||||
};
|
||||
|
||||
try {
|
||||
// 获取所有表
|
||||
const tables = await getAllTables();
|
||||
|
||||
if (!tables) {
|
||||
console.error('无法获取表列表');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n所有ClickHouse表:');
|
||||
console.log(tables.join(', '));
|
||||
|
||||
// 获取每个表的结构,但不获取数据示例
|
||||
for (const tableName of tables) {
|
||||
await getTableSchema(tableName);
|
||||
// 移除数据示例检查
|
||||
// await getTableDataSample(tableName);
|
||||
}
|
||||
|
||||
console.log('\nClickHouse数据库结构检查完成');
|
||||
|
||||
// 保存输出到指定目录
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(DB_REPORTS_DIR)) {
|
||||
fs.mkdirSync(DB_REPORTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const outputPath = path.join(DB_REPORTS_DIR, `clickhouse-schema-${timestamp}.log`);
|
||||
fs.writeFileSync(outputPath, outputBuffer);
|
||||
originalConsoleLog(`结果已保存到: ${outputPath}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查ClickHouse数据库结构时出错:', error);
|
||||
} finally {
|
||||
// 恢复原始的console.log
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
// 关闭客户端连接
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = {
|
||||
getAllTables,
|
||||
getTableSchema,
|
||||
getTableDataSample,
|
||||
main
|
||||
};
|
||||
|
||||
// 如果直接运行此脚本,则执行main函数
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error('运行脚本时出错:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
329
scripts/db/db-inspector/postgres-schema.js
Normal file
329
scripts/db/db-inspector/postgres-schema.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// 检查数据库结构的脚本
|
||||
const { Client } = require('pg');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
// 获取数据库连接字符串
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
console.error('缺少数据库连接字符串。请确保.env文件中包含DATABASE_URL');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 定义输出目录
|
||||
const DB_REPORTS_DIR = path.resolve(__dirname, '../db-reports');
|
||||
|
||||
// 连接数据库
|
||||
async function connect() {
|
||||
console.log('使用PostgreSQL连接字符串连接数据库...');
|
||||
|
||||
// 创建PostgreSQL客户端
|
||||
const client = new Client({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('成功连接到数据库');
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.error('连接数据库失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 断开数据库连接
|
||||
async function disconnect(client) {
|
||||
try {
|
||||
await client.end();
|
||||
console.log('已断开数据库连接');
|
||||
} catch (error) {
|
||||
console.error('断开数据库连接失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有表
|
||||
async function getAllTables(client) {
|
||||
console.log('\n获取所有表...');
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'limq'
|
||||
ORDER BY table_name;
|
||||
`;
|
||||
|
||||
const result = await client.query(query);
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
console.log('没有找到任何表');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('找到以下表:');
|
||||
result.rows.forEach(row => {
|
||||
console.log(` - ${row.table_name}`);
|
||||
});
|
||||
|
||||
return result.rows.map(row => row.table_name);
|
||||
} catch (error) {
|
||||
console.error('获取所有表时出错:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表结构
|
||||
async function getTableSchema(client, tableName) {
|
||||
console.log(`\n获取表 ${tableName} 的结构...`);
|
||||
|
||||
try {
|
||||
// 获取基本列信息
|
||||
const columnsQuery = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
numeric_scale
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_schema = 'limq' AND
|
||||
table_name = $1
|
||||
ORDER BY
|
||||
ordinal_position;
|
||||
`;
|
||||
|
||||
const columnsResult = await client.query(columnsQuery, [tableName]);
|
||||
|
||||
if (!columnsResult.rows || columnsResult.rows.length === 0) {
|
||||
console.log(`表 ${tableName} 不存在或没有列`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取主键信息
|
||||
const primaryKeyQuery = `
|
||||
SELECT
|
||||
kcu.column_name
|
||||
FROM
|
||||
information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'PRIMARY KEY' AND
|
||||
tc.table_schema = 'limq' AND
|
||||
tc.table_name = $1
|
||||
ORDER BY
|
||||
kcu.ordinal_position;
|
||||
`;
|
||||
|
||||
const primaryKeyResult = await client.query(primaryKeyQuery, [tableName]);
|
||||
|
||||
// 获取外键信息
|
||||
const foreignKeysQuery = `
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM
|
||||
information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name
|
||||
AND tc.table_schema = ccu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'FOREIGN KEY' AND
|
||||
tc.table_schema = 'limq' AND
|
||||
tc.table_name = $1;
|
||||
`;
|
||||
|
||||
const foreignKeysResult = await client.query(foreignKeysQuery, [tableName]);
|
||||
|
||||
// 获取索引信息
|
||||
const indexesQuery = `
|
||||
SELECT
|
||||
indexname,
|
||||
indexdef
|
||||
FROM
|
||||
pg_indexes
|
||||
WHERE
|
||||
schemaname = 'public' AND
|
||||
tablename = $1;
|
||||
`;
|
||||
|
||||
const indexesResult = await client.query(indexesQuery, [tableName]);
|
||||
|
||||
// 输出列信息
|
||||
console.log(`表 ${tableName} 的列:`);
|
||||
columnsResult.rows.forEach(column => {
|
||||
console.log(` - ${column.column_name} (${column.data_type}${
|
||||
column.character_maximum_length ? `(${column.character_maximum_length})` :
|
||||
(column.numeric_precision ? `(${column.numeric_precision},${column.numeric_scale})` : '')
|
||||
}, ${column.is_nullable === 'YES' ? '可为空' : '不可为空'}, 默认值: ${column.column_default || 'NULL'})`);
|
||||
});
|
||||
|
||||
// 输出主键信息
|
||||
if (primaryKeyResult.rows.length > 0) {
|
||||
console.log(` 主键: ${primaryKeyResult.rows.map(row => row.column_name).join(', ')}`);
|
||||
} else {
|
||||
console.log(' 主键: 无');
|
||||
}
|
||||
|
||||
// 输出外键信息
|
||||
if (foreignKeysResult.rows.length > 0) {
|
||||
console.log(' 外键:');
|
||||
foreignKeysResult.rows.forEach(fk => {
|
||||
console.log(` - ${fk.column_name} -> ${fk.foreign_table_name}.${fk.foreign_column_name}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' 外键: 无');
|
||||
}
|
||||
|
||||
// 输出索引信息
|
||||
if (indexesResult.rows.length > 0) {
|
||||
console.log(' 索引:');
|
||||
indexesResult.rows.forEach(idx => {
|
||||
console.log(` - ${idx.indexname}: ${idx.indexdef}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' 索引: 无');
|
||||
}
|
||||
|
||||
return {
|
||||
columns: columnsResult.rows,
|
||||
primaryKey: primaryKeyResult.rows,
|
||||
foreignKeys: foreignKeysResult.rows,
|
||||
indexes: indexesResult.rows
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`获取表 ${tableName} 结构时出错:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表数据示例
|
||||
async function getTableDataSample(client, tableName, limit = 5) {
|
||||
console.log(`\n获取表 ${tableName} 的数据示例 (最多 ${limit} 行)...`);
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM "${tableName}"
|
||||
LIMIT $1;
|
||||
`;
|
||||
|
||||
const result = await client.query(query, [limit]);
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
console.log(`表 ${tableName} 中没有数据`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`表 ${tableName} 的数据示例:`);
|
||||
result.rows.forEach((row, index) => {
|
||||
console.log(` 行 ${index + 1}:`);
|
||||
Object.entries(row).forEach(([key, value]) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
console.error(`获取表 ${tableName} 数据示例时出错:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
let client = null;
|
||||
let outputBuffer = '';
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
// 重定向console.log到buffer和控制台
|
||||
console.log = function() {
|
||||
// 调用原始的console.log
|
||||
originalConsoleLog.apply(console, arguments);
|
||||
|
||||
// 写入到buffer
|
||||
outputBuffer += Array.from(arguments).join(' ') + '\n';
|
||||
};
|
||||
|
||||
try {
|
||||
// 连接数据库
|
||||
client = await connect();
|
||||
|
||||
// 获取所有表
|
||||
const tables = await getAllTables(client);
|
||||
|
||||
if (!tables) {
|
||||
console.error('无法获取表列表');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n所有PostgreSQL表:');
|
||||
console.log(tables.join(', '));
|
||||
|
||||
// 获取所有表的结构,而不只是特定表
|
||||
for (const tableName of tables) {
|
||||
await getTableSchema(client, tableName);
|
||||
// 移除数据示例检查
|
||||
// await getTableDataSample(client, tableName);
|
||||
}
|
||||
|
||||
console.log('\n数据库结构检查完成');
|
||||
|
||||
// 保存输出到指定目录
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(DB_REPORTS_DIR)) {
|
||||
fs.mkdirSync(DB_REPORTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const outputPath = path.join(DB_REPORTS_DIR, `postgres-schema-${timestamp}.log`);
|
||||
fs.writeFileSync(outputPath, outputBuffer);
|
||||
originalConsoleLog(`结果已保存到: ${outputPath}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查数据库结构时出错:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// 恢复原始的console.log
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
// 关闭数据库连接
|
||||
if (client) {
|
||||
await disconnect(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = {
|
||||
connect,
|
||||
disconnect,
|
||||
getAllTables,
|
||||
getTableSchema,
|
||||
getTableDataSample,
|
||||
main
|
||||
};
|
||||
|
||||
// 如果直接运行此脚本,则执行main函数
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error('运行脚本时出错:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
102
scripts/db/db-inspector/run-all.js
Normal file
102
scripts/db/db-inspector/run-all.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// 一键运行所有数据库检查脚本
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 定义脚本路径
|
||||
const postgresScriptPath = path.join(__dirname, 'postgres-schema.js');
|
||||
const clickhouseScriptPath = path.join(__dirname, 'clickhouse-schema.js');
|
||||
|
||||
// 定义输出目录
|
||||
const DB_REPORTS_DIR = path.resolve(__dirname, '../db-reports');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(DB_REPORTS_DIR)) {
|
||||
fs.mkdirSync(DB_REPORTS_DIR, { recursive: true });
|
||||
console.log(`创建输出目录: ${DB_REPORTS_DIR}`);
|
||||
}
|
||||
|
||||
// 定义日期时间格式化函数,用于生成日志文件名
|
||||
function getTimestampString() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
// 运行PostgreSQL脚本
|
||||
async function runPostgresScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('\n=======================================');
|
||||
console.log('正在运行PostgreSQL数据库结构检查脚本...');
|
||||
console.log('=======================================\n');
|
||||
|
||||
const process = exec(`node --no-inspect ${postgresScriptPath}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`PostgreSQL脚本运行出错: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`PostgreSQL脚本错误: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(stdout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 运行ClickHouse脚本
|
||||
async function runClickHouseScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('\n=======================================');
|
||||
console.log('正在运行ClickHouse数据库结构检查脚本...');
|
||||
console.log('=======================================\n');
|
||||
|
||||
const process = exec(`node --no-inspect ${clickhouseScriptPath}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`ClickHouse脚本运行出错: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`ClickHouse脚本错误: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(stdout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
console.log('开始运行所有数据库结构检查脚本...');
|
||||
console.log(`输出目录: ${DB_REPORTS_DIR}`);
|
||||
console.log(`时间戳: ${getTimestampString()}`);
|
||||
|
||||
// 运行PostgreSQL脚本
|
||||
await runPostgresScript();
|
||||
|
||||
// 运行ClickHouse脚本
|
||||
await runClickHouseScript();
|
||||
|
||||
console.log('\n=======================================');
|
||||
console.log('所有数据库结构检查脚本已完成!');
|
||||
console.log('报告已保存到以下目录:');
|
||||
console.log(DB_REPORTS_DIR);
|
||||
console.log('=======================================');
|
||||
} catch (error) {
|
||||
console.error('运行脚本时出错:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行主函数
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error('运行脚本时出错:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
|
||||
获取所有表...
|
||||
数据库 limq 中找到以下表:
|
||||
- .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb
|
||||
- .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc
|
||||
- .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1
|
||||
- .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0
|
||||
- .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024
|
||||
- .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea
|
||||
- link_daily_stats
|
||||
- link_events
|
||||
- link_hourly_patterns
|
||||
- links
|
||||
- platform_distribution
|
||||
- project_daily_stats
|
||||
- projects
|
||||
- qr_scans
|
||||
- qrcode_daily_stats
|
||||
- qrcodes
|
||||
- sessions
|
||||
- team_daily_stats
|
||||
- team_members
|
||||
- teams
|
||||
|
||||
所有ClickHouse表:
|
||||
.inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb, .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc, .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1, .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0, .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024, .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea, link_daily_stats, link_events, link_hourly_patterns, links, platform_distribution, project_daily_stats, projects, qr_scans, qrcode_daily_stats, qrcodes, sessions, team_daily_stats, team_members, teams
|
||||
|
||||
获取表 .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb 的结构...
|
||||
|
||||
获取表 .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc 的结构...
|
||||
|
||||
获取表 .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1 的结构...
|
||||
|
||||
获取表 .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0 的结构...
|
||||
|
||||
获取表 .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024 的结构...
|
||||
|
||||
获取表 .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea 的结构...
|
||||
|
||||
获取表 link_daily_stats 的结构...
|
||||
表 link_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- total_clicks (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
- unique_sessions (UInt64, 无默认值)
|
||||
- total_time_spent (UInt64, 无默认值)
|
||||
- avg_time_spent (Float64, 无默认值)
|
||||
- bounce_count (UInt64, 无默认值)
|
||||
- conversion_count (UInt64, 无默认值)
|
||||
- unique_referrers (UInt64, 无默认值)
|
||||
- mobile_count (UInt64, 无默认值)
|
||||
- tablet_count (UInt64, 无默认值)
|
||||
- desktop_count (UInt64, 无默认值)
|
||||
- qr_scan_count (UInt64, 无默认值)
|
||||
- total_conversion_value (Float64, 无默认值)
|
||||
|
||||
获取表 link_events 的结构...
|
||||
表 link_events 的列:
|
||||
- event_id (UUID, 默认值: generateUUIDv4())
|
||||
- event_time (DateTime64(3), 默认值: now64())
|
||||
- date (Date, 默认值: toDate(event_time))
|
||||
- link_id (String, 无默认值)
|
||||
- channel_id (String, 无默认值)
|
||||
- visitor_id (String, 无默认值)
|
||||
- session_id (String, 无默认值)
|
||||
- event_type (Enum8('click' = 1, 'redirect' = 2, 'conversion' = 3, 'error' = 4), 无默认值)
|
||||
- ip_address (String, 无默认值)
|
||||
- country (String, 无默认值)
|
||||
- city (String, 无默认值)
|
||||
- referrer (String, 无默认值)
|
||||
- utm_source (String, 无默认值)
|
||||
- utm_medium (String, 无默认值)
|
||||
- utm_campaign (String, 无默认值)
|
||||
- user_agent (String, 无默认值)
|
||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
||||
- browser (String, 无默认值)
|
||||
- os (String, 无默认值)
|
||||
- time_spent_sec (UInt32, 默认值: 0)
|
||||
- is_bounce (Bool, 默认值: true)
|
||||
- is_qr_scan (Bool, 默认值: false)
|
||||
- qr_code_id (String, 默认值: '')
|
||||
- conversion_type (Enum8('visit' = 1, 'stay' = 2, 'interact' = 3, 'signup' = 4, 'subscription' = 5, 'purchase' = 6), 默认值: 'visit')
|
||||
- conversion_value (Float64, 默认值: 0)
|
||||
- custom_data (String, 默认值: '{}')
|
||||
|
||||
获取表 link_hourly_patterns 的结构...
|
||||
表 link_hourly_patterns 的列:
|
||||
- date (Date, 无默认值)
|
||||
- hour (UInt8, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- visits (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
|
||||
获取表 links 的结构...
|
||||
表 links 的列:
|
||||
- link_id (String, 无默认值)
|
||||
- original_url (String, 无默认值)
|
||||
- created_at (DateTime64(3), 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- title (String, 无默认值)
|
||||
- description (String, 无默认值)
|
||||
- tags (Array(String), 无默认值)
|
||||
- is_active (Bool, 默认值: true)
|
||||
- expires_at (Nullable(DateTime), 无默认值)
|
||||
- team_id (String, 默认值: '')
|
||||
- project_id (String, 默认值: '')
|
||||
|
||||
获取表 platform_distribution 的结构...
|
||||
表 platform_distribution 的列:
|
||||
- date (Date, 无默认值)
|
||||
- utm_source (String, 无默认值)
|
||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
||||
- visits (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
|
||||
获取表 project_daily_stats 的结构...
|
||||
表 project_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- project_id (String, 无默认值)
|
||||
- total_clicks (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
- conversion_count (UInt64, 无默认值)
|
||||
- links_used (UInt64, 无默认值)
|
||||
- qr_scan_count (UInt64, 无默认值)
|
||||
|
||||
获取表 projects 的结构...
|
||||
表 projects 的列:
|
||||
- project_id (String, 无默认值)
|
||||
- team_id (String, 无默认值)
|
||||
- name (String, 无默认值)
|
||||
- created_at (DateTime, 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- description (String, 默认值: '')
|
||||
- is_archived (Bool, 默认值: false)
|
||||
- links_count (UInt32, 默认值: 0)
|
||||
- total_clicks (UInt64, 默认值: 0)
|
||||
- last_updated (DateTime, 默认值: now())
|
||||
|
||||
获取表 qr_scans 的结构...
|
||||
表 qr_scans 的列:
|
||||
- scan_id (UUID, 默认值: generateUUIDv4())
|
||||
- qr_code_id (String, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- scan_time (DateTime64(3), 无默认值)
|
||||
- visitor_id (String, 无默认值)
|
||||
- location (String, 无默认值)
|
||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
||||
- led_to_conversion (Bool, 默认值: false)
|
||||
|
||||
获取表 qrcode_daily_stats 的结构...
|
||||
表 qrcode_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- qr_code_id (String, 无默认值)
|
||||
- total_scans (UInt64, 无默认值)
|
||||
- unique_scanners (UInt64, 无默认值)
|
||||
- conversions (UInt64, 无默认值)
|
||||
- mobile_scans (UInt64, 无默认值)
|
||||
- tablet_scans (UInt64, 无默认值)
|
||||
- desktop_scans (UInt64, 无默认值)
|
||||
- unique_locations (UInt64, 无默认值)
|
||||
|
||||
获取表 qrcodes 的结构...
|
||||
表 qrcodes 的列:
|
||||
- qr_code_id (String, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- team_id (String, 无默认值)
|
||||
- project_id (String, 默认值: '')
|
||||
- name (String, 无默认值)
|
||||
- description (String, 默认值: '')
|
||||
- created_at (DateTime, 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- updated_at (DateTime, 默认值: now())
|
||||
- qr_type (Enum8('standard' = 1, 'custom' = 2, 'dynamic' = 3), 默认值: 'standard')
|
||||
- image_url (String, 默认值: '')
|
||||
- design_config (String, 默认值: '{}')
|
||||
- is_active (Bool, 默认值: true)
|
||||
- total_scans (UInt64, 默认值: 0)
|
||||
- unique_scanners (UInt32, 默认值: 0)
|
||||
|
||||
获取表 sessions 的结构...
|
||||
表 sessions 的列:
|
||||
- session_id (String, 无默认值)
|
||||
- visitor_id (String, 无默认值)
|
||||
- link_id (String, 无默认值)
|
||||
- started_at (DateTime64(3), 无默认值)
|
||||
- last_activity (DateTime64(3), 无默认值)
|
||||
- ended_at (Nullable(DateTime64(3)), 无默认值)
|
||||
- duration_sec (UInt32, 默认值: 0)
|
||||
- session_pages (UInt8, 默认值: 1)
|
||||
- is_completed (Bool, 默认值: false)
|
||||
|
||||
获取表 team_daily_stats 的结构...
|
||||
表 team_daily_stats 的列:
|
||||
- date (Date, 无默认值)
|
||||
- team_id (String, 无默认值)
|
||||
- total_clicks (UInt64, 无默认值)
|
||||
- unique_visitors (UInt64, 无默认值)
|
||||
- conversion_count (UInt64, 无默认值)
|
||||
- links_used (UInt64, 无默认值)
|
||||
- qr_scan_count (UInt64, 无默认值)
|
||||
|
||||
获取表 team_members 的结构...
|
||||
表 team_members 的列:
|
||||
- team_id (String, 无默认值)
|
||||
- user_id (String, 无默认值)
|
||||
- role (Enum8('owner' = 1, 'admin' = 2, 'editor' = 3, 'viewer' = 4), 无默认值)
|
||||
- joined_at (DateTime, 默认值: now())
|
||||
- invited_by (String, 无默认值)
|
||||
- is_active (Bool, 默认值: true)
|
||||
- last_active (DateTime, 默认值: now())
|
||||
|
||||
获取表 teams 的结构...
|
||||
表 teams 的列:
|
||||
- team_id (String, 无默认值)
|
||||
- name (String, 无默认值)
|
||||
- created_at (DateTime, 无默认值)
|
||||
- created_by (String, 无默认值)
|
||||
- description (String, 默认值: '')
|
||||
- avatar_url (String, 默认值: '')
|
||||
- is_active (Bool, 默认值: true)
|
||||
- plan_type (Enum8('free' = 1, 'pro' = 2, 'enterprise' = 3), 无默认值)
|
||||
- members_count (UInt32, 默认值: 1)
|
||||
|
||||
ClickHouse数据库结构检查完成
|
||||
@@ -0,0 +1,483 @@
|
||||
使用PostgreSQL连接字符串连接数据库...
|
||||
成功连接到数据库
|
||||
|
||||
获取所有表...
|
||||
找到以下表:
|
||||
- ProjectTeams
|
||||
- attribute_schemas
|
||||
- channel
|
||||
- channel_tag
|
||||
- favorite
|
||||
- form_field_metadata
|
||||
- google_token
|
||||
- materials
|
||||
- permission_resources
|
||||
- permissions
|
||||
- platform_tokens
|
||||
- project_resources
|
||||
- projects
|
||||
- qr_code
|
||||
- queue_tasks
|
||||
- resource_tags
|
||||
- resources
|
||||
- roles
|
||||
- slide_presentations
|
||||
- subscription
|
||||
- sync_resource_typesense_queue
|
||||
- tags
|
||||
- team_invitation
|
||||
- team_join_request
|
||||
- team_membership
|
||||
- team_projects
|
||||
- teams
|
||||
- type_order
|
||||
- user_projects
|
||||
- users
|
||||
|
||||
所有PostgreSQL表:
|
||||
ProjectTeams, attribute_schemas, channel, channel_tag, favorite, form_field_metadata, google_token, materials, permission_resources, permissions, platform_tokens, project_resources, projects, qr_code, queue_tasks, resource_tags, resources, roles, slide_presentations, subscription, sync_resource_typesense_queue, tags, team_invitation, team_join_request, team_membership, team_projects, teams, type_order, user_projects, users
|
||||
|
||||
获取表 ProjectTeams 的结构...
|
||||
表 ProjectTeams 的列:
|
||||
- A (uuid, 不可为空, 默认值: NULL)
|
||||
- B (uuid, 不可为空, 默认值: NULL)
|
||||
主键: 无
|
||||
外键:
|
||||
- A -> projects.id
|
||||
索引: 无
|
||||
|
||||
获取表 attribute_schemas 的结构...
|
||||
表 attribute_schemas 的列:
|
||||
- version (integer(32,0), 可为空, 默认值: NULL)
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- entity_type (text, 可为空, 默认值: NULL)
|
||||
- schema (jsonb, 可为空, 默认值: NULL)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- is_active (boolean, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 channel 的结构...
|
||||
表 channel 的列:
|
||||
- name (text, 不可为空, 默认值: NULL)
|
||||
- path (text, 不可为空, 默认值: NULL)
|
||||
- shortUrlId (uuid, 可为空, 默认值: NULL)
|
||||
- qrCodeId (uuid, 可为空, 默认值: NULL)
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- isUserCreated (boolean, 可为空, 默认值: false)
|
||||
主键: id
|
||||
外键:
|
||||
- qrCodeId -> qr_code.id
|
||||
- shortUrlId -> resources.id
|
||||
索引: 无
|
||||
|
||||
获取表 channel_tag 的结构...
|
||||
表 channel_tag 的列:
|
||||
- channelId (uuid, 不可为空, 默认值: NULL)
|
||||
- tagId (uuid, 不可为空, 默认值: NULL)
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
主键: id
|
||||
外键:
|
||||
- channelId -> channel.id
|
||||
- tagId -> tags.id
|
||||
索引: 无
|
||||
|
||||
获取表 favorite 的结构...
|
||||
表 favorite 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- favoritable_id (uuid, 不可为空, 默认值: NULL)
|
||||
- favoritable_type (text, 不可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 form_field_metadata 的结构...
|
||||
表 form_field_metadata 的列:
|
||||
- field_name (text, 可为空, 默认值: NULL)
|
||||
- resource_type (text, 可为空, 默认值: NULL)
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- default_value (jsonb, 可为空, 默认值: NULL)
|
||||
- options (jsonb, 可为空, 默认值: NULL)
|
||||
- validation_rules (jsonb, 可为空, 默认值: NULL)
|
||||
- is_required (boolean, 可为空, 默认值: NULL)
|
||||
- order_index (integer(32,0), 可为空, 默认值: NULL)
|
||||
- is_visible (boolean, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- placeholder (text, 可为空, 默认值: NULL)
|
||||
- label (text, 可为空, 默认值: NULL)
|
||||
- field_type (text, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 google_token 的结构...
|
||||
表 google_token 的列:
|
||||
- id (text, 不可为空, 默认值: NULL)
|
||||
- googleEmail (text, 不可为空, 默认值: NULL)
|
||||
- created_at (timestamp without time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp without time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- profile_data (jsonb, 可为空, 默认值: NULL)
|
||||
- tokens (jsonb, 不可为空, 默认值: NULL)
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 materials 的结构...
|
||||
表 materials 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- name (text, 不可为空, 默认值: NULL)
|
||||
- description (text, 可为空, 默认值: ''::text)
|
||||
- attributes (jsonb, 可为空, 默认值: '{}'::jsonb)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- schema_version (integer(32,0), 可为空, 默认值: 1)
|
||||
- type (text, 不可为空, 默认值: NULL)
|
||||
- is_active (boolean, 可为空, 默认值: true)
|
||||
- is_system (boolean, 可为空, 默认值: false)
|
||||
- team_id (uuid, 可为空, 默认值: NULL)
|
||||
- creator_id (uuid, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 permission_resources 的结构...
|
||||
表 permission_resources 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: now())
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: now())
|
||||
- resource_type (USER-DEFINED, 可为空, 默认值: NULL)
|
||||
- resource_name (text, 不可为空, 默认值: NULL)
|
||||
- attributes_type (text, 可为空, 默认值: NULL)
|
||||
- description (text, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 permissions 的结构...
|
||||
表 permissions 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: now())
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: now())
|
||||
- role_id (uuid, 不可为空, 默认值: NULL)
|
||||
- resource_id (uuid, 不可为空, 默认值: NULL)
|
||||
- action (USER-DEFINED, 不可为空, 默认值: NULL)
|
||||
- metadata (jsonb, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- resource_id -> permission_resources.id
|
||||
- role_id -> roles.id
|
||||
索引: 无
|
||||
|
||||
获取表 platform_tokens 的结构...
|
||||
表 platform_tokens 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- platform (text, 不可为空, 默认值: NULL)
|
||||
- access_token (text, 不可为空, 默认值: NULL)
|
||||
- refresh_token (text, 可为空, 默认值: NULL)
|
||||
- expires_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- token_type (text, 可为空, 默认值: NULL)
|
||||
- scope (text, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- metadata (jsonb, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 project_resources 的结构...
|
||||
表 project_resources 的列:
|
||||
- project_id (uuid, 不可为空, 默认值: NULL)
|
||||
- resource_id (uuid, 不可为空, 默认值: NULL)
|
||||
- assigned_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
主键: project_id, resource_id
|
||||
外键:
|
||||
- project_id -> projects.id
|
||||
- resource_id -> resources.id
|
||||
索引: 无
|
||||
|
||||
获取表 projects 的结构...
|
||||
表 projects 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- name (text, 不可为空, 默认值: NULL)
|
||||
- description (text, 可为空, 默认值: NULL)
|
||||
- attributes (jsonb, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- schema_version (integer(32,0), 可为空, 默认值: NULL)
|
||||
- creator_id (uuid, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 qr_code 的结构...
|
||||
表 qr_code 的列:
|
||||
- scan_count (integer(32,0), 不可为空, 默认值: 0)
|
||||
- url (text, 不可为空, 默认值: NULL)
|
||||
- options (jsonb, 可为空, 默认值: NULL)
|
||||
- template_id (text, 可为空, 默认值: NULL)
|
||||
- msg_template_id (text, 可为空, 默认值: NULL)
|
||||
- template_name (text, 可为空, 默认值: NULL)
|
||||
- attributes (jsonb, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp without time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp without time zone, 不可为空, 默认值: NULL)
|
||||
- deleted_at (timestamp without time zone, 可为空, 默认值: NULL)
|
||||
- resource_id (uuid, 可为空, 默认值: NULL)
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
主键: id
|
||||
外键:
|
||||
- resource_id -> resources.id
|
||||
索引: 无
|
||||
|
||||
获取表 queue_tasks 的结构...
|
||||
表 queue_tasks 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- task_id (text, 不可为空, 默认值: NULL)
|
||||
- type (text, 不可为空, 默认值: NULL)
|
||||
- status (text, 不可为空, 默认值: NULL)
|
||||
- data (jsonb, 可为空, 默认值: NULL)
|
||||
- result (jsonb, 可为空, 默认值: NULL)
|
||||
- error (text, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- started_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- finished_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- creator_id (uuid, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- creator_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 resource_tags 的结构...
|
||||
表 resource_tags 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- tag_id (uuid, 不可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- resource_id (uuid, 不可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- resource_id -> resources.id
|
||||
- tag_id -> tags.id
|
||||
索引: 无
|
||||
|
||||
获取表 resources 的结构...
|
||||
表 resources 的列:
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- schema_version (integer(32,0), 可为空, 默认值: NULL)
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- external_id (text, 可为空, 默认值: NULL)
|
||||
- attributes (jsonb, 可为空, 默认值: NULL)
|
||||
- type (text, 可为空, 默认值: NULL)
|
||||
- creator_id (uuid, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- creator_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 roles 的结构...
|
||||
表 roles 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: now())
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: now())
|
||||
- name (text, 不可为空, 默认值: NULL)
|
||||
- description (text, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 slide_presentations 的结构...
|
||||
表 slide_presentations 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- slide_id (text, 不可为空, 默认值: NULL)
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- content (jsonb, 不可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 subscription 的结构...
|
||||
表 subscription 的列:
|
||||
- id (text, 不可为空, 默认值: NULL)
|
||||
- team_id (uuid, 不可为空, 默认值: NULL)
|
||||
- customer_id (text, 不可为空, 默认值: NULL)
|
||||
- status (USER-DEFINED, 不可为空, 默认值: NULL)
|
||||
- plan_id (text, 不可为空, 默认值: NULL)
|
||||
- variant_id (text, 不可为空, 默认值: NULL)
|
||||
- next_payment_date (timestamp without time zone, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 sync_resource_typesense_queue 的结构...
|
||||
表 sync_resource_typesense_queue 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- resource_id (uuid, 不可为空, 默认值: NULL)
|
||||
- entity_type (text, 不可为空, 默认值: NULL)
|
||||
- action (text, 不可为空, 默认值: NULL)
|
||||
- status (text, 不可为空, 默认值: NULL)
|
||||
- error (text, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- processed_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- resource_id -> resources.id
|
||||
索引: 无
|
||||
|
||||
获取表 tags 的结构...
|
||||
表 tags 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- name (text, 可为空, 默认值: NULL)
|
||||
- type (text, 可为空, 默认值: NULL)
|
||||
- attributes (jsonb, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- parent_tag_id (uuid, 可为空, 默认值: NULL)
|
||||
- team_id (uuid, 可为空, 默认值: NULL)
|
||||
- is_shared (boolean, 可为空, 默认值: false)
|
||||
- schema_version (integer(32,0), 可为空, 默认值: NULL)
|
||||
- is_system (boolean, 可为空, 默认值: false)
|
||||
主键: id
|
||||
外键:
|
||||
- parent_tag_id -> tags.id
|
||||
索引: 无
|
||||
|
||||
获取表 team_invitation 的结构...
|
||||
表 team_invitation 的列:
|
||||
- id (text, 不可为空, 默认值: NULL)
|
||||
- team_id (uuid, 不可为空, 默认值: NULL)
|
||||
- email (text, 可为空, 默认值: NULL)
|
||||
- role (USER-DEFINED, 不可为空, 默认值: 'MEMBER'::limq.team_member_role)
|
||||
- created_at (timestamp without time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- expires_at (timestamp without time zone, 不可为空, 默认值: NULL)
|
||||
- type (text, 可为空, 默认值: NULL)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- status (USER-DEFINED, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- team_id -> teams.id
|
||||
索引: 无
|
||||
|
||||
获取表 team_join_request 的结构...
|
||||
表 team_join_request 的列:
|
||||
- id (text, 不可为空, 默认值: NULL)
|
||||
- team_id (uuid, 不可为空, 默认值: NULL)
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- invitation_id (text, 不可为空, 默认值: NULL)
|
||||
- status (USER-DEFINED, 不可为空, 默认值: 'PENDING'::limq.request_status)
|
||||
- message (text, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 不可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 不可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- invitation_id -> team_invitation.id
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 team_membership 的结构...
|
||||
表 team_membership 的列:
|
||||
- id (text, 不可为空, 默认值: NULL)
|
||||
- team_id (uuid, 不可为空, 默认值: NULL)
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- is_creator (boolean, 不可为空, 默认值: false)
|
||||
- role (text, 不可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键:
|
||||
- team_id -> teams.id
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 team_projects 的结构...
|
||||
表 team_projects 的列:
|
||||
- team_id (uuid, 不可为空, 默认值: NULL)
|
||||
- project_id (uuid, 不可为空, 默认值: NULL)
|
||||
- assigned_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
主键: team_id, project_id
|
||||
外键:
|
||||
- project_id -> projects.id
|
||||
- team_id -> teams.id
|
||||
索引: 无
|
||||
|
||||
获取表 teams 的结构...
|
||||
表 teams 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- name (text, 不可为空, 默认值: NULL)
|
||||
- description (text, 可为空, 默认值: NULL)
|
||||
- attributes (jsonb, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- schema_version (integer(32,0), 可为空, 默认值: NULL)
|
||||
- avatar_url (text, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
获取表 type_order 的结构...
|
||||
表 type_order 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- category (text, 不可为空, 默认值: NULL)
|
||||
- type (text, 不可为空, 默认值: NULL)
|
||||
- order (integer(32,0), 不可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
主键: id
|
||||
外键:
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 user_projects 的结构...
|
||||
表 user_projects 的列:
|
||||
- user_id (uuid, 不可为空, 默认值: NULL)
|
||||
- project_id (uuid, 不可为空, 默认值: NULL)
|
||||
- role (text, 可为空, 默认值: NULL)
|
||||
- joined_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
主键: user_id, project_id
|
||||
外键:
|
||||
- project_id -> projects.id
|
||||
- user_id -> users.id
|
||||
索引: 无
|
||||
|
||||
获取表 users 的结构...
|
||||
表 users 的列:
|
||||
- id (uuid, 不可为空, 默认值: gen_random_uuid())
|
||||
- email (text, 不可为空, 默认值: NULL)
|
||||
- password_hash (text, 可为空, 默认值: NULL)
|
||||
- first_name (text, 可为空, 默认值: NULL)
|
||||
- last_name (text, 可为空, 默认值: NULL)
|
||||
- phone_number (text, 可为空, 默认值: NULL)
|
||||
- is_email_verified (boolean, 可为空, 默认值: NULL)
|
||||
- is_phone_verified (boolean, 可为空, 默认值: NULL)
|
||||
- last_login_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- attributes (jsonb, 可为空, 默认值: NULL)
|
||||
- external_id (text, 可为空, 默认值: NULL)
|
||||
- created_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- updated_at (timestamp with time zone, 可为空, 默认值: CURRENT_TIMESTAMP)
|
||||
- deleted_at (timestamp with time zone, 可为空, 默认值: NULL)
|
||||
- schema_version (integer(32,0), 可为空, 默认值: NULL)
|
||||
- avatar_url (text, 可为空, 默认值: NULL)
|
||||
主键: id
|
||||
外键: 无
|
||||
索引: 无
|
||||
|
||||
数据库结构检查完成
|
||||
29
scripts/db/load-clickhouse-testdata.sh
Executable file
29
scripts/db/load-clickhouse-testdata.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# 脚本名称: load-clickhouse-testdata.sh
|
||||
# 用途: 将测试数据加载到ClickHouse数据库中
|
||||
|
||||
# 设置脚本目录路径
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 设置SQL文件路径
|
||||
SQL_FILE="$SCRIPT_DIR/sql/clickhouse/seed-clickhouse-analytics.sql"
|
||||
|
||||
# 检查SQL文件是否存在
|
||||
if [ ! -f "$SQL_FILE" ]; then
|
||||
echo "错误: SQL文件 '$SQL_FILE' 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行CH查询脚本
|
||||
echo "开始加载测试数据到ClickHouse数据库..."
|
||||
bash "$SCRIPT_DIR/sql/clickhouse/ch-query.sh" -f "$SQL_FILE"
|
||||
|
||||
# 检查执行结果
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "测试数据已成功加载到ClickHouse数据库"
|
||||
else
|
||||
echo "错误: 加载测试数据失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
102
scripts/db/sql/clickhouse/ch-query.sh
Executable file
102
scripts/db/sql/clickhouse/ch-query.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
# 文件名: ch-query.sh
|
||||
# 用途: 执行ClickHouse SQL查询的便捷脚本
|
||||
|
||||
# 连接参数
|
||||
CH_HOST="localhost"
|
||||
CH_PORT="9000"
|
||||
CH_USER="admin"
|
||||
CH_PASSWORD="your_secure_password"
|
||||
|
||||
# 基本查询函数
|
||||
function ch_query() {
|
||||
clickhouse client --host $CH_HOST --port $CH_PORT --user $CH_USER --password $CH_PASSWORD -q "$1"
|
||||
}
|
||||
|
||||
# 显示帮助信息
|
||||
function show_help() {
|
||||
echo "ClickHouse 查询工具"
|
||||
echo "用法: $0 [选项] [SQL查询]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " -t 显示所有表"
|
||||
echo " -d 显示所有数据库"
|
||||
echo " -s <表名> 显示表结构"
|
||||
echo " -p <表名> 显示表样本数据(前10行)"
|
||||
echo " -c <表名> 计算表中的记录数"
|
||||
echo " -h, --help 显示此帮助信息"
|
||||
echo " -q \"SQL查询\" 执行自定义SQL查询"
|
||||
echo " -f <文件名> 执行SQL文件"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 -d # 显示所有数据库"
|
||||
echo " $0 -t # 显示所有表"
|
||||
echo " $0 -s limq.link_events # 显示link_events表结构"
|
||||
echo " $0 -q \"SELECT * FROM limq.link_events LIMIT 5\" # 执行自定义查询"
|
||||
}
|
||||
|
||||
# 没有参数时显示帮助
|
||||
if [ $# -eq 0 ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 处理命令行参数
|
||||
case "$1" in
|
||||
-t)
|
||||
ch_query "SHOW TABLES"
|
||||
;;
|
||||
-d)
|
||||
ch_query "SHOW DATABASES"
|
||||
;;
|
||||
-s)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 需要提供表名"
|
||||
exit 1
|
||||
fi
|
||||
ch_query "DESCRIBE TABLE $2"
|
||||
;;
|
||||
-p)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 需要提供表名"
|
||||
exit 1
|
||||
fi
|
||||
ch_query "SELECT * FROM $2 LIMIT 10"
|
||||
;;
|
||||
-c)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 需要提供表名"
|
||||
exit 1
|
||||
fi
|
||||
ch_query "SELECT COUNT(*) FROM $2"
|
||||
;;
|
||||
-q)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 需要提供SQL查询"
|
||||
exit 1
|
||||
fi
|
||||
ch_query "$2"
|
||||
;;
|
||||
-f)
|
||||
if [ -z "$2" ]; then
|
||||
echo "错误: 需要提供SQL文件"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$2" ]; then
|
||||
echo "错误: 文件 '$2' 不存在"
|
||||
exit 1
|
||||
fi
|
||||
SQL=$(cat "$2")
|
||||
ch_query "$SQL"
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo "未知选项: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
4
scripts/db/sql/clickhouse/clickhouse.md
Normal file
4
scripts/db/sql/clickhouse/clickhouse.md
Normal file
@@ -0,0 +1,4 @@
|
||||
```bash
|
||||
alias clickhouse-sql='clickhouse client --host localhost --port 9000 --user admin --password your_secure_password --database promote -q'
|
||||
clickhouse-sql "SHOW TABLES"
|
||||
```
|
||||
170
scripts/db/sql/clickhouse/create_limq.sql
Normal file
170
scripts/db/sql/clickhouse/create_limq.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS limq;
|
||||
|
||||
-- 切换到limq数据库
|
||||
USE limq;
|
||||
|
||||
-- 创建短链接访问事件表
|
||||
CREATE TABLE IF NOT EXISTS limq.link_events (
|
||||
event_id UUID DEFAULT generateUUIDv4(),
|
||||
event_time DateTime64(3) DEFAULT now64(),
|
||||
date Date DEFAULT toDate(event_time),
|
||||
link_id String,
|
||||
channel_id String,
|
||||
visitor_id String,
|
||||
session_id String,
|
||||
event_type Enum8(
|
||||
'click' = 1,
|
||||
'redirect' = 2,
|
||||
'conversion' = 3,
|
||||
'error' = 4
|
||||
),
|
||||
-- 访问者信息
|
||||
ip_address String,
|
||||
country String,
|
||||
city String,
|
||||
-- 来源信息
|
||||
referrer String,
|
||||
utm_source String,
|
||||
utm_medium String,
|
||||
utm_campaign String,
|
||||
-- 设备信息
|
||||
user_agent String,
|
||||
device_type Enum8(
|
||||
'mobile' = 1,
|
||||
'tablet' = 2,
|
||||
'desktop' = 3,
|
||||
'other' = 4
|
||||
),
|
||||
browser String,
|
||||
os String,
|
||||
-- 交互信息
|
||||
time_spent_sec UInt32 DEFAULT 0,
|
||||
is_bounce Boolean DEFAULT true,
|
||||
-- QR码相关
|
||||
is_qr_scan Boolean DEFAULT false,
|
||||
qr_code_id String DEFAULT '',
|
||||
-- 转化数据
|
||||
conversion_type String DEFAULT '',
|
||||
conversion_value Float64 DEFAULT 0,
|
||||
-- 其他属性
|
||||
custom_data String DEFAULT '{}'
|
||||
) ENGINE = MergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, link_id, event_time) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 短链接维度表
|
||||
CREATE TABLE IF NOT EXISTS limq.links (
|
||||
link_id String,
|
||||
original_url String,
|
||||
created_at DateTime,
|
||||
created_by String,
|
||||
title String,
|
||||
description String,
|
||||
tags Array(String),
|
||||
is_active Boolean DEFAULT true,
|
||||
expires_at Nullable(DateTime),
|
||||
team_id String DEFAULT '',
|
||||
project_id String DEFAULT '',
|
||||
PRIMARY KEY (link_id)
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY
|
||||
link_id SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 会话跟踪表
|
||||
CREATE TABLE IF NOT EXISTS limq.sessions (
|
||||
session_id String,
|
||||
visitor_id String,
|
||||
link_id String,
|
||||
started_at DateTime64(3),
|
||||
last_activity DateTime64(3),
|
||||
ended_at Nullable(DateTime64(3)),
|
||||
duration_sec UInt32 DEFAULT 0,
|
||||
session_pages UInt8 DEFAULT 1,
|
||||
is_completed Boolean DEFAULT false,
|
||||
PRIMARY KEY (session_id)
|
||||
) ENGINE = ReplacingMergeTree(last_activity)
|
||||
ORDER BY
|
||||
(session_id, link_id, visitor_id) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- QR码统计表
|
||||
CREATE TABLE IF NOT EXISTS limq.qr_scans (
|
||||
scan_id UUID DEFAULT generateUUIDv4(),
|
||||
qr_code_id String,
|
||||
link_id String,
|
||||
scan_time DateTime64(3),
|
||||
visitor_id String,
|
||||
location String,
|
||||
device_type Enum8(
|
||||
'mobile' = 1,
|
||||
'tablet' = 2,
|
||||
'desktop' = 3,
|
||||
'other' = 4
|
||||
),
|
||||
led_to_conversion Boolean DEFAULT false,
|
||||
PRIMARY KEY (scan_id)
|
||||
) ENGINE = MergeTree() PARTITION BY toYYYYMM(scan_time)
|
||||
ORDER BY
|
||||
(qr_code_id, scan_time) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 每日链接汇总视图
|
||||
CREATE MATERIALIZED VIEW limq.link_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, link_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
link_id,
|
||||
count() AS total_clicks,
|
||||
uniqExact(visitor_id) AS unique_visitors,
|
||||
uniqExact(session_id) AS unique_sessions,
|
||||
sum(time_spent_sec) AS total_time_spent,
|
||||
avg(time_spent_sec) AS avg_time_spent,
|
||||
countIf(is_bounce) AS bounce_count,
|
||||
countIf(event_type = 'conversion') AS conversion_count,
|
||||
uniqExact(referrer) AS unique_referrers,
|
||||
countIf(device_type = 'mobile') AS mobile_count,
|
||||
countIf(device_type = 'tablet') AS tablet_count,
|
||||
countIf(device_type = 'desktop') AS desktop_count,
|
||||
countIf(is_qr_scan) AS qr_scan_count,
|
||||
sum(conversion_value) AS total_conversion_value
|
||||
FROM
|
||||
limq.link_events
|
||||
GROUP BY
|
||||
date,
|
||||
link_id;
|
||||
|
||||
-- 每小时访问模式视图
|
||||
CREATE MATERIALIZED VIEW limq.link_hourly_patterns ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, hour, link_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
toHour(event_time) AS hour,
|
||||
link_id,
|
||||
count() AS visits,
|
||||
uniqExact(visitor_id) AS unique_visitors
|
||||
FROM
|
||||
limq.link_events
|
||||
GROUP BY
|
||||
date,
|
||||
hour,
|
||||
link_id;
|
||||
|
||||
-- 平台分布视图
|
||||
CREATE MATERIALIZED VIEW limq.platform_distribution ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, utm_source, device_type) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
utm_source,
|
||||
device_type,
|
||||
count() AS visits,
|
||||
uniqExact(visitor_id) AS unique_visitors
|
||||
FROM
|
||||
limq.link_events
|
||||
WHERE
|
||||
utm_source != ''
|
||||
GROUP BY
|
||||
date,
|
||||
utm_source,
|
||||
device_type;
|
||||
146
scripts/db/sql/clickhouse/create_team_project_qrcode.sql
Normal file
146
scripts/db/sql/clickhouse/create_team_project_qrcode.sql
Normal file
@@ -0,0 +1,146 @@
|
||||
-- 添加team、project和qrcode表到limq数据库
|
||||
USE limq;
|
||||
|
||||
-- 团队表
|
||||
CREATE TABLE IF NOT EXISTS limq.teams (
|
||||
team_id String,
|
||||
name String,
|
||||
created_at DateTime,
|
||||
created_by String,
|
||||
description String DEFAULT '',
|
||||
avatar_url String DEFAULT '',
|
||||
is_active Boolean DEFAULT true,
|
||||
plan_type Enum8(
|
||||
'free' = 1,
|
||||
'pro' = 2,
|
||||
'enterprise' = 3
|
||||
),
|
||||
members_count UInt32 DEFAULT 1,
|
||||
PRIMARY KEY (team_id)
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY
|
||||
team_id SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 项目表
|
||||
CREATE TABLE IF NOT EXISTS limq.projects (
|
||||
project_id String,
|
||||
team_id String,
|
||||
name String,
|
||||
created_at DateTime,
|
||||
created_by String,
|
||||
description String DEFAULT '',
|
||||
is_archived Boolean DEFAULT false,
|
||||
links_count UInt32 DEFAULT 0,
|
||||
total_clicks UInt64 DEFAULT 0,
|
||||
last_updated DateTime DEFAULT now(),
|
||||
PRIMARY KEY (project_id)
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY
|
||||
(project_id, team_id) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- QR码表 (扩展现有的qr_scans表)
|
||||
CREATE TABLE IF NOT EXISTS limq.qrcodes (
|
||||
qr_code_id String,
|
||||
link_id String,
|
||||
team_id String,
|
||||
project_id String DEFAULT '',
|
||||
name String,
|
||||
description String DEFAULT '',
|
||||
created_at DateTime,
|
||||
created_by String,
|
||||
updated_at DateTime DEFAULT now(),
|
||||
qr_type Enum8(
|
||||
'standard' = 1,
|
||||
'custom' = 2,
|
||||
'dynamic' = 3
|
||||
) DEFAULT 'standard',
|
||||
image_url String DEFAULT '',
|
||||
design_config String DEFAULT '{}',
|
||||
is_active Boolean DEFAULT true,
|
||||
total_scans UInt64 DEFAULT 0,
|
||||
unique_scanners UInt32 DEFAULT 0,
|
||||
PRIMARY KEY (qr_code_id)
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY
|
||||
(qr_code_id, link_id) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 团队成员表
|
||||
CREATE TABLE IF NOT EXISTS limq.team_members (
|
||||
team_id String,
|
||||
user_id String,
|
||||
role Enum8(
|
||||
'owner' = 1,
|
||||
'admin' = 2,
|
||||
'editor' = 3,
|
||||
'viewer' = 4
|
||||
),
|
||||
joined_at DateTime DEFAULT now(),
|
||||
invited_by String,
|
||||
is_active Boolean DEFAULT true,
|
||||
last_active DateTime DEFAULT now(),
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY
|
||||
(team_id, user_id) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 团队每日统计视图
|
||||
CREATE MATERIALIZED VIEW limq.team_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, team_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
l.team_id AS team_id,
|
||||
count() AS total_clicks,
|
||||
uniqExact(e.visitor_id) AS unique_visitors,
|
||||
countIf(e.event_type = 'conversion') AS conversion_count,
|
||||
uniqExact(e.link_id) AS links_used,
|
||||
countIf(e.is_qr_scan) AS qr_scan_count
|
||||
FROM
|
||||
limq.link_events e
|
||||
JOIN limq.links l ON e.link_id = l.link_id
|
||||
WHERE
|
||||
l.team_id != ''
|
||||
GROUP BY
|
||||
date,
|
||||
l.team_id;
|
||||
|
||||
-- 项目每日统计视图
|
||||
CREATE MATERIALIZED VIEW limq.project_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, project_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
l.project_id AS project_id,
|
||||
count() AS total_clicks,
|
||||
uniqExact(e.visitor_id) AS unique_visitors,
|
||||
countIf(e.event_type = 'conversion') AS conversion_count,
|
||||
uniqExact(e.link_id) AS links_used,
|
||||
countIf(e.is_qr_scan) AS qr_scan_count
|
||||
FROM
|
||||
limq.link_events e
|
||||
JOIN limq.links l ON e.link_id = l.link_id
|
||||
WHERE
|
||||
l.project_id != ''
|
||||
GROUP BY
|
||||
date,
|
||||
l.project_id;
|
||||
|
||||
-- QR码每日统计视图
|
||||
CREATE MATERIALIZED VIEW limq.qrcode_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, qr_code_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(scan_time) AS date,
|
||||
qr_code_id,
|
||||
count() AS total_scans,
|
||||
uniqExact(visitor_id) AS unique_scanners,
|
||||
countIf(led_to_conversion) AS conversions,
|
||||
countIf(device_type = 'mobile') AS mobile_scans,
|
||||
countIf(device_type = 'tablet') AS tablet_scans,
|
||||
countIf(device_type = 'desktop') AS desktop_scans,
|
||||
uniqExact(location) AS unique_locations
|
||||
FROM
|
||||
limq.qr_scans
|
||||
GROUP BY
|
||||
date,
|
||||
qr_code_id;
|
||||
997
scripts/db/sql/clickhouse/mock_link_data.sql
Normal file
997
scripts/db/sql/clickhouse/mock_link_data.sql
Normal file
@@ -0,0 +1,997 @@
|
||||
-- 移动端点击访问事件
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 10:25:30',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-123',
|
||||
's-456',
|
||||
'click',
|
||||
'103.45.67.89',
|
||||
'China',
|
||||
'Shanghai',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'organic',
|
||||
'none',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
45,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 11:32:21',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-124',
|
||||
's-457',
|
||||
'click',
|
||||
'43.78.123.45',
|
||||
'Japan',
|
||||
'Tokyo',
|
||||
'https://twitter.com',
|
||||
'twitter',
|
||||
'social',
|
||||
'spring_promo',
|
||||
'Mozilla/5.0 (Android 10)',
|
||||
'mobile',
|
||||
'Chrome',
|
||||
'Android',
|
||||
15,
|
||||
true,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 14:15:45',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-125',
|
||||
's-458',
|
||||
'click',
|
||||
'72.34.67.81',
|
||||
'US',
|
||||
'New York',
|
||||
'https://www.facebook.com',
|
||||
'facebook',
|
||||
'social',
|
||||
'crypto_ad',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
120,
|
||||
false,
|
||||
false,
|
||||
'interact',
|
||||
0
|
||||
);
|
||||
|
||||
-- 桌面设备点击事件
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 08:45:12',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-126',
|
||||
's-459',
|
||||
'click',
|
||||
'89.67.43.21',
|
||||
'Germany',
|
||||
'Berlin',
|
||||
'https://www.reddit.com',
|
||||
'reddit',
|
||||
'referral',
|
||||
'none',
|
||||
'Mozilla/5.0 (Windows NT 10.0)',
|
||||
'desktop',
|
||||
'Chrome',
|
||||
'Windows',
|
||||
300,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 16:20:33',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-127',
|
||||
's-460',
|
||||
'click',
|
||||
'178.65.43.12',
|
||||
'UK',
|
||||
'London',
|
||||
'https://www.linkedin.com',
|
||||
'linkedin',
|
||||
'social',
|
||||
'biz_campaign',
|
||||
'Mozilla/5.0 (Macintosh)',
|
||||
'desktop',
|
||||
'Safari',
|
||||
'MacOS',
|
||||
250,
|
||||
false,
|
||||
false,
|
||||
'stay',
|
||||
0
|
||||
);
|
||||
|
||||
-- 平板设备点击事件
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 13:10:55',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-128',
|
||||
's-461',
|
||||
'click',
|
||||
'156.78.34.12',
|
||||
'Canada',
|
||||
'Toronto',
|
||||
'https://www.youtube.com',
|
||||
'youtube',
|
||||
'video',
|
||||
'tutorial',
|
||||
'Mozilla/5.0 (iPad)',
|
||||
'tablet',
|
||||
'Safari',
|
||||
'iOS',
|
||||
180,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
-- QR扫描访问事件
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 09:30:22',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_qr',
|
||||
'v-129',
|
||||
's-462',
|
||||
'click',
|
||||
'101.56.78.90',
|
||||
'China',
|
||||
'Beijing',
|
||||
'direct',
|
||||
'qr',
|
||||
'print',
|
||||
'offline_event',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
75,
|
||||
false,
|
||||
true,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
-- 转化事件
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 10:27:45',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-123',
|
||||
's-456',
|
||||
'conversion',
|
||||
'103.45.67.89',
|
||||
'China',
|
||||
'Shanghai',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'organic',
|
||||
'none',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
120,
|
||||
false,
|
||||
false,
|
||||
'signup',
|
||||
50
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-15 08:52:18',
|
||||
'2025-03-15',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-126',
|
||||
's-459',
|
||||
'conversion',
|
||||
'89.67.43.21',
|
||||
'Germany',
|
||||
'Berlin',
|
||||
'https://www.reddit.com',
|
||||
'reddit',
|
||||
'referral',
|
||||
'none',
|
||||
'Mozilla/5.0 (Windows NT 10.0)',
|
||||
'desktop',
|
||||
'Chrome',
|
||||
'Windows',
|
||||
450,
|
||||
false,
|
||||
false,
|
||||
'purchase',
|
||||
150.75
|
||||
);
|
||||
|
||||
-- 第二天的数据 (3/16)
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-16 11:15:30',
|
||||
'2025-03-16',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-130',
|
||||
's-463',
|
||||
'click',
|
||||
'178.91.45.67',
|
||||
'France',
|
||||
'Paris',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'organic',
|
||||
'none',
|
||||
'Mozilla/5.0 (Android 11)',
|
||||
'mobile',
|
||||
'Chrome',
|
||||
'Android',
|
||||
60,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-16 14:22:45',
|
||||
'2025-03-16',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-131',
|
||||
's-464',
|
||||
'click',
|
||||
'89.123.45.78',
|
||||
'Spain',
|
||||
'Madrid',
|
||||
'https://www.instagram.com',
|
||||
'instagram',
|
||||
'social',
|
||||
'influencer',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
90,
|
||||
false,
|
||||
false,
|
||||
'interact',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-16 16:40:12',
|
||||
'2025-03-16',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-131',
|
||||
's-464',
|
||||
'conversion',
|
||||
'89.123.45.78',
|
||||
'Spain',
|
||||
'Madrid',
|
||||
'https://www.instagram.com',
|
||||
'instagram',
|
||||
'social',
|
||||
'influencer',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
200,
|
||||
false,
|
||||
false,
|
||||
'subscription',
|
||||
75.50
|
||||
);
|
||||
|
||||
-- 第三天数据 (3/17)
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-17 09:10:22',
|
||||
'2025-03-17',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-132',
|
||||
's-465',
|
||||
'click',
|
||||
'45.67.89.123',
|
||||
'US',
|
||||
'Los Angeles',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'cpc',
|
||||
'spring_sale',
|
||||
'Mozilla/5.0 (Windows NT 10.0)',
|
||||
'desktop',
|
||||
'Edge',
|
||||
'Windows',
|
||||
150,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-17 12:30:45',
|
||||
'2025-03-17',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-133',
|
||||
's-466',
|
||||
'click',
|
||||
'67.89.123.45',
|
||||
'Brazil',
|
||||
'Sao Paulo',
|
||||
'https://www.yahoo.com',
|
||||
'yahoo',
|
||||
'organic',
|
||||
'none',
|
||||
'Mozilla/5.0 (iPad)',
|
||||
'tablet',
|
||||
'Safari',
|
||||
'iOS',
|
||||
120,
|
||||
false,
|
||||
false,
|
||||
'stay',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-17 15:45:33',
|
||||
'2025-03-17',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-132',
|
||||
's-465',
|
||||
'conversion',
|
||||
'45.67.89.123',
|
||||
'US',
|
||||
'Los Angeles',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'cpc',
|
||||
'spring_sale',
|
||||
'Mozilla/5.0 (Windows NT 10.0)',
|
||||
'desktop',
|
||||
'Edge',
|
||||
'Windows',
|
||||
300,
|
||||
false,
|
||||
false,
|
||||
'purchase',
|
||||
225.50
|
||||
);
|
||||
|
||||
-- 添加一周前的数据 (对比期)
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-08 10:25:30',
|
||||
'2025-03-08',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-140',
|
||||
's-470',
|
||||
'click',
|
||||
'103.45.67.89',
|
||||
'China',
|
||||
'Shanghai',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'organic',
|
||||
'none',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
30,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-08 11:32:21',
|
||||
'2025-03-08',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-141',
|
||||
's-471',
|
||||
'click',
|
||||
'89.67.43.21',
|
||||
'Germany',
|
||||
'Berlin',
|
||||
'https://www.reddit.com',
|
||||
'reddit',
|
||||
'referral',
|
||||
'none',
|
||||
'Mozilla/5.0 (Windows NT 10.0)',
|
||||
'desktop',
|
||||
'Chrome',
|
||||
'Windows',
|
||||
200,
|
||||
false,
|
||||
false,
|
||||
'visit',
|
||||
0
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
conversion_type,
|
||||
conversion_value
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
generateUUIDv4(),
|
||||
'2025-03-08 13:10:55',
|
||||
'2025-03-08',
|
||||
'a71fcfe8-d293-4d6d-91c5-1528fa7f6294',
|
||||
'ch_main',
|
||||
'v-140',
|
||||
's-470',
|
||||
'conversion',
|
||||
'103.45.67.89',
|
||||
'China',
|
||||
'Shanghai',
|
||||
'https://www.google.com',
|
||||
'google',
|
||||
'organic',
|
||||
'none',
|
||||
'Mozilla/5.0 (iPhone)',
|
||||
'mobile',
|
||||
'Safari',
|
||||
'iOS',
|
||||
100,
|
||||
false,
|
||||
false,
|
||||
'purchase',
|
||||
100.00
|
||||
);
|
||||
193
scripts/db/sql/clickhouse/recreate_limq.sql
Normal file
193
scripts/db/sql/clickhouse/recreate_limq.sql
Normal file
@@ -0,0 +1,193 @@
|
||||
-- 删除现有的物化视图(需要先删除视图,因为它们依赖于表)
|
||||
DROP TABLE IF EXISTS limq.platform_distribution;
|
||||
|
||||
DROP TABLE IF EXISTS limq.link_hourly_patterns;
|
||||
|
||||
DROP TABLE IF EXISTS limq.link_daily_stats;
|
||||
|
||||
-- 删除现有的表
|
||||
DROP TABLE IF EXISTS limq.qr_scans;
|
||||
|
||||
DROP TABLE IF EXISTS limq.sessions;
|
||||
|
||||
DROP TABLE IF EXISTS limq.link_events;
|
||||
|
||||
DROP TABLE IF EXISTS limq.links;
|
||||
|
||||
-- 创建数据库(如果不存在)
|
||||
CREATE DATABASE IF NOT EXISTS limq;
|
||||
|
||||
-- 切换到limq数据库
|
||||
USE limq;
|
||||
|
||||
-- 创建短链接访问事件表
|
||||
CREATE TABLE IF NOT EXISTS limq.link_events (
|
||||
event_id UUID DEFAULT generateUUIDv4(),
|
||||
event_time DateTime64(3) DEFAULT now64(),
|
||||
date Date DEFAULT toDate(event_time),
|
||||
link_id String,
|
||||
channel_id String,
|
||||
visitor_id String,
|
||||
session_id String,
|
||||
event_type Enum8(
|
||||
'click' = 1,
|
||||
'redirect' = 2,
|
||||
'conversion' = 3,
|
||||
'error' = 4
|
||||
),
|
||||
-- 访问者信息
|
||||
ip_address String,
|
||||
country String,
|
||||
city String,
|
||||
-- 来源信息
|
||||
referrer String,
|
||||
utm_source String,
|
||||
utm_medium String,
|
||||
utm_campaign String,
|
||||
-- 设备信息
|
||||
user_agent String,
|
||||
device_type Enum8(
|
||||
'mobile' = 1,
|
||||
'tablet' = 2,
|
||||
'desktop' = 3,
|
||||
'other' = 4
|
||||
),
|
||||
browser String,
|
||||
os String,
|
||||
-- 交互信息
|
||||
time_spent_sec UInt32 DEFAULT 0,
|
||||
is_bounce Boolean DEFAULT true,
|
||||
-- QR码相关
|
||||
is_qr_scan Boolean DEFAULT false,
|
||||
qr_code_id String DEFAULT '',
|
||||
-- 转化数据
|
||||
conversion_type Enum8(
|
||||
'visit' = 1,
|
||||
'stay' = 2,
|
||||
'interact' = 3,
|
||||
'signup' = 4,
|
||||
'subscription' = 5,
|
||||
'purchase' = 6
|
||||
) DEFAULT 'visit',
|
||||
conversion_value Float64 DEFAULT 0,
|
||||
-- 其他属性
|
||||
custom_data String DEFAULT '{}'
|
||||
) ENGINE = MergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, link_id, event_time) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 短链接维度表
|
||||
CREATE TABLE IF NOT EXISTS limq.links (
|
||||
link_id String,
|
||||
original_url String,
|
||||
created_at DateTime64(3),
|
||||
created_by String,
|
||||
title String,
|
||||
description String,
|
||||
tags Array(String),
|
||||
is_active Boolean DEFAULT true,
|
||||
expires_at Nullable(DateTime64(3)),
|
||||
team_id String DEFAULT '',
|
||||
project_id String DEFAULT '',
|
||||
PRIMARY KEY (link_id)
|
||||
) ENGINE = ReplacingMergeTree()
|
||||
ORDER BY
|
||||
link_id SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 会话跟踪表
|
||||
CREATE TABLE IF NOT EXISTS limq.sessions (
|
||||
session_id String,
|
||||
visitor_id String,
|
||||
link_id String,
|
||||
started_at DateTime64(3),
|
||||
last_activity DateTime64(3),
|
||||
ended_at Nullable(DateTime64(3)),
|
||||
duration_sec UInt32 DEFAULT 0,
|
||||
session_pages UInt8 DEFAULT 1,
|
||||
is_completed Boolean DEFAULT false,
|
||||
PRIMARY KEY (session_id)
|
||||
) ENGINE = ReplacingMergeTree(last_activity)
|
||||
ORDER BY
|
||||
(session_id, link_id, visitor_id) SETTINGS index_granularity = 8192;
|
||||
|
||||
-- QR码统计表
|
||||
CREATE TABLE IF NOT EXISTS limq.qr_scans (
|
||||
scan_id UUID DEFAULT generateUUIDv4(),
|
||||
qr_code_id String,
|
||||
link_id String,
|
||||
scan_time DateTime64(3),
|
||||
visitor_id String,
|
||||
location String,
|
||||
device_type Enum8(
|
||||
'mobile' = 1,
|
||||
'tablet' = 2,
|
||||
'desktop' = 3,
|
||||
'other' = 4
|
||||
),
|
||||
led_to_conversion Boolean DEFAULT false,
|
||||
PRIMARY KEY (scan_id)
|
||||
) ENGINE = MergeTree() PARTITION BY toYYYYMM(scan_time)
|
||||
ORDER BY
|
||||
scan_id SETTINGS index_granularity = 8192;
|
||||
|
||||
-- 每日链接汇总视图
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.link_daily_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, link_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
link_id,
|
||||
count() AS total_clicks,
|
||||
uniqExact(visitor_id) AS unique_visitors,
|
||||
uniqExact(session_id) AS unique_sessions,
|
||||
sum(time_spent_sec) AS total_time_spent,
|
||||
avg(time_spent_sec) AS avg_time_spent,
|
||||
countIf(is_bounce) AS bounce_count,
|
||||
countIf(event_type = 'conversion') AS conversion_count,
|
||||
uniqExact(referrer) AS unique_referrers,
|
||||
countIf(device_type = 'mobile') AS mobile_count,
|
||||
countIf(device_type = 'tablet') AS tablet_count,
|
||||
countIf(device_type = 'desktop') AS desktop_count,
|
||||
countIf(is_qr_scan) AS qr_scan_count,
|
||||
sum(conversion_value) AS total_conversion_value
|
||||
FROM
|
||||
limq.link_events
|
||||
GROUP BY
|
||||
date,
|
||||
link_id;
|
||||
|
||||
-- 每小时访问模式视图
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.link_hourly_patterns ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, hour, link_id) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
toHour(event_time) AS hour,
|
||||
link_id,
|
||||
count() AS visits,
|
||||
uniqExact(visitor_id) AS unique_visitors
|
||||
FROM
|
||||
limq.link_events
|
||||
GROUP BY
|
||||
date,
|
||||
hour,
|
||||
link_id;
|
||||
|
||||
-- 平台分布视图
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS limq.platform_distribution ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(date, utm_source, device_type) SETTINGS index_granularity = 8192 AS
|
||||
SELECT
|
||||
toDate(event_time) AS date,
|
||||
utm_source,
|
||||
device_type,
|
||||
count() AS visits,
|
||||
uniqExact(visitor_id) AS unique_visitors
|
||||
FROM
|
||||
limq.link_events
|
||||
WHERE
|
||||
utm_source != ''
|
||||
GROUP BY
|
||||
date,
|
||||
utm_source,
|
||||
device_type;
|
||||
828
scripts/db/sql/clickhouse/seed-clickhouse-analytics.sql
Normal file
828
scripts/db/sql/clickhouse/seed-clickhouse-analytics.sql
Normal file
@@ -0,0 +1,828 @@
|
||||
-- 清空现有数据(可选)
|
||||
TRUNCATE TABLE IF EXISTS limq.link_events;
|
||||
|
||||
TRUNCATE TABLE IF EXISTS limq.link_daily_stats;
|
||||
|
||||
TRUNCATE TABLE IF EXISTS limq.link_hourly_patterns;
|
||||
|
||||
TRUNCATE TABLE IF EXISTS limq.links;
|
||||
|
||||
-- 使用固定的UUID值插入链接
|
||||
INSERT INTO
|
||||
limq.links (
|
||||
link_id,
|
||||
original_url,
|
||||
created_at,
|
||||
created_by,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'https://example.com/page1',
|
||||
now(),
|
||||
'user-1',
|
||||
'产品页面',
|
||||
'我们的主要产品页面',
|
||||
[ '产品',
|
||||
'营销' ],
|
||||
true
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.links (
|
||||
link_id,
|
||||
original_url,
|
||||
created_at,
|
||||
created_by,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
'https://example.com/promo',
|
||||
now(),
|
||||
'user-1',
|
||||
'促销活动',
|
||||
'夏季特别促销活动',
|
||||
[ '促销',
|
||||
'活动' ],
|
||||
true
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.links (
|
||||
link_id,
|
||||
original_url,
|
||||
created_at,
|
||||
created_by,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'33333333-3333-3333-3333-333333333333',
|
||||
'https://example.com/blog',
|
||||
now(),
|
||||
'user-2',
|
||||
'公司博客',
|
||||
'公司新闻和更新',
|
||||
[ '博客',
|
||||
'内容' ],
|
||||
true
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
limq.links (
|
||||
link_id,
|
||||
original_url,
|
||||
created_at,
|
||||
created_by,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'44444444-4444-4444-4444-444444444444',
|
||||
'https://example.com/signup',
|
||||
now(),
|
||||
'user-2',
|
||||
'注册页面',
|
||||
'新用户注册页面',
|
||||
[ '转化',
|
||||
'注册' ],
|
||||
true
|
||||
);
|
||||
|
||||
-- 为第一个链接创建500条记录
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
qr_code_id,
|
||||
conversion_type,
|
||||
conversion_value,
|
||||
custom_data
|
||||
)
|
||||
SELECT
|
||||
generateUUIDv4() AS event_id,
|
||||
subtractDays(now(), rand() % 30) AS event_time,
|
||||
toDate(event_time) AS date,
|
||||
'11111111-1111-1111-1111-111111111111' AS link_id,
|
||||
'channel-1' AS channel_id,
|
||||
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
|
||||
concat('session-', toString(number % 50 + 1)) AS session_id,
|
||||
multiIf(
|
||||
rand() % 100 < 70,
|
||||
'click',
|
||||
rand() % 100 < 90,
|
||||
'redirect',
|
||||
rand() % 100 < 98,
|
||||
'conversion',
|
||||
'error'
|
||||
) AS event_type,
|
||||
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'China',
|
||||
rand() % 100 < 85,
|
||||
'US',
|
||||
rand() % 100 < 95,
|
||||
'Japan',
|
||||
'Other'
|
||||
) AS country,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'Beijing',
|
||||
rand() % 100 < 85,
|
||||
'New York',
|
||||
rand() % 100 < 95,
|
||||
'Tokyo',
|
||||
'Other'
|
||||
) AS city,
|
||||
multiIf(
|
||||
rand() % 100 < 30,
|
||||
'https://google.com',
|
||||
rand() % 100 < 50,
|
||||
'https://facebook.com',
|
||||
rand() % 100 < 65,
|
||||
'https://twitter.com',
|
||||
rand() % 100 < 75,
|
||||
'https://instagram.com',
|
||||
rand() % 100 < 85,
|
||||
'https://linkedin.com',
|
||||
rand() % 100 < 90,
|
||||
'https://bing.com',
|
||||
rand() % 100 < 95,
|
||||
'https://baidu.com',
|
||||
'direct'
|
||||
) AS referrer,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'google',
|
||||
rand() % 100 < 70,
|
||||
'facebook',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_source,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'cpc',
|
||||
rand() % 100 < 70,
|
||||
'social',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_medium,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'summer_sale',
|
||||
rand() % 100 < 70,
|
||||
'product_launch',
|
||||
rand() % 100 < 90,
|
||||
'newsletter',
|
||||
'brand'
|
||||
) AS utm_campaign,
|
||||
'Mozilla/5.0' AS user_agent,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'mobile',
|
||||
rand() % 100 < 85,
|
||||
'desktop',
|
||||
rand() % 100 < 95,
|
||||
'tablet',
|
||||
'other'
|
||||
) AS device_type,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'Chrome',
|
||||
rand() % 100 < 80,
|
||||
'Safari',
|
||||
rand() % 100 < 95,
|
||||
'Firefox',
|
||||
'Edge'
|
||||
) AS browser,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'iOS',
|
||||
rand() % 100 < 90,
|
||||
'Android',
|
||||
'Windows'
|
||||
) AS os,
|
||||
rand() % 300 AS time_spent_sec,
|
||||
rand() % 100 < 25 AS is_bounce,
|
||||
rand() % 100 < 20 AS is_qr_scan,
|
||||
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'visit',
|
||||
rand() % 100 < 70,
|
||||
'stay',
|
||||
rand() % 100 < 85,
|
||||
'interact',
|
||||
rand() % 100 < 93,
|
||||
'signup',
|
||||
rand() % 100 < 97,
|
||||
'subscription',
|
||||
'purchase'
|
||||
) AS conversion_type,
|
||||
rand() % 100 * 1.5 AS conversion_value,
|
||||
'{}' AS custom_data
|
||||
FROM
|
||||
numbers(500);
|
||||
|
||||
-- 为第二个链接创建300条记录
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
qr_code_id,
|
||||
conversion_type,
|
||||
conversion_value,
|
||||
custom_data
|
||||
)
|
||||
SELECT
|
||||
generateUUIDv4() AS event_id,
|
||||
subtractDays(now(), rand() % 30) AS event_time,
|
||||
toDate(event_time) AS date,
|
||||
'22222222-2222-2222-2222-222222222222' AS link_id,
|
||||
'channel-1' AS channel_id,
|
||||
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
|
||||
concat('session-', toString(number % 40 + 1)) AS session_id,
|
||||
multiIf(
|
||||
rand() % 100 < 70,
|
||||
'click',
|
||||
rand() % 100 < 90,
|
||||
'redirect',
|
||||
rand() % 100 < 98,
|
||||
'conversion',
|
||||
'error'
|
||||
) AS event_type,
|
||||
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'China',
|
||||
rand() % 100 < 85,
|
||||
'US',
|
||||
rand() % 100 < 95,
|
||||
'Japan',
|
||||
'Other'
|
||||
) AS country,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'Beijing',
|
||||
rand() % 100 < 85,
|
||||
'New York',
|
||||
rand() % 100 < 95,
|
||||
'Tokyo',
|
||||
'Other'
|
||||
) AS city,
|
||||
multiIf(
|
||||
rand() % 100 < 30,
|
||||
'https://google.com',
|
||||
rand() % 100 < 50,
|
||||
'https://facebook.com',
|
||||
rand() % 100 < 65,
|
||||
'https://twitter.com',
|
||||
rand() % 100 < 75,
|
||||
'https://instagram.com',
|
||||
rand() % 100 < 85,
|
||||
'https://linkedin.com',
|
||||
rand() % 100 < 90,
|
||||
'https://bing.com',
|
||||
rand() % 100 < 95,
|
||||
'https://baidu.com',
|
||||
'direct'
|
||||
) AS referrer,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'google',
|
||||
rand() % 100 < 70,
|
||||
'facebook',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_source,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'cpc',
|
||||
rand() % 100 < 70,
|
||||
'social',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_medium,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'summer_sale',
|
||||
rand() % 100 < 70,
|
||||
'product_launch',
|
||||
rand() % 100 < 90,
|
||||
'newsletter',
|
||||
'brand'
|
||||
) AS utm_campaign,
|
||||
'Mozilla/5.0' AS user_agent,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'mobile',
|
||||
rand() % 100 < 85,
|
||||
'desktop',
|
||||
rand() % 100 < 95,
|
||||
'tablet',
|
||||
'other'
|
||||
) AS device_type,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'Chrome',
|
||||
rand() % 100 < 80,
|
||||
'Safari',
|
||||
rand() % 100 < 95,
|
||||
'Firefox',
|
||||
'Edge'
|
||||
) AS browser,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'iOS',
|
||||
rand() % 100 < 90,
|
||||
'Android',
|
||||
'Windows'
|
||||
) AS os,
|
||||
rand() % 300 AS time_spent_sec,
|
||||
rand() % 100 < 25 AS is_bounce,
|
||||
rand() % 100 < 15 AS is_qr_scan,
|
||||
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'visit',
|
||||
rand() % 100 < 70,
|
||||
'stay',
|
||||
rand() % 100 < 85,
|
||||
'interact',
|
||||
rand() % 100 < 93,
|
||||
'signup',
|
||||
rand() % 100 < 97,
|
||||
'subscription',
|
||||
'purchase'
|
||||
) AS conversion_type,
|
||||
rand() % 100 * 2.5 AS conversion_value,
|
||||
'{}' AS custom_data
|
||||
FROM
|
||||
numbers(300);
|
||||
|
||||
-- 为第三个链接创建200条记录
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
qr_code_id,
|
||||
conversion_type,
|
||||
conversion_value,
|
||||
custom_data
|
||||
)
|
||||
SELECT
|
||||
generateUUIDv4() AS event_id,
|
||||
subtractDays(now(), rand() % 30) AS event_time,
|
||||
toDate(event_time) AS date,
|
||||
'33333333-3333-3333-3333-333333333333' AS link_id,
|
||||
'channel-2' AS channel_id,
|
||||
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
|
||||
concat('session-', toString(number % 30 + 1)) AS session_id,
|
||||
multiIf(
|
||||
rand() % 100 < 70,
|
||||
'click',
|
||||
rand() % 100 < 90,
|
||||
'redirect',
|
||||
rand() % 100 < 98,
|
||||
'conversion',
|
||||
'error'
|
||||
) AS event_type,
|
||||
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'China',
|
||||
rand() % 100 < 85,
|
||||
'US',
|
||||
rand() % 100 < 95,
|
||||
'Japan',
|
||||
'Other'
|
||||
) AS country,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'Beijing',
|
||||
rand() % 100 < 85,
|
||||
'New York',
|
||||
rand() % 100 < 95,
|
||||
'Tokyo',
|
||||
'Other'
|
||||
) AS city,
|
||||
multiIf(
|
||||
rand() % 100 < 30,
|
||||
'https://google.com',
|
||||
rand() % 100 < 50,
|
||||
'https://facebook.com',
|
||||
rand() % 100 < 65,
|
||||
'https://twitter.com',
|
||||
rand() % 100 < 75,
|
||||
'https://instagram.com',
|
||||
rand() % 100 < 85,
|
||||
'https://linkedin.com',
|
||||
rand() % 100 < 90,
|
||||
'https://bing.com',
|
||||
rand() % 100 < 95,
|
||||
'https://baidu.com',
|
||||
'direct'
|
||||
) AS referrer,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'google',
|
||||
rand() % 100 < 70,
|
||||
'facebook',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_source,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'cpc',
|
||||
rand() % 100 < 70,
|
||||
'social',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_medium,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'summer_sale',
|
||||
rand() % 100 < 70,
|
||||
'product_launch',
|
||||
rand() % 100 < 90,
|
||||
'newsletter',
|
||||
'brand'
|
||||
) AS utm_campaign,
|
||||
'Mozilla/5.0' AS user_agent,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'mobile',
|
||||
rand() % 100 < 85,
|
||||
'desktop',
|
||||
rand() % 100 < 95,
|
||||
'tablet',
|
||||
'other'
|
||||
) AS device_type,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'Chrome',
|
||||
rand() % 100 < 80,
|
||||
'Safari',
|
||||
rand() % 100 < 95,
|
||||
'Firefox',
|
||||
'Edge'
|
||||
) AS browser,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'iOS',
|
||||
rand() % 100 < 90,
|
||||
'Android',
|
||||
'Windows'
|
||||
) AS os,
|
||||
rand() % 600 AS time_spent_sec,
|
||||
rand() % 100 < 15 AS is_bounce,
|
||||
rand() % 100 < 10 AS is_qr_scan,
|
||||
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'visit',
|
||||
rand() % 100 < 70,
|
||||
'stay',
|
||||
rand() % 100 < 85,
|
||||
'interact',
|
||||
rand() % 100 < 93,
|
||||
'signup',
|
||||
rand() % 100 < 97,
|
||||
'subscription',
|
||||
'purchase'
|
||||
) AS conversion_type,
|
||||
rand() % 100 * 1.2 AS conversion_value,
|
||||
'{}' AS custom_data
|
||||
FROM
|
||||
numbers(200);
|
||||
|
||||
-- 为第四个链接创建400条记录
|
||||
INSERT INTO
|
||||
limq.link_events (
|
||||
event_id,
|
||||
event_time,
|
||||
date,
|
||||
link_id,
|
||||
channel_id,
|
||||
visitor_id,
|
||||
session_id,
|
||||
event_type,
|
||||
ip_address,
|
||||
country,
|
||||
city,
|
||||
referrer,
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
user_agent,
|
||||
device_type,
|
||||
browser,
|
||||
os,
|
||||
time_spent_sec,
|
||||
is_bounce,
|
||||
is_qr_scan,
|
||||
qr_code_id,
|
||||
conversion_type,
|
||||
conversion_value,
|
||||
custom_data
|
||||
)
|
||||
SELECT
|
||||
generateUUIDv4() AS event_id,
|
||||
subtractDays(now(), rand() % 30) AS event_time,
|
||||
toDate(event_time) AS date,
|
||||
'44444444-4444-4444-4444-444444444444' AS link_id,
|
||||
'channel-2' AS channel_id,
|
||||
concat('visitor-', toString(rand() % 100 + 1)) AS visitor_id,
|
||||
concat('session-', toString(number % 60 + 1)) AS session_id,
|
||||
multiIf(
|
||||
rand() % 100 < 70,
|
||||
'click',
|
||||
rand() % 100 < 90,
|
||||
'redirect',
|
||||
rand() % 100 < 98,
|
||||
'conversion',
|
||||
'error'
|
||||
) AS event_type,
|
||||
concat('192.168.1.', toString(rand() % 255)) AS ip_address,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'China',
|
||||
rand() % 100 < 85,
|
||||
'US',
|
||||
rand() % 100 < 95,
|
||||
'Japan',
|
||||
'Other'
|
||||
) AS country,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'Beijing',
|
||||
rand() % 100 < 85,
|
||||
'New York',
|
||||
rand() % 100 < 95,
|
||||
'Tokyo',
|
||||
'Other'
|
||||
) AS city,
|
||||
multiIf(
|
||||
rand() % 100 < 30,
|
||||
'https://google.com',
|
||||
rand() % 100 < 50,
|
||||
'https://facebook.com',
|
||||
rand() % 100 < 65,
|
||||
'https://twitter.com',
|
||||
rand() % 100 < 75,
|
||||
'https://instagram.com',
|
||||
rand() % 100 < 85,
|
||||
'https://linkedin.com',
|
||||
rand() % 100 < 90,
|
||||
'https://bing.com',
|
||||
rand() % 100 < 95,
|
||||
'https://baidu.com',
|
||||
'direct'
|
||||
) AS referrer,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'google',
|
||||
rand() % 100 < 70,
|
||||
'facebook',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_source,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'cpc',
|
||||
rand() % 100 < 70,
|
||||
'social',
|
||||
rand() % 100 < 90,
|
||||
'email',
|
||||
'direct'
|
||||
) AS utm_medium,
|
||||
multiIf(
|
||||
rand() % 100 < 40,
|
||||
'summer_sale',
|
||||
rand() % 100 < 70,
|
||||
'product_launch',
|
||||
rand() % 100 < 90,
|
||||
'newsletter',
|
||||
'brand'
|
||||
) AS utm_campaign,
|
||||
'Mozilla/5.0' AS user_agent,
|
||||
multiIf(
|
||||
rand() % 100 < 60,
|
||||
'mobile',
|
||||
rand() % 100 < 85,
|
||||
'desktop',
|
||||
rand() % 100 < 95,
|
||||
'tablet',
|
||||
'other'
|
||||
) AS device_type,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'Chrome',
|
||||
rand() % 100 < 80,
|
||||
'Safari',
|
||||
rand() % 100 < 95,
|
||||
'Firefox',
|
||||
'Edge'
|
||||
) AS browser,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'iOS',
|
||||
rand() % 100 < 90,
|
||||
'Android',
|
||||
'Windows'
|
||||
) AS os,
|
||||
rand() % 400 AS time_spent_sec,
|
||||
rand() % 100 < 20 AS is_bounce,
|
||||
rand() % 100 < 25 AS is_qr_scan,
|
||||
concat('qr-', toString(rand() % 10 + 1)) AS qr_code_id,
|
||||
multiIf(
|
||||
rand() % 100 < 50,
|
||||
'visit',
|
||||
rand() % 100 < 70,
|
||||
'stay',
|
||||
rand() % 100 < 85,
|
||||
'interact',
|
||||
rand() % 100 < 93,
|
||||
'signup',
|
||||
rand() % 100 < 97,
|
||||
'subscription',
|
||||
'purchase'
|
||||
) AS conversion_type,
|
||||
rand() % 100 * 3.5 AS conversion_value,
|
||||
'{}' AS custom_data
|
||||
FROM
|
||||
numbers(400);
|
||||
|
||||
-- 插入link_daily_stats表数据
|
||||
INSERT INTO
|
||||
limq.link_daily_stats (
|
||||
date,
|
||||
link_id,
|
||||
total_clicks,
|
||||
unique_visitors,
|
||||
unique_sessions,
|
||||
total_time_spent,
|
||||
avg_time_spent,
|
||||
bounce_count,
|
||||
conversion_count,
|
||||
unique_referrers,
|
||||
mobile_count,
|
||||
tablet_count,
|
||||
desktop_count,
|
||||
qr_scan_count,
|
||||
total_conversion_value
|
||||
)
|
||||
SELECT
|
||||
subtractDays(today(), number) AS date,
|
||||
multiIf(
|
||||
number % 4 = 0,
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
number % 4 = 1,
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
number % 4 = 2,
|
||||
'33333333-3333-3333-3333-333333333333',
|
||||
'44444444-4444-4444-4444-444444444444'
|
||||
) AS link_id,
|
||||
50 + rand() % 100 AS total_clicks,
|
||||
30 + rand() % 50 AS unique_visitors,
|
||||
20 + rand() % 40 AS unique_sessions,
|
||||
(500 + rand() % 1000) * 60 AS total_time_spent,
|
||||
(rand() % 10) * 60 + rand() % 60 AS avg_time_spent,
|
||||
5 + rand() % 20 AS bounce_count,
|
||||
rand() % 30 AS conversion_count,
|
||||
3 + rand() % 8 AS unique_referrers,
|
||||
20 + rand() % 40 AS mobile_count,
|
||||
5 + rand() % 15 AS tablet_count,
|
||||
15 + rand() % 30 AS desktop_count,
|
||||
rand() % 10 AS qr_scan_count,
|
||||
rand() % 1000 * 2.5 AS total_conversion_value
|
||||
FROM
|
||||
numbers(30)
|
||||
WHERE
|
||||
number < 30;
|
||||
|
||||
-- 插入link_hourly_patterns表数据
|
||||
INSERT INTO
|
||||
limq.link_hourly_patterns (date, hour, link_id, visits, unique_visitors)
|
||||
SELECT
|
||||
subtractDays(today(), number % 7) AS date,
|
||||
number % 24 AS hour,
|
||||
multiIf(
|
||||
intDiv(number, 24) % 4 = 0,
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
intDiv(number, 24) % 4 = 1,
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
intDiv(number, 24) % 4 = 2,
|
||||
'33333333-3333-3333-3333-333333333333',
|
||||
'44444444-4444-4444-4444-444444444444'
|
||||
) AS link_id,
|
||||
5 + rand() % 20 AS visits,
|
||||
3 + rand() % 10 AS unique_visitors
|
||||
FROM
|
||||
numbers(672) -- 7天 x 24小时 x 4个链接
|
||||
WHERE
|
||||
number < 672;
|
||||
|
||||
-- 显示数据行数,验证插入成功
|
||||
SELECT
|
||||
'link_events 表行数:' AS metric,
|
||||
count() AS value
|
||||
FROM
|
||||
limq.link_events
|
||||
UNION
|
||||
ALL
|
||||
SELECT
|
||||
'link_daily_stats 表行数:',
|
||||
count()
|
||||
FROM
|
||||
limq.link_daily_stats
|
||||
UNION
|
||||
ALL
|
||||
SELECT
|
||||
'link_hourly_patterns 表行数:',
|
||||
count()
|
||||
FROM
|
||||
limq.link_hourly_patterns;
|
||||
331
scripts/db/sql/postgres/pg-query.js
Executable file
331
scripts/db/sql/postgres/pg-query.js
Executable file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// # 显示所有表
|
||||
// ./pg-query.js -t
|
||||
|
||||
// # 显示表结构
|
||||
// ./pg-query.js -d influencers
|
||||
|
||||
// # 显示样本数据,限制5行
|
||||
// ./pg-query.js -s posts -l 5
|
||||
|
||||
// # 查看表记录数
|
||||
// ./pg-query.js -c posts
|
||||
|
||||
// # 显示索引
|
||||
// ./pg-query.js -i posts
|
||||
|
||||
// # 显示外键
|
||||
// ./pg-query.js -f posts
|
||||
|
||||
// # 显示引用
|
||||
// ./pg-query.js -r influencers
|
||||
|
||||
// # 执行自定义查询
|
||||
// ./pg-query.js -q "SELECT * FROM influencers WHERE platform = 'Instagram' LIMIT 5"
|
||||
|
||||
// # 执行SQL文件
|
||||
// ./pg-query.js -e schema.sql
|
||||
|
||||
const { Client } = require('pg');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const yargs = require('yargs/yargs');
|
||||
const { hideBin } = require('yargs/helpers');
|
||||
|
||||
// 加载.env文件 - 使用正确的相对路径
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
// 显示连接信息(不含密码)以便调试
|
||||
function getConnectionString() {
|
||||
// 使用.env中的DATABASE_URL
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
console.error('错误: 未找到DATABASE_URL环境变量');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 显示连接信息但隐藏密码
|
||||
const sanitizedUrl = databaseUrl.replace(/:[^:@]+@/, ':***@');
|
||||
console.log(`使用连接: ${sanitizedUrl}`);
|
||||
|
||||
return databaseUrl;
|
||||
}
|
||||
|
||||
// 创建一个新的客户端
|
||||
async function runQuery(query, params = []) {
|
||||
const client = new Client({
|
||||
connectionString: getConnectionString()
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('数据库连接成功');
|
||||
const result = await client.query(query, params);
|
||||
return result.rows;
|
||||
} catch (err) {
|
||||
console.error('查询执行错误:', err.message);
|
||||
return null;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示所有表
|
||||
async function showTables() {
|
||||
const query = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;";
|
||||
const tables = await runQuery(query);
|
||||
|
||||
if (tables && tables.length > 0) {
|
||||
console.log('数据库中的表:');
|
||||
console.table(tables);
|
||||
} else {
|
||||
console.log('没有找到表或连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示表结构
|
||||
async function showTableStructure(tableName) {
|
||||
const query = `
|
||||
SELECT
|
||||
column_name AS "列名",
|
||||
data_type AS "数据类型",
|
||||
CASE WHEN is_nullable = 'YES' THEN '允许为空' ELSE '不允许为空' END AS "是否可空",
|
||||
column_default AS "默认值",
|
||||
character_maximum_length AS "最大长度"
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_schema = 'public' AND
|
||||
table_name = $1
|
||||
ORDER BY
|
||||
ordinal_position;
|
||||
`;
|
||||
|
||||
const columns = await runQuery(query, [tableName]);
|
||||
|
||||
if (columns && columns.length > 0) {
|
||||
console.log(`表 ${tableName} 的结构:`);
|
||||
console.table(columns);
|
||||
} else {
|
||||
console.log(`表 ${tableName} 不存在或连接失败`);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示样本数据
|
||||
async function showSampleData(tableName, limit = 10) {
|
||||
const query = `SELECT * FROM "${tableName}" LIMIT ${limit};`;
|
||||
const data = await runQuery(query);
|
||||
|
||||
if (data && data.length > 0) {
|
||||
console.log(`表 ${tableName} 的样本数据 (${limit} 行):`);
|
||||
console.table(data);
|
||||
} else {
|
||||
console.log(`表 ${tableName} 为空或不存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示记录计数
|
||||
async function showRecordCount(tableName) {
|
||||
const query = `SELECT COUNT(*) AS "记录数" FROM "${tableName}";`;
|
||||
const count = await runQuery(query);
|
||||
|
||||
if (count) {
|
||||
console.log(`表 ${tableName} 的记录数:`);
|
||||
console.table(count);
|
||||
} else {
|
||||
console.log(`表 ${tableName} 不存在或连接失败`);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示索引信息
|
||||
async function showIndexes(tableName) {
|
||||
const query = `
|
||||
SELECT
|
||||
indexname AS "索引名称",
|
||||
indexdef AS "索引定义"
|
||||
FROM
|
||||
pg_indexes
|
||||
WHERE
|
||||
tablename = $1
|
||||
ORDER BY
|
||||
indexname;
|
||||
`;
|
||||
|
||||
const indexes = await runQuery(query, [tableName]);
|
||||
|
||||
if (indexes && indexes.length > 0) {
|
||||
console.log(`表 ${tableName} 的索引:`);
|
||||
console.table(indexes);
|
||||
} else {
|
||||
console.log(`表 ${tableName} 没有索引或不存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示外键
|
||||
async function showForeignKeys(tableName) {
|
||||
const query = `
|
||||
SELECT
|
||||
conname AS "外键名称",
|
||||
pg_get_constraintdef(oid) AS "外键定义"
|
||||
FROM
|
||||
pg_constraint
|
||||
WHERE
|
||||
conrelid = $1::regclass AND contype = 'f';
|
||||
`;
|
||||
|
||||
const foreignKeys = await runQuery(query, [tableName]);
|
||||
|
||||
if (foreignKeys && foreignKeys.length > 0) {
|
||||
console.log(`表 ${tableName} 的外键:`);
|
||||
console.table(foreignKeys);
|
||||
} else {
|
||||
console.log(`表 ${tableName} 没有外键或不存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示引用当前表的外键
|
||||
async function showReferencingKeys(tableName) {
|
||||
const query = `
|
||||
SELECT
|
||||
c.conname AS "外键名称",
|
||||
t.relname AS "引用表",
|
||||
pg_get_constraintdef(c.oid) AS "外键定义"
|
||||
FROM
|
||||
pg_constraint c
|
||||
JOIN
|
||||
pg_class t ON c.conrelid = t.oid
|
||||
WHERE
|
||||
c.confrelid = $1::regclass AND c.contype = 'f';
|
||||
`;
|
||||
|
||||
const referencingKeys = await runQuery(query, [tableName]);
|
||||
|
||||
if (referencingKeys && referencingKeys.length > 0) {
|
||||
console.log(`引用表 ${tableName} 的外键关系:`);
|
||||
console.table(referencingKeys);
|
||||
} else {
|
||||
console.log(`没有找到引用表 ${tableName} 的外键关系`);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行自定义查询
|
||||
async function executeQuery(query) {
|
||||
const result = await runQuery(query);
|
||||
|
||||
if (result) {
|
||||
console.log('查询结果:');
|
||||
console.table(result);
|
||||
} else {
|
||||
console.log('查询执行失败或无结果');
|
||||
}
|
||||
}
|
||||
|
||||
// 执行SQL文件
|
||||
async function executeSqlFile(filename) {
|
||||
try {
|
||||
const sql = fs.readFileSync(filename, 'utf8');
|
||||
console.log(`执行SQL文件: ${filename}`);
|
||||
await executeQuery(sql);
|
||||
} catch (err) {
|
||||
console.error(`执行SQL文件失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.usage('PostgreSQL 查询工具\n\n用法: $0 [选项]')
|
||||
.option('t', {
|
||||
alias: 'tables',
|
||||
describe: '显示所有表',
|
||||
type: 'boolean'
|
||||
})
|
||||
.option('d', {
|
||||
alias: 'describe',
|
||||
describe: '显示表结构',
|
||||
type: 'string'
|
||||
})
|
||||
.option('s', {
|
||||
alias: 'sample',
|
||||
describe: '显示表样本数据',
|
||||
type: 'string'
|
||||
})
|
||||
.option('l', {
|
||||
alias: 'limit',
|
||||
describe: '样本数据行数限制',
|
||||
type: 'number',
|
||||
default: 10
|
||||
})
|
||||
.option('c', {
|
||||
alias: 'count',
|
||||
describe: '计算表中的记录数',
|
||||
type: 'string'
|
||||
})
|
||||
.option('i', {
|
||||
alias: 'indexes',
|
||||
describe: '显示表索引',
|
||||
type: 'string'
|
||||
})
|
||||
.option('f', {
|
||||
alias: 'foreign-keys',
|
||||
describe: '显示表外键关系',
|
||||
type: 'string'
|
||||
})
|
||||
.option('r', {
|
||||
alias: 'references',
|
||||
describe: '显示引用此表的外键',
|
||||
type: 'string'
|
||||
})
|
||||
.option('q', {
|
||||
alias: 'query',
|
||||
describe: '执行自定义SQL查询',
|
||||
type: 'string'
|
||||
})
|
||||
.option('e', {
|
||||
alias: 'execute-file',
|
||||
describe: '执行SQL文件',
|
||||
type: 'string'
|
||||
})
|
||||
.example('$0 -t', '显示所有表')
|
||||
.example('$0 -d influencers', '显示influencers表结构')
|
||||
.example('$0 -s posts -l 5', '显示posts表前5行数据')
|
||||
.epilog('更多信息请访问项目文档')
|
||||
.help()
|
||||
.alias('h', 'help')
|
||||
.argv;
|
||||
|
||||
if (argv.tables) {
|
||||
await showTables();
|
||||
} else if (argv.describe) {
|
||||
await showTableStructure(argv.describe);
|
||||
} else if (argv.sample) {
|
||||
await showSampleData(argv.sample, argv.limit);
|
||||
} else if (argv.count) {
|
||||
await showRecordCount(argv.count);
|
||||
} else if (argv.indexes) {
|
||||
await showIndexes(argv.indexes);
|
||||
} else if (argv.foreignKeys) {
|
||||
await showForeignKeys(argv.foreignKeys);
|
||||
} else if (argv.references) {
|
||||
await showReferencingKeys(argv.references);
|
||||
} else if (argv.query) {
|
||||
await executeQuery(argv.query);
|
||||
} else if (argv.executeFile) {
|
||||
await executeSqlFile(argv.executeFile);
|
||||
} else {
|
||||
yargs(hideBin(process.argv)).showHelp();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('程序执行错误:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行主函数
|
||||
main().catch(err => {
|
||||
console.error('程序执行错误:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
81
scripts/deploy.sh
Normal file
81
scripts/deploy.sh
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}开始部署流程...${NC}"
|
||||
|
||||
# 首先加载环境变量
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
echo -e "${GREEN}加载生产环境配置...${NC}"
|
||||
set -a
|
||||
source .env.production
|
||||
set +a
|
||||
else
|
||||
echo -e "${GREEN}加载开发环境配置...${NC}"
|
||||
set -a
|
||||
source .env.development
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 安装依赖
|
||||
echo -e "${GREEN}安装依赖...${NC}"
|
||||
NODE_ENV= pnpm install --ignore-workspace
|
||||
|
||||
# 生成 Prisma 客户端
|
||||
echo -e "${GREEN}生成 Prisma 客户端...${NC}"
|
||||
npx prisma generate
|
||||
|
||||
# 类型检查
|
||||
echo -e "${GREEN}运行类型检查...${NC}"
|
||||
pnpm tsc --noEmit
|
||||
|
||||
# 询问是否同步数据库架构
|
||||
echo -e "${YELLOW}是否需要同步数据库架构? (y/n)${NC}"
|
||||
read -r sync_db
|
||||
if [ "$sync_db" = "y" ] || [ "$sync_db" = "Y" ]; then
|
||||
echo -e "${GREEN}开始同步数据库架构...${NC}"
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
npx prisma db push
|
||||
else
|
||||
npx prisma db push
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}跳过数据库同步${NC}"
|
||||
fi
|
||||
|
||||
# 构建项目
|
||||
echo -e "${GREEN}构建项目...${NC}"
|
||||
pnpm build
|
||||
|
||||
# 检查并安装 PM2
|
||||
echo -e "${GREEN}检查 PM2...${NC}"
|
||||
if ! command -v pm2 &> /dev/null; then
|
||||
echo -e "${YELLOW}PM2 未安装,正在安装 5.4.3 版本...${NC}"
|
||||
pnpm add pm2@5.4.3 -g
|
||||
else
|
||||
PM2_VERSION=$(pm2 -v)
|
||||
if [ "$PM2_VERSION" != "5.4.3" ]; then
|
||||
echo -e "${YELLOW}错误: PM2 版本必须是 5.4.3,当前版本是 ${PM2_VERSION}${NC}"
|
||||
echo -e "${YELLOW}请运行以下命令更新 PM2:${NC}"
|
||||
echo -e "${YELLOW}pm2 kill && pnpm remove pm2 -g && rm -rf ~/.pm2 && pnpm add pm2@5.4.3 -g${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}PM2 5.4.3 已安装${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
echo -e "${GREEN}以生产模式启动服务...${NC}"
|
||||
pm2 start dist/src/main.js --name limq
|
||||
else
|
||||
echo -e "${GREEN}以开发模式启动服务...${NC}"
|
||||
pm2 start dist/src/main.js --name limq-dev --watch
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}部署完成!${NC}"
|
||||
42
tailwind.config.ts
Normal file
42
tailwind.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
"card-bg": "var(--card-bg)",
|
||||
"card-border": "var(--card-border)",
|
||||
"accent-blue": "var(--accent-blue)",
|
||||
"accent-green": "var(--accent-green)",
|
||||
"accent-red": "var(--accent-red)",
|
||||
"accent-yellow": "var(--accent-yellow)",
|
||||
"accent-purple": "var(--accent-purple)",
|
||||
"accent-pink": "var(--accent-pink)",
|
||||
"accent-teal": "var(--accent-teal)",
|
||||
"accent-orange": "var(--accent-orange)",
|
||||
"text-secondary": "var(--text-secondary)",
|
||||
"progress-bg": "var(--progress-bg)",
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: "var(--card-border)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-blue": "var(--gradient-blue)",
|
||||
"gradient-purple": "var(--gradient-purple)",
|
||||
"gradient-green": "var(--gradient-green)",
|
||||
"gradient-red": "var(--gradient-red)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user