Новый компоент счетчика
This commit is contained in:
parent
8c2d4007fa
commit
9d10fda3f3
9 changed files with 349 additions and 5 deletions
103
backend/pb_migrations/1777809787_created_site_visitors.js
Normal file
103
backend/pb_migrations/1777809787_created_site_visitors.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text791980464",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "visitor_hash",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2783163181",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "ip",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3293145029",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "user_agent",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_2651661972",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "site_visitors",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2651661972");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
28
backend/pb_migrations/1777809835_updated_site_visitors.js
Normal file
28
backend/pb_migrations/1777809835_updated_site_visitors.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2651661972")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2651661972")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
BIN
bun.lockb
Normal file
BIN
bun.lockb
Normal file
Binary file not shown.
77
frontend/src/components/base/VisitorCounter.astro
Normal file
77
frontend/src/components/base/VisitorCounter.astro
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
today?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { today = 0, total = 0 } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="visitor-counters">
|
||||||
|
<div class="counter-item">
|
||||||
|
<Icon name="users-group" />
|
||||||
|
<span class="counter-value">{today} сегодня</span>
|
||||||
|
</div>
|
||||||
|
<div class="counter-item">
|
||||||
|
<Icon name="users-total" />
|
||||||
|
<span class="counter-value">{total} всего</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const root = document.querySelector('.visitor-counters');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const valueEls = root.querySelectorAll('.counter-value');
|
||||||
|
if (valueEls.length < 2) return;
|
||||||
|
|
||||||
|
const todayStr = new Date().toDateString();
|
||||||
|
const lastVisit = localStorage.getItem('site_visited');
|
||||||
|
|
||||||
|
const isRepeat = lastVisit === todayStr;
|
||||||
|
const apiUrl = isRepeat ? '/api/visitors?repeat=true' : '/api/visitors';
|
||||||
|
|
||||||
|
fetch(apiUrl).then(r => r.json()).then(d => {
|
||||||
|
if (typeof d.todayVisitors === 'number') {
|
||||||
|
if (d.isNewVisitor) {
|
||||||
|
localStorage.setItem('site_visited', todayStr);
|
||||||
|
}
|
||||||
|
if (valueEls[0]) valueEls[0].textContent = d.todayVisitors + ' сегодня';
|
||||||
|
if (valueEls[1]) valueEls[1].textContent = d.totalVisitors + ' всего';
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.visitor-counters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: #8c9bb0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-item :global(svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-item:hover {
|
||||||
|
color: #bf9b58;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
|
||||||
import SocialIcons from "@components/base/SocialIcons.astro";
|
import SocialIcons from "@components/base/SocialIcons.astro";
|
||||||
|
import VisitorCounter from "@components/base/VisitorCounter.astro";
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
|
@ -147,11 +148,14 @@ const menu = [
|
||||||
<div
|
<div
|
||||||
class="pt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-4"
|
class="pt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-4"
|
||||||
>
|
>
|
||||||
<p class="text-[10px] uppercase tracking-[0.1em] text-gray-600">
|
<div class="flex flex-col md:flex-row gap-4 md:gap-8 items-center">
|
||||||
© {currentYear} ADVOKAT086. Все права защищены.
|
<p class="text-[10px] uppercase tracking-[0.1em] text-gray-600">
|
||||||
</p>
|
© {currentYear} ADVOKAT086. Все права защищены.
|
||||||
|
</p>
|
||||||
|
<VisitorCounter />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-8">
|
<div class="flex flex-col md:flex-row gap-3 md:gap-8 items-center justify-center">
|
||||||
<a
|
<a
|
||||||
href="/privacy-policy"
|
href="/privacy-policy"
|
||||||
class="text-[10px] uppercase tracking-[0.1em] text-gray-600 hover:text-white transition-colors"
|
class="text-[10px] uppercase tracking-[0.1em] text-gray-600 hover:text-white transition-colors"
|
||||||
|
|
|
||||||
6
frontend/src/icons/users-group.svg
Normal file
6
frontend/src/icons/users-group.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<line x1="19" y1="8" x2="19" y2="14"></line>
|
||||||
|
<line x1="22" y1="11" x2="16" y2="11"></line>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
6
frontend/src/icons/users-total.svg
Normal file
6
frontend/src/icons/users-total.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 383 B |
117
frontend/src/pages/api/visitors.ts
Normal file
117
frontend/src/pages/api/visitors.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://localhost:8090';
|
||||||
|
|
||||||
|
function getClientIp(request: Request): string {
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
const realIp = request.headers.get('x-real-ip');
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
const host = request.headers.get('host') || 'localhost';
|
||||||
|
return host.includes('localhost') || host.includes('127.0.0.1') ? 'localhost' : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateVisitorHash(ip: string, userAgent: string): string {
|
||||||
|
const stableIP = ip.split(':').pop() || ip;
|
||||||
|
const uaParts = userAgent.split(' ');
|
||||||
|
const stableUA = uaParts.slice(0, 2).join(' ');
|
||||||
|
return crypto.createHash('sha256').update(stableIP + stableUA).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pbRequest(method: string, path: string, body?: object) {
|
||||||
|
const url = `${POCKETBASE_URL}${path}`;
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'http://127.0.0.1:4321',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`PB ${method} ${path}: ${res.status} - ${err}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(data: object, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const isRepeatVisit = url.searchParams.get('repeat') === 'true';
|
||||||
|
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||||
|
const visitorHash = generateVisitorHash(ip, userAgent);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
|
||||||
|
const todayStartStr = todayStart.toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
|
||||||
|
const filterTodayVisitor = `visitor_hash="${visitorHash}" && created >= "${todayStartStr}"`;
|
||||||
|
|
||||||
|
let existingVisitor;
|
||||||
|
try {
|
||||||
|
const res = await pbRequest('GET', `/api/collections/site_visitors/records?filter=${encodeURIComponent(filterTodayVisitor)}&perPage=1`);
|
||||||
|
existingVisitor = { totalItems: res.totalItems || 0 };
|
||||||
|
} catch {
|
||||||
|
existingVisitor = { totalItems: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let isNewVisitor = false;
|
||||||
|
|
||||||
|
if (isRepeatVisit) {
|
||||||
|
isNewVisitor = false;
|
||||||
|
}
|
||||||
|
else if (existingVisitor.totalItems === 0) {
|
||||||
|
isNewVisitor = true;
|
||||||
|
try {
|
||||||
|
await pbRequest('POST', '/api/collections/site_visitors/records', {
|
||||||
|
visitor_hash: visitorHash,
|
||||||
|
ip: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterToday = `created >= "${todayStartStr}"`;
|
||||||
|
let todayCount = 0;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resToday = await pbRequest('GET', `/api/collections/site_visitors/records?filter=${encodeURIComponent(filterToday)}&perPage=1`);
|
||||||
|
todayCount = resToday.totalItems || 0;
|
||||||
|
} catch {
|
||||||
|
todayCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resTotal = await pbRequest('GET', `/api/collections/site_visitors/records?perPage=1`);
|
||||||
|
totalCount = resTotal.totalItems || 0;
|
||||||
|
} catch {
|
||||||
|
totalCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ todayVisitors: todayCount, totalVisitors: totalCount, isNewVisitor }, 200);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Visitors] Error:', error);
|
||||||
|
return jsonResponse({ error: 'Внутренняя ошибка сервера' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -21,5 +21,8 @@
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"maildev": "^2.2.1"
|
"maildev": "^2.2.1"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"astro-icon": "^1.1.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue