基于 PDO + SQLite 的免配置访问计数器:前端展示「今日 / 昨日 / 总访问」,支持悬浮与内联两种模式、数字滚动动画、多站点统计。无需 MySQL,无需管理后台。
代码
1.counter.php
<?php
// counter.php —— SQLite 版(每个站点标识一个独立数据库文件 counter-<site>.sqlite)
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
// ---------- 跨域白名单:默认为空 = 仅允许本服务器(同源页面)调用 ----------
// 浏览器同源请求本就不需要 CORS 头;若需允许其他域名调用,把来源加入下面数组(须带协议)
$allowedOrigins = [
// 'https://www.example.com',
// 'https://blog.example.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && in_array($origin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
date_default_timezone_set('Asia/Shanghai');
$debug = isset($_GET['debug']) && $_GET['debug'] == '1';
// 仅 visit 记录访问,其余值一律按只读 read 处理
$mode = (isset($_GET['mode']) && $_GET['mode'] === 'visit') ? 'visit' : 'read';
// ---------- 站点标识:每个站点一个独立 SQLite 文件,便于分开管理/备份 ----------
$site = isset($_GET['site']) ? trim($_GET['site']) : 'default';
// 只保留字母/数字/下划线/中划线(含多字节字符),避免脏数据入库;非法 UTF-8 时视为空
$site = preg_replace('/[^\w\-]+/u', '', $site) ?? '';
if ($site === '') $site = 'default';
$site = function_exists('mb_substr') ? mb_substr($site, 0, 64) : substr($site, 0, 64);
$dbFile = __DIR__ . '/counter-' . $site . '.sqlite';
// ---------- 环境自检 ----------
if ($debug) {
$info = [
'php_version' => PHP_VERSION,
'pdo_sqlite' => extension_loaded('pdo_sqlite') ? 'ok' : 'MISSING',
'dir' => __DIR__,
'dir_writable' => is_writable(__DIR__) ? 'ok' : 'NOT WRITABLE',
'db_file' => file_exists($dbFile) ? 'exists' : 'will create',
'mode' => $mode,
];
try {
$pdo = new PDO('sqlite:' . $dbFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$info['db_connect'] = 'ok';
$info['sqlite_version'] = $pdo->query('SELECT sqlite_version()')->fetchColumn();
$info['journal_mode'] = $pdo->query('PRAGMA journal_mode')->fetchColumn();
} catch (Exception $e) {
$info['db_connect'] = 'FAIL: ' . $e->getMessage();
}
echo json_encode(['code' => 0, 'debug' => $info], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$candidate = trim($parts[0]);
// 仅接受合法 IP,防止伪造的 XFF 值污染访客指纹
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
$ip = $candidate;
}
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$visitorHash = substr(hash('sha256', $ip . '|' . $ua . '|' . $site), 0, 16);
$today = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));
try {
if (!extension_loaded('pdo_sqlite')) {
throw new Exception('pdo_sqlite 扩展未启用,请在 PHP 配置中开启');
}
$pdo = new PDO('sqlite:' . $dbFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// WAL 模式:读写并发不互斥,写不阻塞读
$pdo->exec('PRAGMA journal_mode=WAL');
// 平衡性能与安全,WAL 下 NORMAL 已足够
$pdo->exec('PRAGMA synchronous=NORMAL');
// 遇到锁时最多等待 5 秒,避免 SQLITE_BUSY 立即失败
$pdo->exec('PRAGMA busy_timeout=5000');
// ---------- 建表 ----------
$pdo->exec("CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site TEXT NOT NULL,
date TEXT NOT NULL,
visitor_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(site, date, visitor_hash)
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_site_date ON visits (site, date)");
$pdo->exec("CREATE TABLE IF NOT EXISTS stats (
site TEXT PRIMARY KEY,
total INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)");
// ---------- visit 模式:记录访问 ----------
if ($mode === 'visit') {
$pdo->beginTransaction();
try {
// 唯一索引保证同一访客同一天只插一次
$stmt = $pdo->prepare(
"INSERT OR IGNORE INTO visits (site, date, visitor_hash, created_at)
VALUES (?, ?, ?, ?)"
);
$stmt->execute([$site, $today, $visitorHash, time()]);
$inserted = $stmt->rowCount();
if ($inserted > 0) {
// 先尝试 UPDATE,避免每次都 INSERT OR IGNORE
$stmt = $pdo->prepare(
"UPDATE stats SET total = total + 1, updated_at = ? WHERE site = ?"
);
$stmt->execute([time(), $site]);
if ($stmt->rowCount() === 0) {
// 站点首次访问,插入初始行
$stmt = $pdo->prepare(
"INSERT INTO stats (site, total, updated_at) VALUES (?, 1, ?)"
);
$stmt->execute([$site, time()]);
}
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
// 清理 90 天前明细(约 10% 概率触发,避免每次都 DELETE)
if (mt_rand(1, 100) <= 10) {
$cutoff = date('Y-m-d', strtotime('-90 day'));
$stmt = $pdo->prepare("DELETE FROM visits WHERE site = ? AND date < ?");
$stmt->execute([$site, $cutoff]);
}
}
// ---------- 查询(visit / read 都执行,read 模式无写操作) ----------
// 一条 SQL 同时统计今日/昨日,减少一次数据库往返
$stmt = $pdo->prepare(
"SELECT COUNT(CASE WHEN date = ? THEN 1 END) AS today_cnt,
COUNT(CASE WHEN date = ? THEN 1 END) AS yesterday_cnt
FROM visits WHERE site = ? AND date IN (?, ?)"
);
$stmt->execute([$today, $yesterday, $site, $today, $yesterday]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$todayCount = (int)$row['today_cnt'];
$yesterdayCount = (int)$row['yesterday_cnt'];
$stmt = $pdo->prepare("SELECT total FROM stats WHERE site = ?");
$stmt->execute([$site]);
$totalCount = (int)$stmt->fetchColumn();
echo json_encode([
'code' => 0,
'data' => [
'today' => $todayCount,
'yesterday' => $yesterdayCount,
'total' => $totalCount,
'ts' => time(),
]
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
error_log('[VC Counter] ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'code' => 1,
'msg' => '服务器内部错误:' . $e->getMessage(),
'data' => ['today' => 0, 'yesterday' => 0, 'total' => 0]
], JSON_UNESCAPED_UNICODE);
}2.counter.js
/**
* VC Counter —— 轻量站点访问统计组件(纯前端展示 + SQLite 后端,无需数据库服务)
*
* 用法:<script src="counter/counter.js"></script>
* 配置项(均为可选的 data-* 属性):
* data-site 站点标识,默认 "default",用于多站点分开统计
* data-api 接口地址,默认脚本同目录下的 counter.php
* data-mount 内联挂载选择器(如 #footer),缺省为悬浮模式
* data-position 悬浮位置 bottom-right / bottom-left / top-right / top-left
* data-theme 主题 dark / light
* data-style 自定义样式(CSS 文本或 JSON 对象),悬浮 / 内联模式均生效
* data-refresh 自动刷新间隔秒数(>=1 生效),默认不自动刷新,仅加载时请求一次
* data-mount-timeout 等待挂载元素的超时毫秒数,默认 5000
* data-debug 设为 1 输出控制台调试日志
* 详细文档见同目录 README.md
*/
(function () {
'use strict';
if (window.__vc_counter_loaded) return;
window.__vc_counter_loaded = true;
// ========== 1. 定位脚本元素(多重兜底) ==========
var scriptEl = document.currentScript;
if (!scriptEl) {
var scripts = document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i--) {
var src = scripts[i].src || '';
if (src.indexOf('counter.js') !== -1) { scriptEl = scripts[i]; break; }
}
if (!scriptEl && scripts.length) scriptEl = scripts[scripts.length - 1];
}
if (!scriptEl) {
console.error('[VC Counter] 无法定位脚本元素');
return;
}
// ========== 2. 读取属性 ==========
function attr(name, def) {
var v = scriptEl.getAttribute(name);
return (v === null || v === '') ? (def || '') : String(v).trim();
}
var scriptSrc = scriptEl.src || '';
var site = attr('data-site', 'default') || 'default';
var mountSel = attr('data-mount');
var customStyle = attr('data-style');
var position = attr('data-position', 'bottom-right') || 'bottom-right';
var theme = attr('data-theme', 'dark') || 'dark';
var refreshInterval = parseFloat(attr('data-refresh'));
// 默认不自动刷新;仅当设置 data-refresh(>=1 的秒数)时才开启定时刷新
var autoRefresh = !isNaN(refreshInterval) && refreshInterval >= 1;
var mountTimeout = parseInt(attr('data-mount-timeout'), 10) || 5000;
var debug = attr('data-debug') === '1';
function resolveApi(raw) {
if (raw && /^(https?:)?\/\//i.test(raw)) return raw;
if (scriptSrc) {
try { return new URL(raw || 'counter.php', scriptSrc).href; } catch (e) {}
}
return raw || 'counter.php';
}
var api = resolveApi(attr('data-api'));
function log() {
if (!debug) return;
var a = Array.prototype.slice.call(arguments);
a.unshift('[VC Counter]');
console.log.apply(console, a);
}
function warn() {
var a = Array.prototype.slice.call(arguments);
a.unshift('[VC Counter]');
console.warn.apply(console, a);
}
log('配置:', {
api: api, site: site, mount: mountSel || '(无)', style: customStyle || '(无)',
position: position, theme: theme, refresh: autoRefresh ? refreshInterval + 's' : '关闭'
});
// ========== 3. 注入样式 ==========
if (!document.getElementById('vc-counter-style')) {
var styleEl = document.createElement('style');
styleEl.id = 'vc-counter-style';
styleEl.textContent = `
.vc-bar{--vc-dh:18px;display:inline-flex;align-items:center;gap:12px;
padding:6px 12px;border-radius:20px;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
font-size:13px;line-height:1.4;box-shadow:0 4px 16px rgba(0,0,0,.15);
backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
user-select:none;opacity:.95;transition:opacity .3s}
.vc-bar:hover{opacity:1}
.vc-bar.vc-floating{position:fixed;z-index:99999;pointer-events:none}
.vc-bar.vc-floating:hover{pointer-events:auto}
.vc-bottom-right{bottom:16px;right:16px}
.vc-bottom-left{bottom:16px;left:16px}
.vc-top-right{top:16px;right:16px}
.vc-top-left{top:16px;left:16px}
.vc-bar.vc-inline{position:relative;z-index:1;pointer-events:auto}
.vc-theme-dark{background:rgba(30,34,45,.85);color:#e8ecf4;border:1px solid rgba(255,255,255,.08)}
.vc-theme-light{background:rgba(255,255,255,.9);color:#1a1d29;border:1px solid rgba(0,0,0,.06)}
.vc-item{display:flex;align-items:center;gap:4px;white-space:nowrap}
.vc-label{font-size:12px;opacity:.7}
.vc-number{display:inline-flex;overflow:hidden;height:var(--vc-dh);line-height:var(--vc-dh);
vertical-align:bottom;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:.5px}
.vc-digit{display:inline-block;width:.65em;height:var(--vc-dh);overflow:hidden;position:relative}
.vc-strip{display:flex;flex-direction:column;
transition:transform .7s cubic-bezier(.23,1,.32,1);will-change:transform}
.vc-strip span{display:block;height:var(--vc-dh);line-height:var(--vc-dh);text-align:center}
.vc-dot{width:6px;height:6px;border-radius:50%;background:#4ade80;
box-shadow:0 0 6px #4ade80;animation:vc-pulse 1.6s ease-in-out infinite}
@keyframes vc-pulse{0%,100%{opacity:1}50%{opacity:.3}}
@media (max-width:480px){.vc-bar{--vc-dh:15px;gap:8px;padding:4px 8px;font-size:11px}
.vc-label{font-size:10px}.vc-digit{width:.55em}}
`;
document.head.appendChild(styleEl);
}
// ========== 4. 构建计数器 ==========
function buildBar() {
var bar = document.createElement('div');
bar.className = 'vc-bar vc-theme-' + theme;
bar.innerHTML =
'<span class="vc-dot"></span>' +
'<div class="vc-item"><span class="vc-label">今日访问</span><span class="vc-number" data-key="today"></span></div>' +
'<div class="vc-item"><span class="vc-label">昨日访问</span><span class="vc-number" data-key="yesterday"></span></div>' +
'<div class="vc-item"><span class="vc-label">总访问</span><span class="vc-number" data-key="total"></span></div>';
return bar;
}
function applyPosition(bar, isInline) {
if (isInline) { bar.classList.add('vc-inline'); }
else {
bar.classList.add('vc-floating');
if (!customStyle) bar.classList.add('vc-' + position);
}
// 自定义样式:悬浮 / 内联模式均生效,支持 CSS 文本或 JSON 对象
if (customStyle) {
var css = customStyle.trim();
if (css.charAt(0) === '{') {
try {
var obj = JSON.parse(css);
Object.keys(obj).forEach(function (k) { bar.style[k] = obj[k]; });
} catch (e) { bar.style.cssText = css; }
} else {
bar.style.cssText = css;
}
}
}
// ========== 5. 等待挂载元素 ==========
function waitForElement(selector, timeout, cb) {
function tryFind() {
var el = document.querySelector(selector);
if (el) return el;
// 容错:如果用户写的是 id 值(没带 #),再试一次
if (selector.charAt(0) !== '#' && selector.charAt(0) !== '.' &&
selector.charAt(0) !== '[' && selector.indexOf(' ') === -1) {
try { el = document.getElementById(selector); } catch (e) {}
}
return el;
}
var el = tryFind();
if (el) { cb(el); return; }
var done = false;
var start = Date.now();
var observer = null;
var timer = null;
function finish(found) {
if (done) return;
done = true;
if (observer) observer.disconnect();
if (timer) clearInterval(timer);
cb(found);
}
observer = new MutationObserver(function () {
var found = tryFind();
if (found) finish(found);
});
observer.observe(document.documentElement || document, { childList: true, subtree: true });
timer = setInterval(function () {
var found = tryFind();
if (found) return finish(found);
if (Date.now() - start > timeout) finish(null);
}, 200);
}
// ========== 6. 数字滚动 + AJAX ==========
function startCounter(bar) {
var els = {
today: bar.querySelector('[data-key="today"]'),
yesterday: bar.querySelector('[data-key="yesterday"]'),
total: bar.querySelector('[data-key="total"]')
};
function makeStrip(d) {
var s = document.createElement('span');
s.className = 'vc-strip';
for (var i = 0; i < 10; i++) {
var n = document.createElement('span');
n.textContent = i;
s.appendChild(n);
}
s.style.transform = 'translateY(-' + (d * 10) + '%)';
return s;
}
function updateNumber(container, value) {
if (container.dataset.value === String(value)) return;
var str = String(value);
var digits = container.querySelectorAll('.vc-digit');
if (digits.length === str.length) {
for (var i = 0; i < str.length; i++) {
var d = parseInt(str[i], 10);
var strip = digits[i].querySelector('.vc-strip');
if (strip) strip.style.transform = 'translateY(-' + (d * 10) + '%)';
}
} else {
// 位数变化(含首次加载)时:先按 0 位渲染并强制回流,再滚到目标数字;
// 否则新建 strip 直接带终值 transform,浏览器不渲染起始态,滚动动画不会触发
container.innerHTML = '';
for (var j = 0; j < str.length; j++) {
var dEl = document.createElement('span');
dEl.className = 'vc-digit';
dEl.appendChild(makeStrip(0));
container.appendChild(dEl);
}
void container.offsetWidth;
var strips = container.querySelectorAll('.vc-strip');
for (j = 0; j < str.length; j++) {
strips[j].style.transform =
'translateY(-' + (parseInt(str[j], 10) * 10) + '%)';
}
}
container.dataset.value = String(value);
}
Object.keys(els).forEach(function (k) { updateNumber(els[k], 0); });
var isFirst = true;
var inflight = false;
var failCount = 0;
var timer = null;
function fetchData(onDone) {
if (inflight) { if (onDone) onDone(); return; }
inflight = true;
var mode = isFirst ? 'visit' : 'read';
var url = api + (api.indexOf('?') === -1 ? '?' : '&') +
'site=' + encodeURIComponent(site) +
'&mode=' + mode + '&t=' + Date.now();
log('请求(' + mode + '):', url);
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.timeout = 8000;
// 统一收尾:无论成败只触发一次(超时/出错后 readyState=4 不会重复结算)
function settle(ok) {
if (!inflight) return;
inflight = false;
failCount = ok ? 0 : failCount + 1;
if (onDone) onDone();
}
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status >= 200 && xhr.status < 300) {
try {
var json = JSON.parse(xhr.responseText);
if (json && json.code === 0 && json.data) {
updateNumber(els.today, json.data.today || 0);
updateNumber(els.yesterday, json.data.yesterday || 0);
updateNumber(els.total, json.data.total || 0);
isFirst = false;
log('更新:', json.data);
settle(true);
} else {
warn('数据异常', json);
settle(false);
}
} catch (e) {
warn('JSON 解析失败', xhr.responseText);
settle(false);
}
} else {
warn('HTTP ' + xhr.status + ' ' + url);
settle(false);
}
};
xhr.ontimeout = function () { warn('请求超时'); settle(false); };
xhr.onerror = function () { warn('网络错误'); settle(false); };
xhr.send();
}
function nextDelay() {
// 正常按 data-refresh 轮询;连续失败时指数退避,最长放大 8 倍
return refreshInterval * 1000 * Math.min(Math.pow(2, failCount), 8);
}
// 定时刷新采用 setTimeout 链式调度:上一次请求完成后才安排下一次,避免请求堆积
function schedule() {
if (!autoRefresh || document.hidden) return;
stop();
timer = setTimeout(function () { fetchData(schedule); }, nextDelay());
}
function start() {
stop();
fetchData(schedule);
}
function stop() {
if (timer) { clearTimeout(timer); timer = null; }
}
// 仅自动刷新模式下监听页面可见性:切走暂停,切回立即刷新
if (autoRefresh) {
document.addEventListener('visibilitychange', function () {
if (document.hidden) stop();
else start();
});
}
start();
}
// ========== 7. 初始化 ==========
function init() {
if (!document.body) { setTimeout(init, 50); return; }
if (mountSel) {
log('查找挂载元素:' + mountSel);
waitForElement(mountSel, mountTimeout, function (el) {
var bar = buildBar();
if (el) {
log('✓ 找到挂载元素,使用内联模式');
applyPosition(bar, true);
el.appendChild(bar);
} else {
warn('✗ 等待 ' + mountTimeout + 'ms 未找到 ' + mountSel + ',退化为悬浮模式');
applyPosition(bar, false);
document.body.appendChild(bar);
}
startCounter(bar);
});
} else {
var bar = buildBar();
log('未配置 data-mount,使用悬浮模式');
applyPosition(bar, false);
document.body.appendChild(bar);
startCounter(bar);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();3.counter-json.php(可选)
<?php
// counter-json.php —— JSON 文件存储版(无需 pdo_sqlite 扩展,每个站点标识一个独立数据文件 counter-<site>.json)
// 接口与 counter.php 完全一致,前端通过 data-api 指向本文件即可切换
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
// ---------- 跨域白名单:默认为空 = 仅允许本服务器(同源页面)调用 ----------
// 浏览器同源请求本就不需要 CORS 头;若需允许其他域名调用,把来源加入下面数组(须带协议)
$allowedOrigins = [
// 'https://www.example.com',
// 'https://blog.example.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && in_array($origin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
date_default_timezone_set('Asia/Shanghai');
$debug = isset($_GET['debug']) && $_GET['debug'] == '1';
// 仅 visit 记录访问,其余值一律按只读 read 处理
$mode = (isset($_GET['mode']) && $_GET['mode'] === 'visit') ? 'visit' : 'read';
// ---------- 站点标识:每个站点一个独立 JSON 文件,便于分开管理/备份 ----------
$site = isset($_GET['site']) ? trim($_GET['site']) : 'default';
// 只保留字母/数字/下划线/中划线(含多字节字符),避免脏数据入库;非法 UTF-8 时视为空
$site = preg_replace('/[^\w\-]+/u', '', $site) ?? '';
if ($site === '') $site = 'default';
$site = function_exists('mb_substr') ? mb_substr($site, 0, 64) : substr($site, 0, 64);
$dataFile = __DIR__ . '/counter-' . $site . '.json';
// ---------- 环境自检 ----------
if ($debug) {
$info = [
'php_version' => PHP_VERSION,
'storage' => 'json',
'dir' => __DIR__,
'dir_writable' => is_writable(__DIR__) ? 'ok' : 'NOT WRITABLE',
'data_file' => file_exists($dataFile) ? 'exists' : 'will create',
'mode' => $mode,
];
try {
$data = readStore($dataFile);
$info['storage_check'] = 'ok';
$info['total'] = (int)($data['total'] ?? 0);
} catch (Exception $e) {
$info['storage_check'] = 'FAIL: ' . $e->getMessage();
}
echo json_encode(['code' => 0, 'debug' => $info], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$candidate = trim($parts[0]);
// 仅接受合法 IP,防止伪造的 XFF 值污染访客指纹
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
$ip = $candidate;
}
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$visitorHash = substr(hash('sha256', $ip . '|' . $ua . '|' . $site), 0, 16);
$today = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));
try {
if ($mode === 'visit') {
// ---------- visit 模式:独占锁下读改写,记录访问 ----------
$fp = fopen($dataFile, 'c+');
if (!$fp) throw new Exception('无法打开数据文件(目录不可写?)');
if (!flock($fp, LOCK_EX)) { fclose($fp); throw new Exception('文件锁获取失败'); }
$data = decodeStore(stream_get_contents($fp));
if (!isset($data['days'][$today]) || !in_array($visitorHash, $data['days'][$today], true)) {
// 同一访客同一天只记一次;总访问数仅在新增访客时 +1
$data['days'][$today][] = $visitorHash;
$data['total'] = (int)($data['total'] ?? 0) + 1;
$data['updated_at'] = time();
// 清理 90 天前明细(约 10% 概率触发,避免每次都做多余清理)
if (mt_rand(1, 100) <= 10) {
$cutoff = date('Y-m-d', strtotime('-90 day'));
foreach (array_keys($data['days']) as $d) {
if ($d < $cutoff) unset($data['days'][$d]);
}
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
fflush($fp);
}
flock($fp, LOCK_UN);
fclose($fp);
} else {
// ---------- read 模式:共享锁只读,无写操作 ----------
$data = readStore($dataFile);
}
// ---------- 查询(visit / read 都执行) ----------
$todayCount = isset($data['days'][$today]) ? count($data['days'][$today]) : 0;
$yesterdayCount = isset($data['days'][$yesterday]) ? count($data['days'][$yesterday]) : 0;
$totalCount = (int)($data['total'] ?? 0);
echo json_encode([
'code' => 0,
'data' => [
'today' => $todayCount,
'yesterday' => $yesterdayCount,
'total' => $totalCount,
'ts' => time(),
]
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
error_log('[VC Counter] ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'code' => 1,
'msg' => '服务器内部错误:' . $e->getMessage(),
'data' => ['today' => 0, 'yesterday' => 0, 'total' => 0]
], JSON_UNESCAPED_UNICODE);
}
// ========== JSON 存储工具函数 ==========
// 文件结构:{"total":0,"updated_at":0,"days":{"YYYY-MM-DD":["访客hash",...]}}
// 共享锁只读;文件不存在时返回空数据
function readStore($file) {
if (!file_exists($file)) return ['total' => 0, 'updated_at' => 0, 'days' => []];
$fp = fopen($file, 'r');
if (!$fp) throw new Exception('无法读取数据文件');
if (!flock($fp, LOCK_SH)) { fclose($fp); throw new Exception('文件锁获取失败'); }
$raw = stream_get_contents($fp);
flock($fp, LOCK_UN);
fclose($fp);
return decodeStore($raw);
}
// 解码并补齐默认结构;文件损坏时从空数据开始(不中断计数)
function decodeStore($raw) {
$data = json_decode((string)$raw, true);
if (!is_array($data)) $data = [];
$data['total'] = isset($data['total']) ? (int)$data['total'] : 0;
$data['updated_at'] = isset($data['updated_at']) ? (int)$data['updated_at'] : 0;
if (!isset($data['days']) || !is_array($data['days'])) $data['days'] = [];
// 过滤掉非数组的天明细,防止后续 count() 报错
$data['days'] = array_filter($data['days'], 'is_array');
return $data;
}
文件说明
| 文件 | 说明 |
|---|---|
counter.js | 前端脚本:展示计数条、请求接口、数字滚动动画 |
counter.php | 后端接口:记录/读取访问数据(SQLite 存储) |
counter-json.php | 后端接口:记录/读取访问数据(JSON 文件存储,无需 pdo_sqlite 扩展,接口与 counter.php 完全一致) |
counter-<site>.sqlite | 数据库文件,每个站点标识一个独立库(如 counter-default.sqlite),首次访问自动创建,需目录可写 |
counter-<site>.json | JSON 版数据文件(仅使用 counter-json.php 时生成),每个站点标识一个独立文件 |
环境要求
PHP 7.0+
- SQLite 版(
counter.php):需启用pdo_sqlite扩展 - JSON 版(
counter-json.php):无扩展要求,任何 PHP 环境均可运行
- SQLite 版(
counter目录对 PHP 进程可写(用于创建数据文件)
快速开始
- 将整个
counter目录上传到网站(如/counter/)。 - 在需要展示的页面引入脚本(建议放在
</body>前):
<script src="/counter/counter.js"></script>- 打开页面即可看到右下角悬浮的统计条,首次访问自动建库并记录。
- 环境自检:浏览器访问
http://你的域名/counter/counter.php?debug=1,返回 JSON 中各项为ok即正常。
前端配置项(data-* 属性,均可选)
| 属性 | 默认值 | 说明 |
|---|---|---|
data-site | default | 站点标识,同一份代码可给多个站点分别计数 |
data-api | 同目录 counter.php | 接口地址,跨域部署时填完整 URL |
data-mount | 空(悬浮模式) | 内联挂载元素选择器,如 #footer-stats;等待超时后自动退化为悬浮模式 |
data-position | bottom-right | 悬浮位置:bottom-right / bottom-left / top-right / top-left |
data-theme | dark | 主题:dark / light |
data-style | 空 | 自定义样式,支持 CSS 文本或 JSON 对象;悬浮 / 内联模式均生效 |
data-refresh | 关闭 | 自动刷新间隔(秒,≥1 生效);默认不自动刷新,仅页面加载时请求一次 |
data-mount-timeout | 5000 | 等待挂载元素出现的超时(毫秒) |
data-debug | 0 | 设为 1 时在控制台输出调试日志 |
示例
<!-- 最简用法:默认不自动刷新,页面加载时统计并展示一次 -->
<script src="/counter/counter.js"></script>
<!-- 独立站点标识 + 内联挂载 + 10 秒自动刷新 -->
<script src="/counter/counter.js"
data-site="blog"
data-mount="#stats"
data-theme="light"
data-refresh="10"
data-debug="1"></script>刷新行为说明
- 默认(未设置
data-refresh):页面加载时请求一次(visit模式记录本次访问并返回数据),之后不再发起任何请求。 设置
data-refresh="N"(N ≥ 1):开启定时刷新,每 N 秒读取一次最新数据(read模式,不重复计数):- 采用链式调度,上一次请求完成后才发起下一次,不会出现请求堆积;
- 连续失败时自动指数退避(间隔最长放大到 8 倍),恢复成功后自动回到正常间隔;
- 切换到后台标签页时暂停刷新,切回时立即刷新一次。
后端接口
记录并读取(visit 模式)
GET /counter/counter.php?site=blog&mode=visit只读数据(read 模式,不计入统计)
GET /counter/counter.php?site=blog&mode=read返回:
{
"code": 0,
"data": { "today": 12, "yesterday": 45, "total": 1024, "ts": 1760000000 }
}环境自检(debug)
GET /counter/counter.php?debug=1数据存储
两个后端版本数据结构等价、接口一致,任选其一即可,不可混用(数据互不相通)。
SQLite 版(默认,counter.php)
- 按站点分库:每个站点标识对应一个独立数据库文件
counter-<站点标识>.sqlite(如counter-blog.sqlite),互不干扰,便于分开管理、备份与迁移。 - 首次使用某站点标识时自动建库,各站点数据互不关联。
- 库内
visits表:按「日期 + 访客指纹(IP + UA 哈希)」唯一去重,同一访客每天只计一次;明细保留 90 天,过期自动清理。 - 库内
stats表:该站点累计总访问数。 - 备份只需复制对应站点的
counter-<site>.sqlite文件。
JSON 版(counter-json.php)
- 主机不支持
pdo_sqlite时使用,前端加data-api指向本文件即可:
<script src="/counter/counter.js" data-api="/counter/counter-json.php"></script>- 每个站点标识一个独立文件
counter-<站点标识>.json(如counter-blog.json),首次访问自动创建。 - 文件结构:
{"total": 累计总数, "updated_at": 更新时间, "days": {"日期": ["访客指纹", ...]}},同一访客每天只计一次;明细同样保留 90 天。 - 写入采用独占文件锁(
flock),并发访问不会互相覆盖;高并发大流量站点建议优先用 SQLite 版。 - 备份只需复制对应站点的
counter-<site>.json文件。
常见问题
Q:只想允许本站调用,禁止其他网站调用?
默认即是:counter.php 顶部的 $allowedOrigins 白名单为空时,仅同源页面可读取数据,其他域名的浏览器请求会被拦截。若需放开某个域名,把来源(须带协议)加入数组即可,如 'https://www.example.com'。注意:该限制对浏览器生效,无法拦截服务器端的直接请求。
Q:页面显示 0 或接口返回 500?
访问 counter.php?debug=1 自检:确认 pdo_sqlite 为 ok、dir_writable 为 ok;若扩展缺失需在 php.ini 中开启 extension=pdo_sqlite,或改用 JSON 版 counter-json.php(无扩展要求)。
Q:想开启自动刷新?
加上 data-refresh="N"(N 为秒数)即可,例如 data-refresh="30" 表示每 30 秒刷新一次。
Q:统计数比实际访问量小?
同一访客(IP + UA)同一天只计一次,属于正常去重。
Q:多个页面 / 多个域名如何共用或分开统计?
各页面 data-site 填相同标识即合并统计;填不同标识则分开统计。
Q:如何重置某个站点的数据?
停止该站点访问后删除对应的 counter-<site>.sqlite(如有 -wal / -shm 伴生文件一并删除),下次访问自动重建。
评论 (0)