mirror of
https://github.com/tzuwei-huang/random-number-generator.git
synced 2026-08-24 14:10:29 +09:00
feat: add live demo page (#4)
* feat: Add web-based UI for fair random number generator with multi-language support and live demo link in README * feat: Add mode switching (Fair/Pure Random), repeat detection, and cryptographic randomness for web app
This commit is contained in:
parent
6aff0accc8
commit
4f0e8f7284
18
README.md
18
README.md
@ -26,13 +26,20 @@ A web-based random number generator built with FastAPI. It ensures fairness by p
|
||||
### Fairness Mechanism
|
||||
This app ensures fairness through two layers:
|
||||
1. **Logical Fairness (Pool-based)**: Instead of just picking a random number, the app creates a "pool" containing all numbers in the specified range. When a number is picked, it is **removed** from the pool. This guarantees that every number is drawn exactly once, and no number is repeated or skipped.
|
||||
2. **Statistical Fairness (Cryptographic Randomness)**: The app uses Python's `secrets` module (`secrets.randbelow`) to select the index from the pool. Unlike the standard `random` module, `secrets` is designed for cryptography and provides high-quality randomness that is resistant to prediction.
|
||||
2. **Statistical Fairness (Cryptographic Randomness)**:
|
||||
- **Desktop App**: Uses Python's `secrets` module (`secrets.randbelow`) to select the index from the pool. Unlike the standard `random` module, `secrets` is designed for cryptography and provides high-quality randomness.
|
||||
- **Web App (GitHub Pages)**: Uses the browser's `crypto.getRandomValues()` API, which provides the same level of cryptographic security as the Python version, ensuring high-quality randomness in a client-side environment.
|
||||
|
||||
You can run the verification script yourself to see the distribution:
|
||||
```bash
|
||||
uv run python tests/verify_fairness.py
|
||||
```
|
||||
|
||||
### 🌐 Live Demo (GitHub Pages)
|
||||
|
||||
You can run this app directly in your browser without installing anything!
|
||||
**[👉 Click here to open the Web App](https://tzuwei-huang.github.io/random-number-generator/)**
|
||||
|
||||
### Quick Start (Executable)
|
||||
|
||||
If you don't want to install Python, you can download the latest version from [GitHub Releases](https://github.com/tzuwei-huang/random-number-generator/releases).
|
||||
@ -91,13 +98,20 @@ This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE
|
||||
### 公平性機制
|
||||
本應用程式透過兩個層面確保公平性:
|
||||
1. **邏輯公平(基於號碼池)**:程式並非單純隨機抽號,而是先建立一個包含範圍內所有號碼的「號碼池」。每當抽到一個號碼,就會將其從池中**移除**。這保證了每個號碼在一個循環內只會被抽中一次,不會重複也不會遺漏。
|
||||
2. **統計公平(密碼學等級隨機性)**:程式使用 Python 的 `secrets` 模組 (`secrets.randbelow`) 來從池中挑選索引。與標準的 `random` 模組不同,`secrets` 模組是專為密碼學設計的,提供高品質且難以預測的隨機性。
|
||||
2. **統計公平(密碼學等級隨機性)**:
|
||||
- **電腦版 (Desktop App)**:程式使用 Python 的 `secrets` 模組 (`secrets.randbelow`) 來從池中挑選索引。與標準的 `random` 模組不同,`secrets` 模組是專為密碼學設計的,提供高品質且難以預測的隨機性。
|
||||
- **網頁版 (Web App)**:使用瀏覽器的 `crypto.getRandomValues()` API。這同樣是密碼學等級的強隨機數來源,與電腦版具有相同的安全性與不可預測性。
|
||||
|
||||
您可以自行執行驗證腳本來查看分佈情況:
|
||||
```bash
|
||||
uv run python tests/verify_fairness.py
|
||||
```
|
||||
|
||||
### 🌐 線上試用 (GitHub Pages)
|
||||
|
||||
您可以直接在瀏覽器中執行此應用程式,無需安裝任何內容!
|
||||
**[👉 點此開啟網頁版應用程式](https://tzuwei-huang.github.io/random-number-generator/)**
|
||||
|
||||
### 快速開始 (執行檔)
|
||||
|
||||
如果您不想安裝 Python,可以從 [GitHub Releases](https://github.com/tzuwei-huang/random-number-generator/releases) 下載最新版本。
|
||||
|
||||
299
index.html
Normal file
299
index.html
Normal file
@ -0,0 +1,299 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fair Random Number Generator</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; margin: 20px 0; background-color: #f4f7f6; font-size: 1.2em; }
|
||||
.container { width: 98%; max-width: 98%; margin: 0 auto; padding: 40px 20px; background: white; border: 1px solid #eee; border-radius: 15px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); position: relative; box-sizing: border-box; }
|
||||
.lang-switch { position: absolute; top: 20px; right: 20px; }
|
||||
.lang-link { text-decoration: none; color: #3498db; font-size: 1.2em; font-weight: bold; cursor: pointer; }
|
||||
h1 { color: #2c3e50; margin-bottom: 30px; font-size: 3em; }
|
||||
.result { font-size: 16em; font-weight: bold; color: #e74c3c; margin: 30px 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.1); }
|
||||
.info { font-size: 2em; color: #7f8c8d; margin-bottom: 30px; }
|
||||
button { padding: 20px 40px; font-size: 2em; cursor: pointer; transition: all 0.2s; }
|
||||
button:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
|
||||
button:active { transform: translateY(0); }
|
||||
.reset-btn { background-color: #f8f9fa; border: 1px solid #dee2e6; color: #6c757d; text-decoration: none; padding: 15px 30px; border-radius: 6px; display: inline-block; margin-top: 20px; font-size: 1.3em; cursor: pointer; }
|
||||
.reset-btn:hover { background-color: #e9ecef; }
|
||||
.generate-btn { background-color: #2ecc71; border: none; color: white; border-radius: 6px; font-weight: bold; }
|
||||
.generate-btn:hover { background-color: #27ae60; }
|
||||
input[type="number"] { padding: 15px; border: 2px solid #edeff2; border-radius: 8px; width: 150px; outline: none; font-size: 2em; text-align: center; }
|
||||
input[type="number"]:focus { border-color: #3498db; }
|
||||
label { font-size: 2em; color: #34495e; margin-right: 10px; }
|
||||
.drawn-list { margin-top: 40px; text-align: left; background: #fdfdfd; padding: 25px; border: 1px dashed #dcdde1; border-radius: 10px; }
|
||||
.drawn-numbers { font-size: 3em; font-weight: bold; color: #34495e; word-wrap: break-word; line-height: 1.2; margin-top: 10px; }
|
||||
.mode-container { margin: 10px 0; font-size: 1.2em; color: #7f8c8d; }
|
||||
.mode-badge { padding: 5px 15px; border-radius: 20px; font-weight: bold; background-color: #ecf0f1; color: #34495e; margin: 0 10px; }
|
||||
.mode-toggle-btn { background-color: #3498db; border: none; color: white; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 1em; text-decoration: none; display: inline-block; transition: background 0.2s; }
|
||||
.mode-toggle-btn:hover { background-color: #2980b9; }
|
||||
.repeat-badge { background-color: #ff9f43; color: white; padding: 5px 15px; border-radius: 50px; font-size: 0.15em; vertical-align: middle; margin-left: 10px; box-shadow: 0 4px 10px rgba(230, 126, 34, 0.3); animation: bounce 0.5s ease infinite alternate; }
|
||||
@keyframes bounce { from { transform: translateY(0); } to { transform: translateY(-5px); } }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="lang-switch">
|
||||
<a id="lang-toggle" class="lang-link">繁體中文</a>
|
||||
</div>
|
||||
<h1 id="title">🎲 Random Picker</h1>
|
||||
|
||||
<div id="setup-view">
|
||||
<div class="mode-container">
|
||||
<span id="mode-text">Mode:</span>
|
||||
<span id="mode-badge" class="mode-badge">Fair (No Repeat)</span>
|
||||
<button id="toggle-mode-setup" class="mode-toggle-btn">Switch Mode</button>
|
||||
</div>
|
||||
<p id="enter-range" style="font-size: 2em; color: #34495e;">Enter the range:</p>
|
||||
<label id="label-min">Min:</label>
|
||||
<input type="number" id="min-val" value="1" min="0">
|
||||
<label id="label-max">Max:</label>
|
||||
<input type="number" id="max-val" value="100" min="0">
|
||||
<br><br>
|
||||
<button id="start-btn" class="generate-btn">Start</button>
|
||||
</div>
|
||||
|
||||
<div id="active-view" class="hidden">
|
||||
<div class="mode-container">
|
||||
<span id="mode-text-active">Mode:</span>
|
||||
<span id="mode-badge-active" class="mode-badge">Fair (No Repeat)</span>
|
||||
<button id="toggle-mode-active" class="mode-toggle-btn">Switch Mode</button>
|
||||
</div>
|
||||
<div class="info">
|
||||
<span id="range-text">Range:</span> <span id="display-range"></span><br>
|
||||
<span id="remaining-text">Remaining:</span> <span id="remaining-count"></span>
|
||||
</div>
|
||||
|
||||
<div class="result">
|
||||
<span id="result-display">?</span>
|
||||
<span id="repeat-badge" class="repeat-badge hidden">Repeat!</span>
|
||||
</div>
|
||||
|
||||
<div id="action-buttons">
|
||||
<button id="pick-btn" class="generate-btn">Pick One</button>
|
||||
<div id="all-picked-msg" class="hidden">
|
||||
<p style="font-size: 2em; color: #2ecc71; font-weight: bold;" id="all-picked-text">🎉 All numbers picked!</p>
|
||||
<button id="restart-btn" class="generate-btn">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a id="reset-btn" class="reset-btn">Reset</a>
|
||||
|
||||
<div id="drawn-container" class="drawn-list hidden">
|
||||
<strong id="drawn-title" style="font-size: 2em;">Drawn Numbers:</strong>
|
||||
<div id="drawn-numbers" class="drawn-numbers"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TRANSLATIONS = {
|
||||
"en": {
|
||||
"title": "🎲 Random Picker",
|
||||
"enter_range": "Enter the range:",
|
||||
"min": "Min:",
|
||||
"max": "Max:",
|
||||
"start": "Start",
|
||||
"range": "Range:",
|
||||
"remaining": "Remaining:",
|
||||
"pick_one": "Pick One",
|
||||
"all_picked": "🎉 All numbers picked!",
|
||||
"restart": "Restart",
|
||||
"reset": "Reset",
|
||||
"reset_range": "Reset Range",
|
||||
"drawn_numbers": "Drawn Numbers:",
|
||||
"lang_toggle": "繁體中文",
|
||||
"mode": "Mode:",
|
||||
"mode_fair": "Fair (No Repeat)",
|
||||
"mode_random": "Pure Random",
|
||||
"toggle_mode": "Switch Mode",
|
||||
"repeat": "Repeat!"
|
||||
},
|
||||
"zh": {
|
||||
"title": "🎲 隨機抽號器",
|
||||
"enter_range": "輸入範圍:",
|
||||
"min": "最小值:",
|
||||
"max": "最大值:",
|
||||
"start": "開始",
|
||||
"range": "範圍:",
|
||||
"remaining": "剩餘數量:",
|
||||
"pick_one": "抽一個",
|
||||
"all_picked": "🎉 全部抽完啦!",
|
||||
"restart": "重新開始",
|
||||
"reset": "重置",
|
||||
"reset_range": "重新設定範圍",
|
||||
"drawn_numbers": "已抽出的號碼:",
|
||||
"lang_toggle": "English",
|
||||
"mode": "模式:",
|
||||
"mode_fair": "公平模式 (不重複)",
|
||||
"mode_random": "純隨機模式",
|
||||
"toggle_mode": "切換模式",
|
||||
"repeat": "重複!"
|
||||
}
|
||||
};
|
||||
|
||||
let currentLang = 'zh';
|
||||
let pool = [];
|
||||
let drawn = [];
|
||||
let currentRange = [1, 100];
|
||||
let isActive = false;
|
||||
let mode = 'fair';
|
||||
let isRepeat = false;
|
||||
|
||||
function updateUI() {
|
||||
const t = TRANSLATIONS[currentLang];
|
||||
document.getElementById('lang-toggle').innerText = t.lang_toggle;
|
||||
document.getElementById('title').innerText = t.title;
|
||||
document.getElementById('enter-range').innerText = t.enter_range;
|
||||
document.getElementById('label-min').innerText = t.min;
|
||||
document.getElementById('label-max').innerText = t.max;
|
||||
document.getElementById('start-btn').innerText = t.start;
|
||||
document.getElementById('range-text').innerText = t.range;
|
||||
document.getElementById('remaining-text').innerText = t.remaining;
|
||||
document.getElementById('pick-btn').innerText = t.pick_one;
|
||||
document.getElementById('all-picked-text').innerText = t.all_picked;
|
||||
document.getElementById('restart-btn').innerText = t.restart;
|
||||
document.getElementById('drawn-title').innerText = t.drawn_numbers;
|
||||
|
||||
const modeLabel = mode === 'fair' ? t.mode_fair : t.mode_random;
|
||||
document.getElementById('mode-text').innerText = t.mode;
|
||||
document.getElementById('mode-badge').innerText = modeLabel;
|
||||
document.getElementById('toggle-mode-setup').innerText = t.toggle_mode;
|
||||
document.getElementById('mode-text-active').innerText = t.mode;
|
||||
document.getElementById('mode-badge-active').innerText = modeLabel;
|
||||
document.getElementById('toggle-mode-active').innerText = t.toggle_mode;
|
||||
document.getElementById('repeat-badge').innerText = t.repeat;
|
||||
|
||||
if (isActive) {
|
||||
document.getElementById('setup-view').classList.add('hidden');
|
||||
document.getElementById('active-view').classList.remove('hidden');
|
||||
document.getElementById('display-range').innerText = `${currentRange[0]} ~ ${currentRange[1]}`;
|
||||
document.getElementById('remaining-count').innerText = pool.length;
|
||||
|
||||
if (mode === 'fair') {
|
||||
if (pool.length > 0) {
|
||||
document.getElementById('pick-btn').classList.remove('hidden');
|
||||
document.getElementById('all-picked-msg').classList.add('hidden');
|
||||
document.getElementById('reset-btn').innerText = t.reset;
|
||||
} else {
|
||||
document.getElementById('pick-btn').classList.add('hidden');
|
||||
document.getElementById('all-picked-msg').classList.remove('hidden');
|
||||
document.getElementById('reset-btn').innerText = t.reset_range;
|
||||
}
|
||||
} else {
|
||||
document.getElementById('pick-btn').classList.remove('hidden');
|
||||
document.getElementById('all-picked-msg').classList.add('hidden');
|
||||
document.getElementById('reset-btn').innerText = t.reset;
|
||||
}
|
||||
|
||||
const padding = String(currentRange[1]).length;
|
||||
const formattedDrawn = drawn.map(n => String(n).padStart(padding, '0'));
|
||||
document.getElementById('drawn-numbers').innerText = formattedDrawn.join(', ');
|
||||
|
||||
if (drawn.length > 0) {
|
||||
document.getElementById('drawn-container').classList.remove('hidden');
|
||||
const lastNumber = drawn[0];
|
||||
document.getElementById('result-display').innerText = String(lastNumber).padStart(padding, '0');
|
||||
if (isRepeat) {
|
||||
document.getElementById('repeat-badge').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('repeat-badge').classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
document.getElementById('drawn-container').classList.add('hidden');
|
||||
document.getElementById('result-display').innerText = '?';
|
||||
document.getElementById('repeat-badge').classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
document.getElementById('setup-view').classList.remove('hidden');
|
||||
document.getElementById('active-view').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function getRandomInt(min, max) {
|
||||
const range = max - min + 1;
|
||||
const array = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(array);
|
||||
return (array[0] % range) + min;
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode = mode === 'fair' ? 'random' : 'fair';
|
||||
updateUI();
|
||||
}
|
||||
|
||||
document.getElementById('toggle-mode-setup').onclick = toggleMode;
|
||||
document.getElementById('toggle-mode-active').onclick = toggleMode;
|
||||
|
||||
document.getElementById('lang-toggle').onclick = () => {
|
||||
currentLang = currentLang === 'en' ? 'zh' : 'en';
|
||||
updateUI();
|
||||
};
|
||||
|
||||
document.getElementById('start-btn').onclick = () => {
|
||||
const min = parseInt(document.getElementById('min-val').value);
|
||||
const max = parseInt(document.getElementById('max-val').value);
|
||||
if (isNaN(min) || isNaN(max) || min > max) {
|
||||
alert(currentLang === 'en' ? "Invalid range!" : "無效的範圍!");
|
||||
return;
|
||||
}
|
||||
if (max - min + 1 > 10000) {
|
||||
alert(currentLang === 'en' ? "Range too large (max 10000)!" : "範圍太大 (最大 10000)!");
|
||||
return;
|
||||
}
|
||||
currentRange = [min, max];
|
||||
initPool(min, max);
|
||||
isActive = true;
|
||||
pickOne();
|
||||
updateUI();
|
||||
};
|
||||
|
||||
function initPool(min, max) {
|
||||
pool = [];
|
||||
for (let i = min; i <= max; i++) {
|
||||
pool.push(i);
|
||||
}
|
||||
drawn = [];
|
||||
isRepeat = false;
|
||||
}
|
||||
|
||||
function pickOne() {
|
||||
let picked;
|
||||
if (mode === 'fair') {
|
||||
if (pool.length === 0) return;
|
||||
const idx = getRandomInt(0, pool.length - 1);
|
||||
picked = pool.splice(idx, 1)[0];
|
||||
isRepeat = false;
|
||||
} else {
|
||||
picked = getRandomInt(currentRange[0], currentRange[1]);
|
||||
isRepeat = drawn.includes(picked);
|
||||
const poolIdx = pool.indexOf(picked);
|
||||
if (poolIdx !== -1) {
|
||||
pool.splice(poolIdx, 1);
|
||||
}
|
||||
}
|
||||
drawn.unshift(picked);
|
||||
}
|
||||
|
||||
document.getElementById('pick-btn').onclick = () => {
|
||||
pickOne();
|
||||
updateUI();
|
||||
};
|
||||
|
||||
document.getElementById('restart-btn').onclick = () => {
|
||||
initPool(currentRange[0], currentRange[1]);
|
||||
pickOne();
|
||||
updateUI();
|
||||
};
|
||||
|
||||
document.getElementById('reset-btn').onclick = () => {
|
||||
isActive = false;
|
||||
updateUI();
|
||||
};
|
||||
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user