- 新增 Web 用户端(登录体系 + 统一 REST API 调用) - 后端增加用户认证、统一 ApiResponse、CORS 支持 - Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层 - 完善 README 架构说明与密码学原理文档 - 新增 .gitignore 与持久化数据表说明
254 lines
8.2 KiB
JavaScript
254 lines
8.2 KiB
JavaScript
// ============================================================
|
||
// Secure Device 用户端(Web)—— 前后端分离演示
|
||
//
|
||
// 后端地址:默认 localhost:8080(CORS 已放开)。
|
||
// 认证:登录/注册后获得 Bearer Token,存 localStorage,
|
||
// 后续所有用户端接口自动携带 Authorization: Bearer <token>。
|
||
// ============================================================
|
||
|
||
const API_BASE = 'http://localhost:8080';
|
||
|
||
// ---------- 状态 ----------
|
||
let token = localStorage.getItem('sd_token') || '';
|
||
let userId = localStorage.getItem('sd_userId') || '';
|
||
|
||
// ---------- DOM 引用 ----------
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const sections = {
|
||
auth: $('authSection'),
|
||
device: $('deviceSection'),
|
||
recover: $('recoverSection'),
|
||
photo: $('photoSection'),
|
||
};
|
||
|
||
// 登录后才需要显示的面板(登录区 auth 始终显示,不在其中)
|
||
const authedSections = {
|
||
device: $('deviceSection'),
|
||
recover: $('recoverSection'),
|
||
photo: $('photoSection'),
|
||
};
|
||
|
||
// ---------- 通用请求 ----------
|
||
async function api(path, { method = 'GET', body = null } = {}) {
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||
|
||
const resp = await fetch(API_BASE + path, {
|
||
method,
|
||
headers,
|
||
body: body ? JSON.stringify(body) : null,
|
||
});
|
||
|
||
// 统一响应格式 { code, message, data },即使 HTTP 4xx/5xx 也是该结构
|
||
const json = await resp.json().catch(() => null);
|
||
showRaw(method, path, resp.status, json);
|
||
if (!resp.ok || !json || json.code !== 0) {
|
||
throw new Error((json && json.message) || `HTTP ${resp.status}`);
|
||
}
|
||
return json.data;
|
||
}
|
||
|
||
// ---------- 原始响应展示 ----------
|
||
function showRaw(method, path, status, json) {
|
||
$('rawOutput').textContent =
|
||
`HTTP ${status} ${method} ${API_BASE}${path}\n` +
|
||
JSON.stringify(json, null, 2);
|
||
}
|
||
|
||
// ---------- 结果展示 ----------
|
||
function showResult(elId, text, isErr = false) {
|
||
const el = $(elId);
|
||
el.textContent = text;
|
||
el.className = 'result ' + (isErr ? 'err' : 'ok');
|
||
}
|
||
|
||
// ---------- 登录态 UI ----------
|
||
function refreshAuthUI() {
|
||
const loggedIn = !!token;
|
||
$('userInfo').classList.toggle('hidden', !loggedIn);
|
||
$('userLabel').textContent = loggedIn ? `当前用户:${userId}` : '';
|
||
// 登录区始终显示;仅「设备/恢复/照片」三个面板在登录后才显示
|
||
$('authSection').classList.toggle('hidden', false);
|
||
Object.values(authedSections).forEach((s) => s.classList.toggle('hidden', !loggedIn));
|
||
if (!loggedIn) $('authResult').textContent = '';
|
||
}
|
||
|
||
function login(user, pass) {
|
||
token = user;
|
||
userId = user;
|
||
localStorage.setItem('sd_token', token);
|
||
localStorage.setItem('sd_userId', userId);
|
||
refreshAuthUI();
|
||
}
|
||
|
||
function logout() {
|
||
token = '';
|
||
userId = '';
|
||
localStorage.removeItem('sd_token');
|
||
localStorage.removeItem('sd_userId');
|
||
refreshAuthUI();
|
||
}
|
||
|
||
// ============================================================
|
||
// 事件绑定
|
||
// ============================================================
|
||
|
||
// 登录
|
||
$('btnLogin').addEventListener('click', async () => {
|
||
const uid = $('authUserId').value.trim();
|
||
const pass = $('authPassword').value || '123456';
|
||
if (!uid) return showResult('authResult', '请输入用户名', true);
|
||
try {
|
||
const data = await api('/api/auth/login', {
|
||
method: 'POST',
|
||
body: { userId: uid, password: pass },
|
||
});
|
||
login(data.userId, data.token);
|
||
showResult('authResult', `登录成功,Token: ${data.token.slice(0, 16)}...`);
|
||
} catch (e) {
|
||
showResult('authResult', `登录失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 注册
|
||
$('btnRegister').addEventListener('click', async () => {
|
||
const uid = $('authUserId').value.trim();
|
||
const pass = $('authPassword').value || '123456';
|
||
const phone = $('authPhone').value.trim() || '13800138000';
|
||
if (!uid) return showResult('authResult', '请输入用户名', true);
|
||
try {
|
||
const data = await api('/api/auth/register', {
|
||
method: 'POST',
|
||
body: { userId: uid, phone, password: pass },
|
||
});
|
||
login(data.userId, data.token);
|
||
showResult('authResult', `注册成功并已登录,Token: ${data.token.slice(0, 16)}...`);
|
||
} catch (e) {
|
||
showResult('authResult', `注册失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 退出登录
|
||
$('btnLogout').addEventListener('click', () => {
|
||
logout();
|
||
showResult('authResult', '已退出登录');
|
||
});
|
||
|
||
// 绑定设备
|
||
$('btnBind').addEventListener('click', async () => {
|
||
const sn = $('bindSn').value.trim();
|
||
if (!sn) return showResult('bindResult', '请输入设备 SN', true);
|
||
try {
|
||
const data = await api('/api/device/bind', {
|
||
method: 'POST',
|
||
body: { sn },
|
||
});
|
||
showResult('bindResult', data.message || '绑定成功');
|
||
} catch (e) {
|
||
showResult('bindResult', `绑定失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 设备列表
|
||
$('btnListDevices').addEventListener('click', async () => {
|
||
try {
|
||
const data = await api('/api/user/devices');
|
||
const devices = data.devices || [];
|
||
if (devices.length === 0) {
|
||
showResult('devicesResult', '暂无绑定设备');
|
||
return;
|
||
}
|
||
const lines = devices.map(
|
||
(d) => `• ${d.sn} | deviceId: ${d.deviceId} | 状态: ${d.active ? '启用' : '停用'}`
|
||
);
|
||
showResult('devicesResult', lines.join('\n'));
|
||
} catch (e) {
|
||
showResult('devicesResult', `获取设备列表失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 发送短信验证码
|
||
$('btnSendSms').addEventListener('click', async () => {
|
||
const phone = $('recoverPhone').value.trim() || '13800138000';
|
||
try {
|
||
const data = await api('/api/device/sms/send', {
|
||
method: 'POST',
|
||
body: { phone },
|
||
});
|
||
showResult('recoverResult', data.message || '验证码已发送(demo 固定 000000)');
|
||
} catch (e) {
|
||
showResult('recoverResult', `发送失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 恢复授权
|
||
$('btnRecover').addEventListener('click', async () => {
|
||
const sn = $('recoverSn').value.trim();
|
||
const newPubKey = $('recoverNewPubKey').value.trim();
|
||
const smsCode = $('recoverSmsCode').value.trim();
|
||
if (!sn) return showResult('recoverResult', '请输入设备 SN', true);
|
||
if (!newPubKey) return showResult('recoverResult', '请输入新设备公钥(从设备端复制)', true);
|
||
if (!smsCode) return showResult('recoverResult', '请输入短信验证码', true);
|
||
try {
|
||
const data = await api('/api/device/recover', {
|
||
method: 'POST',
|
||
body: { sn, smsCode, newPublicKeyBase64: newPubKey },
|
||
});
|
||
showResult(
|
||
'recoverResult',
|
||
`恢复授权成功!deviceId: ${data.deviceId}\n` +
|
||
`encryptedRecoveryToken: ${data.encryptedRecoveryToken.slice(0, 32)}...\n` +
|
||
`(设备端可凭此 Token 取回照片 DEK)`
|
||
);
|
||
} catch (e) {
|
||
showResult('recoverResult', `恢复授权失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 照片列表
|
||
$('btnListPhotos').addEventListener('click', async () => {
|
||
try {
|
||
const data = await api('/api/user/photos');
|
||
const photos = data.photos || [];
|
||
if (photos.length === 0) {
|
||
showResult('photosResult', '暂无照片(请先在设备端上传)');
|
||
return;
|
||
}
|
||
showResult('photosResult', photos.map((p) => `• ${p}`).join('\n'));
|
||
} catch (e) {
|
||
showResult('photosResult', `获取照片列表失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// 下载解密照片
|
||
$('btnDecrypt').addEventListener('click', async () => {
|
||
const photoId = $('decryptPhotoId').value.trim();
|
||
if (!photoId) return showResult('decryptResult', '请输入 photoId', true);
|
||
try {
|
||
const data = await api(`/api/photo/${photoId}/decrypt`);
|
||
const plaintext = decodeBase64Text(data.plaintextBase64);
|
||
showResult(
|
||
'decryptResult',
|
||
`解密成功!photoId: ${data.photoId}\n明文内容:\n${plaintext}`
|
||
);
|
||
} catch (e) {
|
||
showResult('decryptResult', `解密失败:${e.message}`, true);
|
||
}
|
||
});
|
||
|
||
// ---------- 工具 ----------
|
||
function decodeBase64Text(b64) {
|
||
try {
|
||
const bin = atob(b64);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
return new TextDecoder('utf-8').decode(bytes);
|
||
} catch {
|
||
return '<无法解码为文本,可能为二进制照片数据>';
|
||
}
|
||
}
|
||
|
||
// ---------- 初始化 ----------
|
||
refreshAuthUI();
|