web-counter 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """web-counter: A self-hosted website visit counter."""
web_counter/auth.py ADDED
@@ -0,0 +1,44 @@
1
+ """Authentication: bcrypt password hashing and session management."""
2
+
3
+ import secrets
4
+ import time
5
+
6
+ import bcrypt
7
+
8
+ # In-memory session store: token -> {"username": str, "created_at": float}
9
+ _sessions: dict[str, dict] = {}
10
+
11
+ SESSION_MAX_AGE = 86400 # 24 hours
12
+
13
+
14
+ def hash_password(password: str) -> str:
15
+ """Hash a password with bcrypt."""
16
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
17
+
18
+
19
+ def verify_password(password: str, password_hash: str) -> bool:
20
+ """Verify a password against its bcrypt hash."""
21
+ return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
22
+
23
+
24
+ def create_session(username: str) -> str:
25
+ """Create a new session token for the given username."""
26
+ token = secrets.token_hex(32)
27
+ _sessions[token] = {"username": username, "created_at": time.time()}
28
+ return token
29
+
30
+
31
+ def validate_session(token: str) -> str | None:
32
+ """Validate a session token. Returns username if valid, None otherwise."""
33
+ if token not in _sessions:
34
+ return None
35
+ session = _sessions[token]
36
+ if time.time() - session["created_at"] > SESSION_MAX_AGE:
37
+ del _sessions[token]
38
+ return None
39
+ return session["username"]
40
+
41
+
42
+ def destroy_session(token: str):
43
+ """Destroy a session token."""
44
+ _sessions.pop(token, None)
web_counter/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """CLI: start, restart, stop, createsuperuser, export-js."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+
13
+ def _read_pid(pid_file: str) -> int | None:
14
+ """Read PID from file."""
15
+ try:
16
+ with open(pid_file, "r") as f:
17
+ return int(f.read().strip())
18
+ except (FileNotFoundError, ValueError):
19
+ return None
20
+
21
+
22
+ def _is_running(pid: int | None) -> bool:
23
+ """Check if a process with given PID is running."""
24
+ if pid is None:
25
+ return False
26
+ try:
27
+ os.kill(pid, 0)
28
+ return True
29
+ except OSError:
30
+ return False
31
+
32
+
33
+ def start_command(args):
34
+ """Start the web-counter server in the background."""
35
+ from .config import Config
36
+
37
+ config = Config(
38
+ salt=args.salt,
39
+ host=args.host,
40
+ port=args.port,
41
+ db_path=args.db_path,
42
+ pid_file=args.pid_file,
43
+ allowed_origins=args.allowed_origins,
44
+ rate_limit=args.rate_limit,
45
+ )
46
+ config.validate()
47
+
48
+ # Check if already running
49
+ pid = _read_pid(config.pid_file)
50
+ if _is_running(pid):
51
+ print(f"✗ web-counter 已在运行中 (PID: {pid})")
52
+ sys.exit(1)
53
+
54
+ # Ensure data directory exists
55
+ Path(config.db_path).parent.mkdir(parents=True, exist_ok=True)
56
+ Path(config.pid_file).parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ # Build command
59
+ cmd = [
60
+ sys.executable, "-m", "uvicorn", "web_counter.main:app",
61
+ "--host", config.host,
62
+ "--port", str(config.port),
63
+ "--log-level", "info",
64
+ ]
65
+
66
+ # Set environment variables for the subprocess
67
+ env = os.environ.copy()
68
+ env["COUNTER_SALT"] = config.salt
69
+ env["COUNTER_HOST"] = config.host
70
+ env["COUNTER_PORT"] = str(config.port)
71
+ env["COUNTER_DB_PATH"] = config.db_path
72
+ env["COUNTER_PID_FILE"] = config.pid_file
73
+ env["COUNTER_ALLOWED_ORIGINS"] = config.allowed_origins
74
+ env["COUNTER_RATE_LIMIT"] = str(config.rate_limit)
75
+
76
+ # Start process (background)
77
+ proc = subprocess.Popen(
78
+ cmd,
79
+ env=env,
80
+ stdout=subprocess.DEVNULL,
81
+ stderr=subprocess.DEVNULL,
82
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
83
+ )
84
+
85
+ # Write PID file
86
+ with open(config.pid_file, "w") as f:
87
+ f.write(str(proc.pid))
88
+
89
+ origin = f"http://{config.host}:{config.port}"
90
+ print(f"✓ web-counter 已启动,监听 {origin}")
91
+ print()
92
+ print("---------- 复制以下代码到网页 </body> 前 ----------")
93
+ print()
94
+ print(f'<script async src="{origin}/counter.js"></script>')
95
+ print()
96
+ print('<span style="display:none" class="counter-container" data-counter-style="card">')
97
+ print(" 本站访问次数 <span data-pv-site></span> 次 ·")
98
+ print(" 今日访问量 <span data-pv-today></span> 次 ·")
99
+ print(" 今日访客 <span data-uv-today></span> 人 ·")
100
+ print(" 总访问量 <span data-pv-site></span> 次 ·")
101
+ print(" 总访客 <span data-uv-site></span> 人")
102
+ print("</span>")
103
+ print()
104
+ print("------------------------------------------------------")
105
+
106
+
107
+ def stop_command(args):
108
+ """Stop the running web-counter server."""
109
+ from .config import Config
110
+
111
+ config = Config(pid_file=args.pid_file)
112
+
113
+ pid = _read_pid(config.pid_file)
114
+ if not _is_running(pid):
115
+ print("✗ web-counter 未在运行")
116
+ if Path(config.pid_file).exists():
117
+ Path(config.pid_file).unlink()
118
+ sys.exit(1)
119
+
120
+ # Send SIGTERM for graceful shutdown
121
+ os.kill(pid, signal.SIGTERM)
122
+
123
+ # Wait for process to exit (max 10 seconds)
124
+ for _ in range(100):
125
+ if not _is_running(pid):
126
+ break
127
+ time.sleep(0.1)
128
+
129
+ if _is_running(pid):
130
+ print(f"✗ 无法停止进程 (PID: {pid}),尝试强制终止...")
131
+ try:
132
+ os.kill(pid, signal.SIGKILL)
133
+ except OSError:
134
+ pass
135
+ time.sleep(0.5)
136
+
137
+ # Clean up PID file
138
+ if Path(config.pid_file).exists():
139
+ Path(config.pid_file).unlink()
140
+
141
+ print("✓ web-counter 已停止")
142
+
143
+
144
+ def restart_command(args):
145
+ """Restart the web-counter server."""
146
+ print("正在重启 web-counter...")
147
+ stop_command(args)
148
+ start_command(args)
149
+
150
+
151
+ def createsuperuser_command(args):
152
+ """Create an admin user interactively."""
153
+ from .config import Config
154
+ from .database import init_db, create_admin
155
+ from .auth import hash_password
156
+
157
+ config = Config(
158
+ salt="dummy",
159
+ db_path=args.db_path,
160
+ )
161
+
162
+ username = input("Username: ").strip()
163
+ if not username:
164
+ print("✗ 用户名不能为空")
165
+ sys.exit(1)
166
+
167
+ password = input("Password: ").strip()
168
+ if not password:
169
+ print("✗ 密码不能为空")
170
+ sys.exit(1)
171
+
172
+ confirm = input("Confirm password: ").strip()
173
+ if password != confirm:
174
+ print("✗ 两次输入的密码不一致")
175
+ sys.exit(1)
176
+
177
+ async def _create():
178
+ await init_db(config.db_path)
179
+ password_hash = hash_password(password)
180
+ await create_admin(config.db_path, username, password_hash)
181
+
182
+ asyncio.run(_create())
183
+ print(f"✓ 管理员 {username} 创建成功")
184
+
185
+
186
+ def export_js_command(args):
187
+ """Export counter.js to stdout."""
188
+ js_path = Path(__file__).parent / "static" / "counter.js"
189
+ print(js_path.read_text(encoding="utf-8"))
190
+
191
+
192
+ def main():
193
+ """CLI entry point."""
194
+ parser = argparse.ArgumentParser(
195
+ prog="web-counter",
196
+ description="A self-hosted website visit counter",
197
+ )
198
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
199
+
200
+ # start
201
+ p = subparsers.add_parser("start", help="Start the server in background")
202
+ p.add_argument("--salt", default=None, help="IP hash salt (required)")
203
+ p.add_argument("--host", default=None, help="Listen address (default: 0.0.0.0)")
204
+ p.add_argument("--port", type=int, default=None, help="Listen port (default: 8000)")
205
+ p.add_argument("--db-path", default=None, help="SQLite database path")
206
+ p.add_argument("--pid-file", default=None, help="PID file path")
207
+ p.add_argument("--allowed-origins", default=None, help="CORS allowed origins")
208
+ p.add_argument("--rate-limit", type=int, default=None, help="Rate limit per IP per minute")
209
+
210
+ # restart
211
+ p = subparsers.add_parser("restart", help="Restart the server")
212
+ p.add_argument("--salt", default=None, help="IP hash salt")
213
+ p.add_argument("--host", default=None, help="Listen address")
214
+ p.add_argument("--port", type=int, default=None, help="Listen port")
215
+ p.add_argument("--db-path", default=None, help="SQLite database path")
216
+ p.add_argument("--pid-file", default=None, help="PID file path")
217
+ p.add_argument("--allowed-origins", default=None, help="CORS allowed origins")
218
+ p.add_argument("--rate-limit", type=int, default=None, help="Rate limit per IP per minute")
219
+
220
+ # stop
221
+ p = subparsers.add_parser("stop", help="Stop the server")
222
+ p.add_argument("--pid-file", default=None, help="PID file path")
223
+
224
+ # createsuperuser
225
+ p = subparsers.add_parser("createsuperuser", help="Create an admin user")
226
+ p.add_argument("--db-path", default=None, help="SQLite database path")
227
+
228
+ # export-js
229
+ subparsers.add_parser("export-js", help="Export counter.js to stdout")
230
+
231
+ args = parser.parse_args()
232
+
233
+ if args.command == "start":
234
+ start_command(args)
235
+ elif args.command == "restart":
236
+ restart_command(args)
237
+ elif args.command == "stop":
238
+ stop_command(args)
239
+ elif args.command == "createsuperuser":
240
+ createsuperuser_command(args)
241
+ elif args.command == "export-js":
242
+ export_js_command(args)
243
+ else:
244
+ parser.print_help()
245
+ sys.exit(1)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
web_counter/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """Configuration management.
2
+
3
+ Priority (highest to lowest): CLI arguments > environment variables > .env file.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def _load_dotenv():
11
+ """Load .env file from current working directory if it exists."""
12
+ env_path = Path.cwd() / ".env"
13
+ if not env_path.exists():
14
+ return
15
+ with open(env_path, "r", encoding="utf-8") as f:
16
+ for line in f:
17
+ line = line.strip()
18
+ if not line or line.startswith("#") or "=" not in line:
19
+ continue
20
+ key, _, value = line.partition("=")
21
+ key = key.strip()
22
+ value = value.strip().strip('"').strip("'")
23
+ if key and key not in os.environ:
24
+ os.environ[key] = value
25
+
26
+
27
+ _load_dotenv()
28
+
29
+
30
+ class Config:
31
+ """Application configuration."""
32
+
33
+ def __init__(self, **kwargs):
34
+ self.salt = kwargs.get("salt") or os.environ.get("COUNTER_SALT", "")
35
+ self.host = kwargs.get("host") or os.environ.get("COUNTER_HOST", "0.0.0.0")
36
+ self.port = int(kwargs.get("port") or os.environ.get("COUNTER_PORT", "8000"))
37
+ self.db_path = kwargs.get("db_path") or os.environ.get("COUNTER_DB_PATH", "./data/counter.db")
38
+ self.pid_file = kwargs.get("pid_file") or os.environ.get("COUNTER_PID_FILE", "./data/counter.pid")
39
+ self.allowed_origins = kwargs.get("allowed_origins") or os.environ.get("COUNTER_ALLOWED_ORIGINS", "*")
40
+ self.rate_limit = int(kwargs.get("rate_limit") or os.environ.get("COUNTER_RATE_LIMIT", "60"))
41
+
42
+ def validate(self):
43
+ """Validate required configuration."""
44
+ if not self.salt:
45
+ raise ValueError(
46
+ "COUNTER_SALT is required. Set it via --salt, COUNTER_SALT env var, "
47
+ "or .env file.\nGenerate one with: openssl rand -hex 16"
48
+ )
@@ -0,0 +1,293 @@
1
+ """Dashboard and login page server-rendered HTML."""
2
+
3
+ import json
4
+
5
+
6
+ def get_login_html(error: str = "") -> str:
7
+ """Return the login page HTML."""
8
+ error_html = f'<p style="color:#e74c3c;text-align:center;margin-bottom:16px;">{error}</p>' if error else ""
9
+ return f"""<!DOCTYPE html>
10
+ <html lang="zh-CN">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <title>web-counter - Login</title>
15
+ <style>
16
+ * {{ margin:0; padding:0; box-sizing:border-box; }}
17
+ body {{
18
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
+ background: #f5f5f5;
20
+ display: flex; align-items: center; justify-content: center;
21
+ min-height: 100vh;
22
+ }}
23
+ .login-box {{
24
+ background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
25
+ padding: 40px; width: 360px; max-width: 90vw;
26
+ }}
27
+ h1 {{ text-align:center; font-size:20px; margin-bottom:24px; color:#333; }}
28
+ label {{ display:block; font-size:14px; color:#666; margin-bottom:6px; }}
29
+ input[type="text"], input[type="password"] {{
30
+ width:100%; padding:10px 12px; border:1px solid #ddd; border-radius:4px;
31
+ font-size:14px; margin-bottom:16px; outline:none;
32
+ }}
33
+ input:focus {{ border-color:#4a90d9; }}
34
+ button {{
35
+ width:100%; padding:10px; background:#4a90d9; color:#fff; border:none;
36
+ border-radius:4px; font-size:15px; cursor:pointer;
37
+ }}
38
+ button:hover {{ background:#357abd; }}
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <div class="login-box">
43
+ <h1>web-counter 管理后台</h1>
44
+ {error_html}
45
+ <form method="POST" action="/dashboard">
46
+ <label for="username">用户名</label>
47
+ <input type="text" id="username" name="username" required autofocus>
48
+ <label for="password">密码</label>
49
+ <input type="password" id="password" name="password" required>
50
+ <button type="submit">登 录</button>
51
+ </form>
52
+ </div>
53
+ </body>
54
+ </html>"""
55
+
56
+
57
+ def get_dashboard_html(
58
+ today_pv: int = 0,
59
+ today_uv: int = 0,
60
+ site_pv: int = 0,
61
+ site_uv: int = 0,
62
+ offsets: dict | None = None,
63
+ daily_stats: list | None = None,
64
+ ) -> str:
65
+ """Return the dashboard page HTML with embedded data."""
66
+ if offsets is None:
67
+ offsets = {}
68
+ if daily_stats is None:
69
+ daily_stats = []
70
+
71
+ stats_json = json.dumps({
72
+ "today_pv": today_pv,
73
+ "today_uv": today_uv,
74
+ "site_pv": site_pv,
75
+ "site_uv": site_uv,
76
+ "offsets": offsets,
77
+ "daily": daily_stats,
78
+ })
79
+
80
+ return f"""<!DOCTYPE html>
81
+ <html lang="zh-CN">
82
+ <head>
83
+ <meta charset="UTF-8">
84
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
85
+ <title>web-counter - Dashboard</title>
86
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
87
+ <style>
88
+ * {{ margin:0; padding:0; box-sizing:border-box; }}
89
+ body {{
90
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
91
+ background: #f5f5f5; color:#333;
92
+ }}
93
+ .header {{
94
+ background:#fff; border-bottom:1px solid #e0e0e0; padding:12px 24px;
95
+ display:flex; align-items:center; justify-content:space-between;
96
+ }}
97
+ .header h1 {{ font-size:18px; }}
98
+ .header a {{ color:#e74c3c; text-decoration:none; font-size:14px; }}
99
+ .container {{ max-width:960px; margin:0 auto; padding:24px; }}
100
+ .cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:16px; margin-bottom:24px; }}
101
+ .card {{
102
+ background:#fff; border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,0.06);
103
+ padding:20px;
104
+ }}
105
+ .card .label {{ font-size:13px; color:#999; margin-bottom:8px; }}
106
+ .card .value {{ font-size:28px; font-weight:600; }}
107
+ .card.today {{ border-left:3px solid #4a90d9; }}
108
+ .card.site {{ border-left:3px solid #27ae60; }}
109
+ .section {{ background:#fff; border-radius:8px; box-shadow:0 1px 4px rgba(0,0,0,0.06); padding:20px; margin-bottom:24px; }}
110
+ .section h2 {{ font-size:16px; margin-bottom:16px; color:#333; }}
111
+ .chart-wrap {{ position:relative; width:100%; height:300px; }}
112
+ form.manage {{ display:flex; flex-direction:column; gap:12px; }}
113
+ .row {{ display:flex; gap:12px; align-items:flex-end; flex-wrap:wrap; }}
114
+ .field {{ display:flex; flex-direction:column; gap:4px; }}
115
+ .field label {{ font-size:13px; color:#666; }}
116
+ .field input, .field select {{
117
+ padding:8px 10px; border:1px solid #ddd; border-radius:4px; font-size:14px;
118
+ }}
119
+ button.btn {{
120
+ padding:8px 16px; border:none; border-radius:4px; font-size:14px; cursor:pointer; color:#fff;
121
+ }}
122
+ .btn-primary {{ background:#4a90d9; }}
123
+ .btn-danger {{ background:#e74c3c; }}
124
+ .btn-primary:hover {{ background:#357abd; }}
125
+ .btn-danger:hover {{ background:#c0392b; }}
126
+ pre {{ background:#f8f8f8; border:1px solid #eee; border-radius:4px; padding:16px; font-size:13px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; }}
127
+ .toast {{ position:fixed; top:20px; right:20px; padding:12px 20px; border-radius:4px; color:#fff; font-size:14px; z-index:999; display:none; }}
128
+ .toast.success {{ background:#27ae60; }}
129
+ .toast.error {{ background:#e74c3c; }}
130
+ </style>
131
+ </head>
132
+ <body>
133
+ <div class="header">
134
+ <h1>web-counter 统计看板</h1>
135
+ <a href="#" onclick="logout();return false;">退出登录</a>
136
+ </div>
137
+ <div class="container">
138
+ <div class="cards">
139
+ <div class="card today"><div class="label">今日 PV</div><div class="value" id="todayPv">{today_pv}</div></div>
140
+ <div class="card today"><div class="label">今日 UV</div><div class="value" id="todayUv">{today_uv}</div></div>
141
+ <div class="card site"><div class="label">累计 PV</div><div class="value" id="sitePv">{site_pv}</div></div>
142
+ <div class="card site"><div class="label">累计 UV</div><div class="value" id="siteUv">{site_uv}</div></div>
143
+ </div>
144
+
145
+ <div class="section">
146
+ <h2>30 天趋势</h2>
147
+ <div class="chart-wrap"><canvas id="trendChart"></canvas></div>
148
+ </div>
149
+
150
+ <div class="section">
151
+ <h2>接入代码</h2>
152
+ <p style="font-size:13px;color:#666;margin-bottom:8px;">将以下代码复制到网页 &lt;/body&gt; 前即可接入统计:</p>
153
+ <pre id="embedCode">&lt;script async src="{_get_origin()}/counter.js"&gt;&lt;/script&gt;
154
+
155
+ &lt;span style="display:none" class="counter-container" data-counter-style="card"&gt;
156
+ 本站访问次数 &lt;span data-pv-site&gt;&lt;/span&gt; 次 ·
157
+ 今日访问量 &lt;span data-pv-today&gt;&lt;/span&gt; 次 ·
158
+ 今日访客 &lt;span data-uv-today&gt;&lt;/span&gt; 人 ·
159
+ 总访客 &lt;span data-uv-site&gt;&lt;/span&gt; 人
160
+ &lt;/span&gt;</pre>
161
+ </div>
162
+
163
+ <div class="section">
164
+ <h2>数据重置</h2>
165
+ <form class="manage" id="resetForm" onsubmit="resetData(event)">
166
+ <div class="row">
167
+ <div class="field">
168
+ <label>重置范围</label>
169
+ <select id="resetScope">
170
+ <option value="today">今日数据</option>
171
+ <option value="all">全部数据 (不可逆)</option>
172
+ <option value="page">指定页面</option>
173
+ </select>
174
+ </div>
175
+ <div class="field" id="resetPathField" style="display:none;">
176
+ <label>页面路径</label>
177
+ <input type="text" id="resetPath" placeholder="/blog/hello">
178
+ </div>
179
+ <button type="submit" class="btn btn-danger">执行重置</button>
180
+ </div>
181
+ </form>
182
+ </div>
183
+
184
+ <div class="section">
185
+ <h2>起始值设置</h2>
186
+ <form class="manage" id="offsetForm" onsubmit="setOffset(event)">
187
+ <div class="row">
188
+ <div class="field"><label>累计 PV 起始值</label><input type="number" id="offsetSitePv" value="{offsets.get('site_pv', 0)}"></div>
189
+ <div class="field"><label>累计 UV 起始值</label><input type="number" id="offsetSiteUv" value="{offsets.get('site_uv', 0)}"></div>
190
+ <button type="submit" class="btn btn-primary">保存起始值</button>
191
+ </div>
192
+ </form>
193
+ </div>
194
+ </div>
195
+
196
+ <div class="toast" id="toast"></div>
197
+
198
+ <script>
199
+ var INITIAL_DATA = {stats_json};
200
+
201
+ function fmt(n) {{ return n.toString().replace(/\\B(?=(\\d{{3}})+(?!\\d))/g, ','); }}
202
+
203
+ // Trend chart
204
+ (function() {{
205
+ var daily = INITIAL_DATA.daily || [];
206
+ var labels = daily.map(function(d) {{ return d.date.slice(5); }});
207
+ var pvData = daily.map(function(d) {{ return d.pv; }});
208
+ var uvData = daily.map(function(d) {{ return d.uv; }});
209
+
210
+ new Chart(document.getElementById('trendChart'), {{
211
+ type: 'line',
212
+ data: {{
213
+ labels: labels,
214
+ datasets: [
215
+ {{ label: 'PV', data: pvData, borderColor: '#4a90d9', tension:0.3, pointRadius:1 }},
216
+ {{ label: 'UV', data: uvData, borderColor: '#27ae60', tension:0.3, pointRadius:1 }}
217
+ ]
218
+ }},
219
+ options: {{
220
+ responsive:true, maintainAspectRatio:false,
221
+ scales: {{ y: {{ beginAtZero:true, ticks:{{precision:0}} }} }}
222
+ }}
223
+ }});
224
+ }})();
225
+
226
+ // Show/hide path field for page reset
227
+ document.getElementById('resetScope').addEventListener('change', function() {{
228
+ document.getElementById('resetPathField').style.display = this.value === 'page' ? '' : 'none';
229
+ }});
230
+
231
+ function toast(msg, type) {{
232
+ var t = document.getElementById('toast');
233
+ t.textContent = msg;
234
+ t.className = 'toast ' + (type || 'success');
235
+ t.style.display = 'block';
236
+ setTimeout(function() {{ t.style.display = 'none'; }}, 3000);
237
+ }}
238
+
239
+ async function resetData(e) {{
240
+ e.preventDefault();
241
+ var scope = document.getElementById('resetScope').value;
242
+ var body = {{scope: scope}};
243
+ if (scope === 'page') body.path = document.getElementById('resetPath').value;
244
+ if (scope === 'all' && !confirm('确定要删除全部数据吗?此操作不可逆!')) return;
245
+ var resp = await fetch('/api/admin/reset', {{
246
+ method:'POST', headers:{{'Content-Type':'application/json'}}, body:JSON.stringify(body)
247
+ }});
248
+ if (resp.ok) {{ toast('重置成功'); setTimeout(function(){{ location.reload(); }}, 500); }}
249
+ else toast('重置失败: ' + (await resp.text()), 'error');
250
+ }}
251
+
252
+ async function setOffset(e) {{
253
+ e.preventDefault();
254
+ var body = {{
255
+ site_pv: parseInt(document.getElementById('offsetSitePv').value) || 0,
256
+ site_uv: parseInt(document.getElementById('offsetSiteUv').value) || 0,
257
+ }};
258
+ var resp = await fetch('/api/admin/offset', {{
259
+ method:'POST', headers:{{'Content-Type':'application/json'}}, body:JSON.stringify(body)
260
+ }});
261
+ if (resp.ok) {{ toast('起始值已保存'); setTimeout(function(){{ location.reload(); }}, 500); }}
262
+ else toast('保存失败: ' + (await resp.text()), 'error');
263
+ }}
264
+
265
+ async function logout() {{
266
+ await fetch('/api/admin/logout', {{method:'POST'}});
267
+ location.reload();
268
+ }}
269
+
270
+ // Auto-refresh every 30s
271
+ setInterval(function() {{
272
+ fetch('/api/count').then(function(r){{ return r.json(); }}).then(function(d) {{
273
+ document.getElementById('todayPv').textContent = fmt(d.today_pv);
274
+ document.getElementById('todayUv').textContent = fmt(d.today_uv);
275
+ document.getElementById('sitePv').textContent = fmt(d.site_pv);
276
+ document.getElementById('siteUv').textContent = fmt(d.site_uv);
277
+ }}).catch(function(){{}});
278
+ }}, 30000);
279
+ </script>
280
+ </body>
281
+ </html>"""
282
+
283
+
284
+ def _get_origin() -> str:
285
+ """Get the origin from environment for embed code display."""
286
+ import os
287
+ host = os.environ.get("COUNTER_HOST", "0.0.0.0")
288
+ port = os.environ.get("COUNTER_PORT", "8000")
289
+ if host == "0.0.0.0":
290
+ host = "your-domain.com"
291
+ if port == "80" or port == "443":
292
+ return f"http://{host}"
293
+ return f"http://{host}:{port}"