feat(platform): migrate to softap web provisioning portal
This commit is contained in:
580
components/platform/assets/wifi_configuration.html
Normal file
580
components/platform/assets/wifi_configuration.html
Normal file
@@ -0,0 +1,580 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="referrer no-referrer">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>TalkingQ Network Configuration</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-a: #031f4c;
|
||||
--bg-b: #0a66ff;
|
||||
--bg-c: #2bc7ff;
|
||||
--text-primary: #0b1c35;
|
||||
--text-secondary: #4f5f86;
|
||||
--btn-a: #0a61ff;
|
||||
--btn-b: #24befc;
|
||||
--ok-bg: #dcfce7;
|
||||
--ok-text: #166534;
|
||||
--err-bg: #fee2e2;
|
||||
--err-text: #991b1b;
|
||||
--muted-bg: #eef4ff;
|
||||
--muted-text: #2a4b95;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--bg-a) 0%, var(--bg-b) 45%, var(--bg-c) 100%);
|
||||
padding: 28px 14px 40px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 26px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 28px 65px rgba(5, 29, 82, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 24px 26px;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #0a54ff, #1f95ff 60%, #66e0ff);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 8px 22px rgba(3, 18, 66, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.brand-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.brand-copy p {
|
||||
margin: 4px 0 0;
|
||||
opacity: 0.86;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.language-switch {
|
||||
width: 156px;
|
||||
}
|
||||
|
||||
.language-switch select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.language-switch select:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px 24px 30px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.intro h2 {
|
||||
margin: 0;
|
||||
color: #0a2d72;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #e0e8ff;
|
||||
border-radius: 18px;
|
||||
background: #f8fbff;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.field:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #1a2f5c;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #d6def0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #2b8cff;
|
||||
box-shadow: 0 0 0 3px rgba(43, 140, 255, 0.2);
|
||||
}
|
||||
|
||||
.button {
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
background: linear-gradient(120deg, var(--btn-a), var(--btn-b));
|
||||
box-shadow: 0 12px 30px rgba(9, 71, 161, 0.35);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 34px rgba(8, 64, 150, 0.35);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #b9c6e4;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: var(--muted-text);
|
||||
background: var(--muted-bg);
|
||||
border: 1px solid #c9d8ff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 16px;
|
||||
min-height: 24px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dce5ff;
|
||||
background: #f4f7ff;
|
||||
color: #334c7d;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-text);
|
||||
border-color: #b7e9c4;
|
||||
}
|
||||
|
||||
.status.err {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-text);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.brand-header {
|
||||
padding: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.language-switch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 22px 18px 24px;
|
||||
}
|
||||
|
||||
.intro h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<div class="card">
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo">TQ</div>
|
||||
<div class="brand-copy">
|
||||
<h1>TalkingQ</h1>
|
||||
<p data-i18n="tagline">Draw smarter. Connect faster.</p>
|
||||
</div>
|
||||
<div class="language-switch">
|
||||
<select id="language" aria-label="Language">
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="intro">
|
||||
<h2 data-i18n="title">Wi-Fi Configuration</h2>
|
||||
<p data-i18n="subtitle">Select a scanned network or enter SSID manually. Credentials are saved only after connection succeeds.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="field">
|
||||
<button id="scan-btn" class="button secondary" type="button" data-i18n="scan_button">Scan Nearby Wi-Fi</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="network-list" data-i18n="nearby_label">Nearby Wi-Fi</label>
|
||||
<select id="network-list">
|
||||
<option value="" data-i18n="manual_option">Manual input</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<form id="wifi-form">
|
||||
<div class="field">
|
||||
<label for="ssid" data-i18n="ssid_label">SSID</label>
|
||||
<input id="ssid" name="ssid" maxlength="32" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password" data-i18n="password_label">Password</label>
|
||||
<input id="password" name="password" maxlength="64" type="password" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button id="submit-btn" class="button" type="submit" data-i18n="connect_button">Connect</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('wifi-form');
|
||||
const statusBox = document.getElementById('status');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const scanBtn = document.getElementById('scan-btn');
|
||||
const networkList = document.getElementById('network-list');
|
||||
const ssidInput = document.getElementById('ssid');
|
||||
const languageSelect = document.getElementById('language');
|
||||
|
||||
const i18n = {
|
||||
'zh-CN': {
|
||||
title: 'Wi-Fi 配网',
|
||||
subtitle: '可先从扫描结果选择网络,也可以手动输入 SSID。仅在连接成功后才会保存密码。',
|
||||
tagline: 'Draw smarter. Connect faster.',
|
||||
scan_button: '扫描附近 Wi-Fi',
|
||||
nearby_label: '附近 Wi-Fi',
|
||||
manual_option: '手动输入',
|
||||
ssid_label: 'SSID',
|
||||
password_label: '密码',
|
||||
connect_button: '连接',
|
||||
status_ready: '准备就绪。',
|
||||
status_scanning: '正在扫描附近 Wi-Fi...',
|
||||
status_scan_ok: '扫描完成,请选择网络后连接。',
|
||||
status_scan_empty: '未扫描到 Wi-Fi,请手动输入 SSID。',
|
||||
status_scan_failed: '扫描失败,请手动输入 SSID。',
|
||||
status_ssid_required: 'SSID 不能为空。',
|
||||
status_connecting: '正在连接,请稍候...',
|
||||
status_connect_ok: '连接成功,正在关闭配网页面...',
|
||||
status_connect_failed: '连接失败:{error}。请重试。',
|
||||
status_request_failed: '请求失败,请重试。',
|
||||
signal_unit: 'dBm'
|
||||
},
|
||||
'en-US': {
|
||||
title: 'Wi-Fi Configuration',
|
||||
subtitle: 'Select a scanned network or enter SSID manually. Credentials are saved only after connection succeeds.',
|
||||
tagline: 'Draw smarter. Connect faster.',
|
||||
scan_button: 'Scan Nearby Wi-Fi',
|
||||
nearby_label: 'Nearby Wi-Fi',
|
||||
manual_option: 'Manual input',
|
||||
ssid_label: 'SSID',
|
||||
password_label: 'Password',
|
||||
connect_button: 'Connect',
|
||||
status_ready: 'Ready.',
|
||||
status_scanning: 'Scanning nearby Wi-Fi...',
|
||||
status_scan_ok: 'Scan complete. Select a network and connect.',
|
||||
status_scan_empty: 'No Wi-Fi found. Please input SSID manually.',
|
||||
status_scan_failed: 'Scan failed. Please input SSID manually.',
|
||||
status_ssid_required: 'SSID is required.',
|
||||
status_connecting: 'Connecting, please wait...',
|
||||
status_connect_ok: 'Connected successfully. Closing this provisioning page...',
|
||||
status_connect_failed: 'Connection failed: {error}. Please try again.',
|
||||
status_request_failed: 'Request failed. Please try again.',
|
||||
signal_unit: 'dBm'
|
||||
}
|
||||
};
|
||||
|
||||
const languageMap = {
|
||||
'zh': 'zh-CN',
|
||||
'zh-CN': 'zh-CN',
|
||||
'zh-SG': 'zh-CN',
|
||||
'en': 'en-US',
|
||||
'en-US': 'en-US',
|
||||
'en-GB': 'en-US',
|
||||
'en-CA': 'en-US',
|
||||
'en-AU': 'en-US'
|
||||
};
|
||||
|
||||
let currentLang = 'zh-CN';
|
||||
|
||||
function tr(key) {
|
||||
const langPack = i18n[currentLang] || i18n['en-US'];
|
||||
return langPack[key] || i18n['en-US'][key] || key;
|
||||
}
|
||||
|
||||
function trWithParams(key, params) {
|
||||
let text = tr(key);
|
||||
if (!params) {
|
||||
return text;
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((k) => {
|
||||
text = text.split('{' + k + '}').join(String(params[k]));
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
function setStatusText(text, type) {
|
||||
statusBox.textContent = text;
|
||||
statusBox.className = type ? ('status ' + type) : 'status';
|
||||
}
|
||||
|
||||
function setStatusKey(key, type, params) {
|
||||
setStatusText(trWithParams(key, params), type);
|
||||
}
|
||||
|
||||
function decodeSsid(value) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (_err) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function closeProvisionPage() {
|
||||
window.open('', '_self');
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
window.location.replace('about:blank');
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function applyTranslations() {
|
||||
document.documentElement.lang = currentLang;
|
||||
document.title = 'TalkingQ Network Configuration';
|
||||
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const text = tr(key);
|
||||
if (text) {
|
||||
el.textContent = text;
|
||||
}
|
||||
});
|
||||
|
||||
const manualOption = networkList.querySelector('option[value=\"\"]');
|
||||
if (manualOption) {
|
||||
manualOption.textContent = tr('manual_option');
|
||||
}
|
||||
|
||||
if (!statusBox.textContent) {
|
||||
setStatusKey('status_ready', '');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLanguage(input) {
|
||||
if (!input) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (languageMap[input]) {
|
||||
return languageMap[input];
|
||||
}
|
||||
const main = input.split('-')[0];
|
||||
return languageMap[main] || 'zh-CN';
|
||||
}
|
||||
|
||||
function initLanguage() {
|
||||
const saved = localStorage.getItem('provision_lang');
|
||||
const browser = (navigator.language || 'zh-CN').trim();
|
||||
currentLang = normalizeLanguage(saved || browser);
|
||||
languageSelect.value = currentLang;
|
||||
applyTranslations();
|
||||
}
|
||||
|
||||
languageSelect.addEventListener('change', () => {
|
||||
currentLang = normalizeLanguage(languageSelect.value);
|
||||
localStorage.setItem('provision_lang', currentLang);
|
||||
applyTranslations();
|
||||
});
|
||||
|
||||
function rebuildNetworkList(networks) {
|
||||
networkList.innerHTML = '';
|
||||
const manualOption = document.createElement('option');
|
||||
manualOption.value = '';
|
||||
manualOption.textContent = tr('manual_option');
|
||||
networkList.appendChild(manualOption);
|
||||
|
||||
networks.forEach((item) => {
|
||||
const ssid = decodeSsid(item.ssid || '');
|
||||
if (!ssid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option = document.createElement('option');
|
||||
const signal = typeof item.rssi === 'number' ? (item.rssi + ' ' + tr('signal_unit')) : ('? ' + tr('signal_unit'));
|
||||
const auth = item.auth || 'unknown';
|
||||
option.value = ssid;
|
||||
option.textContent = ssid + ' (' + signal + ', ' + auth + ')';
|
||||
networkList.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function scanNetworks() {
|
||||
scanBtn.disabled = true;
|
||||
setStatusKey('status_scanning', '');
|
||||
|
||||
try {
|
||||
const response = await fetch('/scan', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'scan_failed');
|
||||
}
|
||||
|
||||
const networks = Array.isArray(data.networks) ? data.networks : [];
|
||||
rebuildNetworkList(networks);
|
||||
|
||||
if (networks.length > 0) {
|
||||
setStatusKey('status_scan_ok', '');
|
||||
} else {
|
||||
setStatusKey('status_scan_empty', 'err');
|
||||
}
|
||||
} catch (_err) {
|
||||
rebuildNetworkList([]);
|
||||
setStatusKey('status_scan_failed', 'err');
|
||||
}
|
||||
|
||||
scanBtn.disabled = false;
|
||||
}
|
||||
|
||||
networkList.addEventListener('change', () => {
|
||||
if (networkList.value) {
|
||||
ssidInput.value = networkList.value;
|
||||
}
|
||||
});
|
||||
|
||||
scanBtn.addEventListener('click', () => {
|
||||
scanNetworks();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
let ssid = ssidInput.value.trim();
|
||||
if (!ssid && networkList.value) {
|
||||
ssid = networkList.value;
|
||||
ssidInput.value = ssid;
|
||||
}
|
||||
|
||||
const password = document.getElementById('password').value;
|
||||
if (!ssid) {
|
||||
setStatusKey('status_ssid_required', 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
setStatusKey('status_connecting', '');
|
||||
|
||||
try {
|
||||
const payload = 'ssid=' + encodeURIComponent(ssid) + '&password=' + encodeURIComponent(password);
|
||||
const response = await fetch('/provision', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: payload
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
setStatusKey('status_connect_ok', 'ok');
|
||||
setTimeout(() => {
|
||||
closeProvisionPage();
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusKey('status_connect_failed', 'err', { error: data.error || 'unknown' });
|
||||
} catch (_err) {
|
||||
setStatusKey('status_request_failed', 'err');
|
||||
}
|
||||
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
initLanguage();
|
||||
setStatusKey('status_ready', '');
|
||||
scanNetworks();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user