ow
This commit is contained in:
1 parent
e9af73007e
commit
2909e9b0af
28 files changed
+17789
No files matched your search
@@ -0,0 +1,21 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version" : "1.0",
|
||||
"configurations" : [
|
||||
{
|
||||
"playground" : "standard",
|
||||
"type" : "uni-app:app-android"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+13835
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "security",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev:custom": "uni -p",
|
||||
"dev:h5": "uni",
|
||||
"dev:h5:ssr": "uni --ssr",
|
||||
"dev:app": "uni -p app",
|
||||
"dev:app-android": "uni -p app-android",
|
||||
"dev:app-ios": "uni -p app-ios",
|
||||
"dev:mp-alipay": "uni -p mp-alipay",
|
||||
"dev:mp-baidu": "uni -p mp-baidu",
|
||||
"dev:mp-jd": "uni -p mp-jd",
|
||||
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||
"dev:mp-lark": "uni -p mp-lark",
|
||||
"dev:mp-qq": "uni -p mp-qq",
|
||||
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||
"dev:mp-harmony": "uni -p mp-harmony",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"dev:mp-xhs": "uni -p mp-xhs",
|
||||
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||
"build:custom": "uni build -p",
|
||||
"build:h5": "uni build",
|
||||
"build:h5:ssr": "uni build --ssr",
|
||||
"build:app": "uni build -p app",
|
||||
"build:app-android": "uni build -p app-android",
|
||||
"build:app-ios": "uni build -p app-ios",
|
||||
"build:mp-alipay": "uni build -p mp-alipay",
|
||||
"build:mp-baidu": "uni build -p mp-baidu",
|
||||
"build:mp-jd": "uni build -p mp-jd",
|
||||
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||
"build:mp-lark": "uni build -p mp-lark",
|
||||
"build:mp-qq": "uni build -p mp-qq",
|
||||
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||
"build:mp-harmony": "uni build -p mp-harmony",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"build:mp-xhs": "uni build -p mp-xhs",
|
||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"nox:connect": "node scripts/nox.mjs connect",
|
||||
"nox:serve": "node scripts/nox.mjs serve --build",
|
||||
"nox:open": "node scripts/nox.mjs open",
|
||||
"nox:test": "node scripts/nox.mjs test --build --shot",
|
||||
"nox:shot": "node scripts/nox.mjs screenshot",
|
||||
"nox:native": "node scripts/nox.mjs native"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-components": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-h5": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-harmony": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-5020620260917001",
|
||||
"crypto-js": "^4.1.1",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-5020620260917001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-5020620260917001",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "5.2.8",
|
||||
"vue-tsc": "^1.0.24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Nox(夜神模拟器)运行/测试助手
|
||||
*
|
||||
* 背景:Nox 自带 adb(协议36) 与 HBuilderX 自带 adb(协议41) 会互相 kill server 造成设备掉线,
|
||||
* 因此本脚本统一使用 HBuilderX 的 adb,并通过 TCP `connect 127.0.0.1:62001` 挂载 Nox。
|
||||
*
|
||||
* 两种在 Nox 上测试本项目的方式:
|
||||
* 1) test :构建/复用 H5 产物 -> 局域网静态服务 -> adb 唤起 Nox 浏览器打开(零打包,当前即可用)
|
||||
* 2) native:调用 HBuilderX CLI `launch app-android` 运行到安卓基座(需先在 HBuilderX 里以 uni-app 方式导入本项目)
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/nox.mjs connect
|
||||
* node scripts/nox.mjs serve [--build] [--port 5199]
|
||||
* node scripts/nox.mjs open [--url http://ip:port]
|
||||
* node scripts/nox.mjs test [--build] [--port 5199] [--shot]
|
||||
* node scripts/nox.mjs screenshot
|
||||
* node scripts/nox.mjs native
|
||||
*/
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
// ---- 可配置项(均可用环境变量覆盖)----
|
||||
const HBX_HOME = process.env.HBX_HOME || "C:\\apps\\HBuilderX";
|
||||
const ADB =
|
||||
process.env.NOX_ADB ||
|
||||
path.join(HBX_HOME, "plugins", "launcher-tools", "tools", "adbs", "adb.exe");
|
||||
const CLI = process.env.HBX_CLI || path.join(HBX_HOME, "cli.exe");
|
||||
const NOX_SERIAL = process.env.NOX_SERIAL || "127.0.0.1:62001";
|
||||
const PROJECT_NAME = process.env.NOX_PROJECT || "security";
|
||||
const H5_DIST = path.join(ROOT, "dist", "build", "h5");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0] || "test";
|
||||
const getFlag = (name, def) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
if (i === -1) return def;
|
||||
const next = args[i + 1];
|
||||
return next && !next.startsWith("--") ? next : true;
|
||||
};
|
||||
const hasFlag = (name) => args.includes(`--${name}`);
|
||||
|
||||
function lanIp() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const name of Object.keys(ifaces)) {
|
||||
for (const info of ifaces[name] || []) {
|
||||
if (info.family === "IPv4" && !info.internal) return info.address;
|
||||
}
|
||||
}
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
function adb(...adbArgs) {
|
||||
return spawnSync(ADB, adbArgs, { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const r = adb("connect", NOX_SERIAL);
|
||||
console.log((r.stdout || "") + (r.stderr || ""));
|
||||
const list = adb("devices");
|
||||
console.log(list.stdout || "");
|
||||
if (!(list.stdout || "").includes(NOX_SERIAL)) {
|
||||
console.error(`[nox] 未发现设备 ${NOX_SERIAL},请确认夜神模拟器已启动。`);
|
||||
process.exit(1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
function serve(port) {
|
||||
if (!fs.existsSync(path.join(H5_DIST, "index.html"))) {
|
||||
console.error(
|
||||
`[nox] 未找到 H5 产物:${H5_DIST},请先执行 build:h5 或加 --build。`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const server = http.createServer((req, res) => {
|
||||
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
||||
let filePath = path.join(H5_DIST, urlPath === "/" ? "index.html" : urlPath);
|
||||
if (!filePath.startsWith(H5_DIST)) {
|
||||
res.writeHead(403);
|
||||
return res.end("forbidden");
|
||||
}
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// SPA 兜底:未知路径回 index.html
|
||||
filePath = path.join(H5_DIST, "index.html");
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type":
|
||||
MIME[path.extname(filePath).toLowerCase()] ||
|
||||
"application/octet-stream",
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(
|
||||
`[nox] 静态服务已启动:http://${lanIp()}:${port} (Ctrl+C 停止)`,
|
||||
);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
function openBrowser(url) {
|
||||
const r = adb(
|
||||
"-s",
|
||||
NOX_SERIAL,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
url,
|
||||
);
|
||||
console.log((r.stdout || "") + (r.stderr || ""));
|
||||
}
|
||||
|
||||
function screenshot() {
|
||||
const out = path.join(ROOT, "unpackage", "nox-screenshot.png");
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
const r = spawnSync(ADB, ["-s", NOX_SERIAL, "exec-out", "screencap", "-p"], {
|
||||
encoding: "buffer",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (r.error || !r.stdout || r.stdout.length === 0) {
|
||||
console.error(
|
||||
"[nox] 截图失败:设备可能离线,先执行 node scripts/nox.mjs connect 后重试",
|
||||
);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(out, r.stdout);
|
||||
console.log(`[nox] 截图已保存:${out}(${r.stdout.length} 字节)`);
|
||||
}
|
||||
|
||||
function run(cmdName, cmdArgs) {
|
||||
const r = spawnSync(cmdName, cmdArgs, { encoding: "utf8", shell: false });
|
||||
console.log((r.stdout || "") + (r.stderr || ""));
|
||||
return r;
|
||||
}
|
||||
|
||||
function buildH5() {
|
||||
console.log("[nox] 构建 H5 产物 ...");
|
||||
const npmCmd = process.platform === "win32" ? "npm" : "npm";
|
||||
const r = spawnSync(npmCmd, ["run", "build:h5"], {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
if (r.status !== 0) {
|
||||
console.error("[nox] 构建失败");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function native() {
|
||||
console.log(
|
||||
"[nox] 通过 HBuilderX CLI 运行到安卓基座(需项目已在 HBuilderX 中识别为 uni-app)...",
|
||||
);
|
||||
run(CLI, [
|
||||
"launch",
|
||||
"app-android",
|
||||
"--project",
|
||||
PROJECT_NAME,
|
||||
"--deviceId",
|
||||
NOX_SERIAL,
|
||||
"--playground",
|
||||
"standard",
|
||||
]);
|
||||
}
|
||||
|
||||
const port = Number(getFlag("port", 5199));
|
||||
|
||||
switch (cmd) {
|
||||
case "connect":
|
||||
connect();
|
||||
break;
|
||||
case "serve":
|
||||
if (hasFlag("build")) buildH5();
|
||||
connect();
|
||||
serve(port);
|
||||
break;
|
||||
case "open":
|
||||
connect();
|
||||
openBrowser(getFlag("url", `http://${lanIp()}:${port}`));
|
||||
break;
|
||||
case "screenshot":
|
||||
connect();
|
||||
screenshot();
|
||||
break;
|
||||
case "native":
|
||||
connect();
|
||||
native();
|
||||
break;
|
||||
case "test":
|
||||
default: {
|
||||
if (hasFlag("build")) buildH5();
|
||||
connect();
|
||||
const server = serve(port);
|
||||
const url = `http://${lanIp()}:${port}`;
|
||||
setTimeout(() => {
|
||||
openBrowser(url);
|
||||
console.log(`[nox] 已在 Nox 浏览器打开:${url}`);
|
||||
if (hasFlag("shot")) setTimeout(screenshot, 4000);
|
||||
}, 800);
|
||||
process.on("SIGINT", () => {
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types='@dcloudio/types' />
|
||||
import 'vue'
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
type Hooks = App.AppInstance & Page.PageInstance;
|
||||
|
||||
interface ComponentCustomOptions extends Hooks {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
|
||||
onLaunch(() => {
|
||||
console.log("App Launch");
|
||||
});
|
||||
onShow(() => {
|
||||
console.log("App Show");
|
||||
});
|
||||
onHide(() => {
|
||||
console.log("App Hide");
|
||||
});
|
||||
</script>
|
||||
<style></style>
|
||||
@@ -0,0 +1,429 @@
|
||||
import { db } from "@/db/local";
|
||||
import {
|
||||
hashPattern,
|
||||
deriveKey,
|
||||
encryptText,
|
||||
decryptText,
|
||||
generatePassword,
|
||||
} from "@/utils/crypto";
|
||||
import type { PasswordRow } from "@/db/local";
|
||||
|
||||
/** 会话密钥仅存本机 storage(App 端可进一步迁移到 KeyChain/iCloudKit),退出登录即清除 */
|
||||
const SESSION_KEY = "pm_session_key";
|
||||
|
||||
/** 哈希加料常量,避免本机库被直接拿到图案哈希后离线撞库(9 点全排列规模有限) */
|
||||
const PATTERN_PEPPER = "uni-security-pwd-mgr";
|
||||
|
||||
export function randomSalt(): string {
|
||||
return generatePassword({
|
||||
length: 24,
|
||||
upper: true,
|
||||
lower: true,
|
||||
digits: true,
|
||||
symbols: false,
|
||||
});
|
||||
}
|
||||
|
||||
function patternToHash(pattern: string, salt: string): string {
|
||||
return hashPattern(`${pattern}#${PATTERN_PEPPER}#${salt}`, 0);
|
||||
}
|
||||
|
||||
/** 备用密码校验哈希(与图案同构的加料哈希) */
|
||||
function backupToHash(pwd: string, salt: string): string {
|
||||
return hashPattern(`${pwd}#${PATTERN_PEPPER}#${salt}`, 0);
|
||||
}
|
||||
|
||||
export function saveSessionKey(key: string) {
|
||||
uni.setStorageSync(SESSION_KEY, key);
|
||||
}
|
||||
|
||||
/** 会话保险库密钥(= 备份密码派生的 backupKey);仅输入备份密码后存在,用于加解密 pwd_cipher */
|
||||
export function getSessionKey(): string {
|
||||
return uni.getStorageSync(SESSION_KEY) || "";
|
||||
}
|
||||
|
||||
/* ---------------- 图案开门标记(仅表示已解锁进入,不含保险库密钥) ---------------- */
|
||||
const UNLOCK_KEY = "pm_unlocked";
|
||||
|
||||
/** 图案是否已通过校验(可进入 App 看元数据,但看不到密码值) */
|
||||
export function isUnlocked(): boolean {
|
||||
return uni.getStorageSync(UNLOCK_KEY) === "1";
|
||||
}
|
||||
|
||||
export function setUnlocked(v: boolean): void {
|
||||
if (v) uni.setStorageSync(UNLOCK_KEY, "1");
|
||||
else uni.removeStorageSync(UNLOCK_KEY);
|
||||
}
|
||||
|
||||
/* ---------------- 登录方式标记(决定“高级”分类是否可见) ---------------- */
|
||||
const LOGIN_MODE_KEY = "pm_login_mode";
|
||||
|
||||
/** 当前会话登录方式:'pattern' | 'password' | '' */
|
||||
export function getLoginMode(): string {
|
||||
return uni.getStorageSync(LOGIN_MODE_KEY) || "";
|
||||
}
|
||||
|
||||
export function setLoginMode(mode: string): void {
|
||||
uni.setStorageSync(LOGIN_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
uni.removeStorageSync(SESSION_KEY);
|
||||
uni.removeStorageSync(LOGIN_MODE_KEY);
|
||||
uni.removeStorageSync(UNLOCK_KEY);
|
||||
}
|
||||
|
||||
/* ---------------- 图案登录锁定(错 5 次锁 24 小时) ---------------- */
|
||||
const FAIL_KEY = "pm_pattern_fail";
|
||||
const LOCK_KEY = "pm_pattern_lock_until";
|
||||
const MAX_FAIL = 5;
|
||||
const LOCK_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 当前图案登录是否处于锁定态及剩余毫秒 */
|
||||
export function getPatternLock(): { locked: boolean; remainMs: number } {
|
||||
const until = Number(uni.getStorageSync(LOCK_KEY) || 0);
|
||||
const now = Date.now();
|
||||
if (until > now) return { locked: true, remainMs: until - now };
|
||||
if (until) {
|
||||
uni.removeStorageSync(LOCK_KEY);
|
||||
uni.removeStorageSync(FAIL_KEY);
|
||||
}
|
||||
return { locked: false, remainMs: 0 };
|
||||
}
|
||||
|
||||
/** 已记录的连续错误次数 */
|
||||
export function getFailCount(): number {
|
||||
return Number(uni.getStorageSync(FAIL_KEY) || 0);
|
||||
}
|
||||
|
||||
/** 记录一次图案错误;达阈值则锁定 24 小时,返回锁定状态 */
|
||||
export function notePatternFail(): { locked: boolean; remainMs: number } {
|
||||
const c = getFailCount() + 1;
|
||||
if (c >= MAX_FAIL) {
|
||||
uni.setStorageSync(LOCK_KEY, Date.now() + LOCK_MS);
|
||||
uni.removeStorageSync(FAIL_KEY);
|
||||
return getPatternLock();
|
||||
}
|
||||
uni.setStorageSync(FAIL_KEY, c);
|
||||
return { locked: false, remainMs: 0 };
|
||||
}
|
||||
|
||||
/** 清除锁定与计数(备用密码登录成功后调用) */
|
||||
export function clearPatternLock(): void {
|
||||
uni.removeStorageSync(FAIL_KEY);
|
||||
uni.removeStorageSync(LOCK_KEY);
|
||||
}
|
||||
|
||||
/* ---------------- 备用密码登录锁定(错 5 次锁 24 小时,期间图案登录一并禁止) ---------------- */
|
||||
const PWD_FAIL_KEY = "pm_pwd_fail";
|
||||
const PWD_LOCK_KEY = "pm_pwd_lock_until";
|
||||
|
||||
/** 当前密码登录是否处于锁定态及剩余毫秒 */
|
||||
export function getPwdLock(): { locked: boolean; remainMs: number } {
|
||||
const until = Number(uni.getStorageSync(PWD_LOCK_KEY) || 0);
|
||||
const now = Date.now();
|
||||
if (until > now) return { locked: true, remainMs: until - now };
|
||||
if (until) {
|
||||
uni.removeStorageSync(PWD_LOCK_KEY);
|
||||
uni.removeStorageSync(PWD_FAIL_KEY);
|
||||
}
|
||||
return { locked: false, remainMs: 0 };
|
||||
}
|
||||
|
||||
/** 已记录的连续密码错误次数 */
|
||||
export function getPwdFailCount(): number {
|
||||
return Number(uni.getStorageSync(PWD_FAIL_KEY) || 0);
|
||||
}
|
||||
|
||||
/** 记录一次密码错误;达阈值则锁定 24 小时,返回锁定状态 */
|
||||
export function notePwdFail(): { locked: boolean; remainMs: number } {
|
||||
const c = getPwdFailCount() + 1;
|
||||
if (c >= MAX_FAIL) {
|
||||
uni.setStorageSync(PWD_LOCK_KEY, Date.now() + LOCK_MS);
|
||||
uni.removeStorageSync(PWD_FAIL_KEY);
|
||||
return getPwdLock();
|
||||
}
|
||||
uni.setStorageSync(PWD_FAIL_KEY, c);
|
||||
return { locked: false, remainMs: 0 };
|
||||
}
|
||||
|
||||
/** 清除密码锁定与计数 */
|
||||
export function clearPwdLock(): void {
|
||||
uni.removeStorageSync(PWD_FAIL_KEY);
|
||||
uni.removeStorageSync(PWD_LOCK_KEY);
|
||||
}
|
||||
|
||||
export async function hasUser(): Promise<boolean> {
|
||||
return (await db.getUser()) !== null;
|
||||
}
|
||||
|
||||
/** 首次设置:图案校验哈希 + 备份密码派生密钥(pwd_cipher 的根密钥);不再存图案包裹的数据密钥 */
|
||||
export async function setupPattern(
|
||||
pattern: string,
|
||||
backupPassword: string,
|
||||
): Promise<void> {
|
||||
const salt = randomSalt();
|
||||
const backupSalt = randomSalt();
|
||||
const backupKey = deriveKey(backupPassword, backupSalt);
|
||||
await db.createUser({
|
||||
patternHash: patternToHash(pattern, salt),
|
||||
salt,
|
||||
backupHash: backupToHash(backupPassword, backupSalt),
|
||||
backupSalt,
|
||||
keyCipher: "",
|
||||
});
|
||||
// 刚设置时已知备份密码,直接解锁保险库
|
||||
saveSessionKey(backupKey);
|
||||
setUnlocked(true);
|
||||
setLoginMode("password");
|
||||
}
|
||||
|
||||
/** 图案校验:仅开门(可看元数据),不写入保险库密钥 */
|
||||
export async function verifyPattern(pattern: string): Promise<boolean> {
|
||||
const user = await db.getUser();
|
||||
if (!user) return false;
|
||||
if (patternToHash(pattern, user.salt) !== user.pattern_hash) return false;
|
||||
// 图案仅开门:不写入保险库密钥,密码值需再输入备份密码才可见
|
||||
uni.removeStorageSync(SESSION_KEY);
|
||||
setUnlocked(true);
|
||||
// 图案登录:隐藏“高级”分类(抵御逼问)
|
||||
setLoginMode("pattern");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface PwdItem {
|
||||
id: number;
|
||||
title: string;
|
||||
account: string;
|
||||
password: string;
|
||||
note: string;
|
||||
category: string;
|
||||
image: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function rowToItem(row: PasswordRow, key: string): PwdItem {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
account: row.account,
|
||||
password: key ? decryptText(key, row.pwd_cipher) : "",
|
||||
note: row.note,
|
||||
category: row.category || "",
|
||||
image: row.image || "",
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPasswords(): Promise<PwdItem[]> {
|
||||
// 已开门或有保险库密钥即可列元数据;无密钥时 password 字段为空(需备份密码才解密)
|
||||
if (!isUnlocked() && !getSessionKey()) return [];
|
||||
const key = getSessionKey();
|
||||
const rows = await db.listPasswords();
|
||||
return rows.map((r) => rowToItem(r, key));
|
||||
}
|
||||
|
||||
/** 单条读取(解密后返回),无保险库密钥时返回 null */
|
||||
export async function getPassword(id: number): Promise<PwdItem | null> {
|
||||
const key = getSessionKey();
|
||||
if (!key) return null;
|
||||
const row = await db.getPassword(id);
|
||||
return row ? rowToItem(row, key) : null;
|
||||
}
|
||||
|
||||
export async function savePassword(input: {
|
||||
id?: number;
|
||||
title: string;
|
||||
account: string;
|
||||
password: string;
|
||||
note: string;
|
||||
category: string;
|
||||
image: string;
|
||||
}): Promise<void> {
|
||||
const key = getSessionKey();
|
||||
if (!key) throw new Error("请先输入备份密码");
|
||||
const cipher = encryptText(key, input.password);
|
||||
if (input.id) {
|
||||
await db.updatePassword(input.id, {
|
||||
title: input.title,
|
||||
account: input.account,
|
||||
pwd_cipher: cipher,
|
||||
note: input.note,
|
||||
category: input.category,
|
||||
image: input.image,
|
||||
});
|
||||
} else {
|
||||
await db.createPassword({
|
||||
title: input.title,
|
||||
account: input.account,
|
||||
pwd_cipher: cipher,
|
||||
note: input.note,
|
||||
category: input.category,
|
||||
image: input.image,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePassword(id: number): Promise<void> {
|
||||
await db.deletePassword(id);
|
||||
}
|
||||
|
||||
/** 是否已设置备用密码(旧用户可能为空) */
|
||||
export async function hasBackup(): Promise<boolean> {
|
||||
const u = await db.getUser();
|
||||
return !!u && !!u.backup_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入备份密码解锁保险库(校验哈希 -> 得 backupKey -> 写入会话)。
|
||||
* 若为旧库(key_cipher 非空),顺带一次性迁移:用旧数据密钥解出明文 -> 用 backupKey 重存。
|
||||
* @param elevate 是否升级为“密码登录”(显示“高级”并清锁定);创建/查看仅需密钥时传 false。
|
||||
*/
|
||||
export async function unlockVault(
|
||||
pwd: string,
|
||||
elevate = true,
|
||||
): Promise<boolean> {
|
||||
const user = await db.getUser();
|
||||
if (!user || !user.backup_hash) return false;
|
||||
const backupKey = deriveKey(pwd, user.backup_salt);
|
||||
if (backupToHash(pwd, user.backup_salt) !== user.backup_hash) return false;
|
||||
if (user.key_cipher) {
|
||||
// 旧库:key_cipher = encrypt(backupKey, dataKey),据此取回旧数据密钥再重加密
|
||||
const oldDataKey = decryptText(backupKey, user.key_cipher);
|
||||
if (oldDataKey) {
|
||||
const rows = await db.listPasswords();
|
||||
const entries = rows.map((r) => ({
|
||||
id: r.id,
|
||||
pwd_cipher: encryptText(
|
||||
backupKey,
|
||||
decryptText(oldDataKey, r.pwd_cipher),
|
||||
),
|
||||
}));
|
||||
await db.rekeyPasswords(entries);
|
||||
}
|
||||
await db.setMigrated();
|
||||
}
|
||||
saveSessionKey(backupKey);
|
||||
setUnlocked(true);
|
||||
if (elevate) {
|
||||
// 明确的“密码登录”:才显示“高级”分类,并解除图案与密码锁定
|
||||
setLoginMode("password");
|
||||
clearPatternLock();
|
||||
clearPwdLock();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 忘记图案/重新验证:等价于解锁保险库 */
|
||||
export async function loginWithPassword(pwd: string): Promise<boolean> {
|
||||
return unlockVault(pwd);
|
||||
}
|
||||
|
||||
/** 修改备用密码:需当前保险库密钥解密旧密文,用新密钥全库重加密 */
|
||||
export async function setBackupPassword(newPwd: string): Promise<void> {
|
||||
const oldKey = getSessionKey();
|
||||
if (!oldKey) throw new Error("请先输入当前备份密码");
|
||||
const user = await db.getUser();
|
||||
if (!user) throw new Error("用户不存在");
|
||||
const backupSalt = randomSalt();
|
||||
const newKey = deriveKey(newPwd, backupSalt);
|
||||
const rows = await db.listPasswords();
|
||||
const entries = rows.map((r) => ({
|
||||
id: r.id,
|
||||
pwd_cipher: encryptText(newKey, decryptText(oldKey, r.pwd_cipher)),
|
||||
}));
|
||||
await db.rekeyPasswords(entries);
|
||||
await db.updateBackup(backupToHash(newPwd, backupSalt), backupSalt, "");
|
||||
saveSessionKey(newKey);
|
||||
}
|
||||
|
||||
/** 明文快照条目(备份文件内“密码”字段以明文存放,整体再由文件口令加密) */
|
||||
export interface PlainEntry {
|
||||
title: string;
|
||||
account: string;
|
||||
password: string;
|
||||
note: string;
|
||||
category: string;
|
||||
image: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
export interface PlainDump {
|
||||
user: { pattern_hash: string; salt: string };
|
||||
passwords: PlainEntry[];
|
||||
}
|
||||
|
||||
/** 导出明文快照(供备份文件口令加密):需保险库密钥把 pwd_cipher 解回明文 */
|
||||
export async function getExportPlainDump(): Promise<PlainDump> {
|
||||
const key = getSessionKey();
|
||||
if (!key) throw new Error("请先输入备份密码");
|
||||
const user = await db.getUser();
|
||||
if (!user) throw new Error("用户不存在");
|
||||
const rows = await db.listPasswords();
|
||||
return {
|
||||
user: { pattern_hash: user.pattern_hash, salt: user.salt },
|
||||
passwords: rows.map((r) => ({
|
||||
title: r.title,
|
||||
account: r.account,
|
||||
password: decryptText(key, r.pwd_cipher),
|
||||
note: r.note || "",
|
||||
category: r.category || "",
|
||||
image: r.image || "",
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从明文快照恢复:用新备份密码派生密钥重新加密入库,保留原图案 */
|
||||
export async function restoreFromPlainDump(
|
||||
dump: PlainDump,
|
||||
newBackupPwd: string,
|
||||
): Promise<void> {
|
||||
const backupSalt = randomSalt();
|
||||
const backupKey = deriveKey(newBackupPwd, backupSalt);
|
||||
const now = Date.now();
|
||||
const passwords = (dump.passwords || []).map((p, i) => ({
|
||||
id: i + 1,
|
||||
title: p.title,
|
||||
account: p.account || "",
|
||||
pwd_cipher: encryptText(backupKey, p.password || ""),
|
||||
note: p.note || "",
|
||||
category: p.category || "",
|
||||
image: p.image || "",
|
||||
created_at: p.created_at || now,
|
||||
updated_at: p.updated_at || now,
|
||||
}));
|
||||
const user = {
|
||||
id: 1,
|
||||
pattern_hash: dump.user?.pattern_hash || "",
|
||||
salt: dump.user?.salt || randomSalt(),
|
||||
backup_hash: backupToHash(newBackupPwd, backupSalt),
|
||||
backup_salt: backupSalt,
|
||||
key_cipher: "",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
await db.importAll({ user, passwords });
|
||||
saveSessionKey(backupKey);
|
||||
setUnlocked(true);
|
||||
setLoginMode("password");
|
||||
}
|
||||
|
||||
/** 修改图案:仅更新图案校验哈希与盐,不再重加密(密码由备份密码密钥保护,与图案无关) */
|
||||
export async function changePattern(
|
||||
oldPattern: string,
|
||||
newPattern: string,
|
||||
): Promise<boolean> {
|
||||
const user = await db.getUser();
|
||||
if (!user) return false;
|
||||
if (patternToHash(oldPattern, user.salt) !== user.pattern_hash) return false;
|
||||
const newSalt = randomSalt();
|
||||
await db.updateUserPattern(patternToHash(newPattern, newSalt), newSalt);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function resetApp(): Promise<void> {
|
||||
clearSession();
|
||||
await db.clearAll();
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<view
|
||||
class="pattern-lock"
|
||||
:class="{ 'is-error': error }"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<view class="lock-area" ref="areaRef">
|
||||
<svg class="lock-lines" :width="areaSize" :height="areaSize">
|
||||
<polyline
|
||||
v-if="points.length > 1"
|
||||
:points="polylinePoints"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.85)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
v-if="current && points.length >= 1"
|
||||
:x1="lastPoint.x"
|
||||
:y1="lastPoint.y"
|
||||
:x2="current.x"
|
||||
:y2="current.y"
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<view
|
||||
v-for="i in gridSize * gridSize"
|
||||
:key="i"
|
||||
class="lock-dot"
|
||||
:class="{ 'is-active': selected.includes(i - 1) }"
|
||||
:style="dotStyle(i - 1)"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, getCurrentInstance } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
error?: boolean;
|
||||
/** 父级递增此值以清空当前绘制 */
|
||||
resetKey?: number;
|
||||
size?: number;
|
||||
}>(),
|
||||
{ error: false, resetKey: 0, size: 300 }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'confirm', pattern: string): void }>();
|
||||
|
||||
const gridSize = 3;
|
||||
const instance = getCurrentInstance();
|
||||
const areaSize = props.size;
|
||||
const gap = areaSize / gridSize;
|
||||
const dotRadius = gap * 0.28;
|
||||
const hitRadius = gap * 0.38;
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
|
||||
const selected = ref<number[]>([]);
|
||||
const current = ref<Pt | null>(null);
|
||||
let drawing = false;
|
||||
let areaRect: Pt & { w: number; h: number } | null = null;
|
||||
|
||||
function dotCenter(idx: number): Pt {
|
||||
const r = Math.floor(idx / gridSize);
|
||||
const c = idx % gridSize;
|
||||
return { x: gap * c + gap / 2, y: gap * r + gap / 2 };
|
||||
}
|
||||
|
||||
function dotStyle(idx: number) {
|
||||
const r = Math.floor(idx / gridSize);
|
||||
const c = idx % gridSize;
|
||||
const d = dotRadius * 2;
|
||||
return {
|
||||
width: `${d}px`,
|
||||
height: `${d}px`,
|
||||
left: `${gap * c + gap / 2 - dotRadius}px`,
|
||||
top: `${gap * r + gap / 2 - dotRadius}px`,
|
||||
};
|
||||
}
|
||||
|
||||
const points = computed<Pt[]>(() => selected.value.map(dotCenter));
|
||||
const polylinePoints = computed(() => points.value.map((p) => `${p.x},${p.y}`).join(' '));
|
||||
const lastPoint = computed<Pt>(() => (points.value.length ? points.value[points.value.length - 1] : { x: 0, y: 0 }));
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
selected.value = [];
|
||||
current.value = null;
|
||||
areaRect = null;
|
||||
}
|
||||
);
|
||||
|
||||
function measure(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
nextTick(() => {
|
||||
const query = uni.createSelectorQuery().in(instance?.proxy as any);
|
||||
query
|
||||
.select('.lock-area')
|
||||
.boundingClientRect((rect: any) => {
|
||||
if (rect && rect.width) {
|
||||
areaRect = { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toLocal(t: { clientX: number; clientY: number }): Pt {
|
||||
return { x: t.clientX - (areaRect?.x ?? 0), y: t.clientY - (areaRect?.y ?? 0) };
|
||||
}
|
||||
|
||||
/** 命中检测:未选中的点中距离最近且在半径内 */
|
||||
function hitDot(p: Pt): number {
|
||||
for (let i = 0; i < gridSize * gridSize; i++) {
|
||||
if (selected.value.includes(i)) continue;
|
||||
const c = dotCenter(i);
|
||||
const dx = p.x - c.x;
|
||||
const dy = p.y - c.y;
|
||||
if (dx * dx + dy * dy <= hitRadius * hitRadius) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function onTouchStart(e: TouchEvent) {
|
||||
if (!areaRect) await measure();
|
||||
drawing = true;
|
||||
selected.value = [];
|
||||
current.value = null;
|
||||
const touch = e.touches[0];
|
||||
if (touch) {
|
||||
const p = toLocal(touch);
|
||||
const idx = hitDot(p);
|
||||
if (idx >= 0) selected.value = [idx];
|
||||
current.value = p;
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!drawing || !areaRect) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const p = toLocal(touch);
|
||||
current.value = p;
|
||||
const idx = hitDot(p);
|
||||
if (idx >= 0) selected.value = [...selected.value, idx];
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
current.value = null;
|
||||
const pattern = selected.value.join('-');
|
||||
if (selected.value.length >= 3) {
|
||||
emit('confirm', pattern);
|
||||
} else if (selected.value.length > 0) {
|
||||
// 少于 3 个点不构成有效图案,自动清空
|
||||
selected.value = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pattern-lock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lock-area {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.lock-lines {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lock-dot {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lock-dot.is-active {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.is-error .lock-dot {
|
||||
border-color: #ff8a80;
|
||||
}
|
||||
|
||||
.is-error .lock-dot.is-active {
|
||||
background: rgba(255, 138, 128, 0.35);
|
||||
}
|
||||
|
||||
.is-error {
|
||||
animation: lock-shake 0.4s;
|
||||
}
|
||||
|
||||
@keyframes lock-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 本地数据库层(数据只存设备,不联网)
|
||||
* - App 端:plus.sqlite 原生 SQLite
|
||||
* - H5 端:localStorage 模拟(开发/预览用,接口一致)
|
||||
*/
|
||||
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
pattern_hash: string;
|
||||
salt: string;
|
||||
backup_hash: string;
|
||||
backup_salt: string;
|
||||
key_cipher: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface PasswordRow {
|
||||
id: number;
|
||||
title: string;
|
||||
account: string;
|
||||
pwd_cipher: string;
|
||||
note: string;
|
||||
category: string;
|
||||
image: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type Db = { kind: "sqlite" } | { kind: "local" };
|
||||
|
||||
const LS_USER = "pm_db_user";
|
||||
const LS_PASSWORDS = "pm_db_passwords";
|
||||
|
||||
function openSqlite(): Promise<Db> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.sqlite.openDatabase({
|
||||
name: "security",
|
||||
path: "_doc/security.db",
|
||||
success: () => resolve({ kind: "sqlite" }),
|
||||
fail: (e: any) =>
|
||||
reject(new Error("openDatabase failed: " + JSON.stringify(e))),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function execSql(db: Db, sql: string): Promise<void> {
|
||||
if (db.kind === "local") return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.sqlite.executeSql({
|
||||
name: "security",
|
||||
sql: sql as unknown as string[],
|
||||
success: () => resolve(),
|
||||
fail: (e: any) =>
|
||||
reject(
|
||||
new Error("execSql failed: " + JSON.stringify(e) + " sql=" + sql),
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectSql(db: Db, sql: string): Promise<any[]> {
|
||||
if (db.kind === "local") return Promise.resolve([]);
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.sqlite.selectSql({
|
||||
name: "security",
|
||||
sql,
|
||||
success: (data: any[]) => resolve(data || []),
|
||||
fail: (e: any) =>
|
||||
reject(
|
||||
new Error("selectSql failed: " + JSON.stringify(e) + " sql=" + sql),
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** SQL 单引号转义(plus.sqlite 预编译参数支持有限,统一做字面量转义) */
|
||||
function q(v: string | number): string {
|
||||
if (typeof v === "number") return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function readLocal<T>(key: string): T[] {
|
||||
try {
|
||||
return JSON.parse(uni.getStorageSync(key) || "[]") as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocal<T>(key: string, rows: T[]) {
|
||||
uni.setStorageSync(key, JSON.stringify(rows));
|
||||
}
|
||||
|
||||
let dbPromise: Promise<Db> | null = null;
|
||||
|
||||
function getDb(): Promise<Db> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = (async () => {
|
||||
let db: Db;
|
||||
// #ifdef APP-PLUS
|
||||
db = await openSqlite();
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
db = { kind: "local" };
|
||||
// #endif
|
||||
await initSchema(db);
|
||||
return db;
|
||||
})();
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function initSchema(db: Db) {
|
||||
if (db.kind === "sqlite") {
|
||||
await execSql(
|
||||
db,
|
||||
'CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, pattern_hash TEXT NOT NULL, salt TEXT NOT NULL, backup_hash TEXT DEFAULT "", backup_salt TEXT DEFAULT "", key_cipher TEXT DEFAULT "", created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)',
|
||||
);
|
||||
await execSql(
|
||||
db,
|
||||
'CREATE TABLE IF NOT EXISTS passwords (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, account TEXT DEFAULT "", pwd_cipher TEXT NOT NULL, note TEXT DEFAULT "", category TEXT DEFAULT "", image TEXT DEFAULT "", created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)',
|
||||
);
|
||||
}
|
||||
// local 模式无需建表,读写时按 JSON 处理
|
||||
// App 端旧库兼容:若 passwords 表缺少 category / image 列则补齐
|
||||
if (db.kind === "sqlite") {
|
||||
try {
|
||||
const cols = await selectSql(db, "PRAGMA table_info(passwords)");
|
||||
const has = (name: string) => cols.some((c: any) => c.name === name);
|
||||
if (cols.length && !has("category")) {
|
||||
await execSql(
|
||||
db,
|
||||
'ALTER TABLE passwords ADD COLUMN category TEXT DEFAULT ""',
|
||||
);
|
||||
}
|
||||
if (cols.length && !has("image")) {
|
||||
await execSql(
|
||||
db,
|
||||
'ALTER TABLE passwords ADD COLUMN image TEXT DEFAULT ""',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* PRAGMA 不被支持时忽略,建表语句已含该列 */
|
||||
}
|
||||
// 旧库兼容:user 表补齐备用密码恢复相关列
|
||||
try {
|
||||
const ucols = await selectSql(db, "PRAGMA table_info(user)");
|
||||
const uhas = (name: string) => ucols.some((c: any) => c.name === name);
|
||||
if (ucols.length && !uhas("backup_hash")) {
|
||||
await execSql(
|
||||
db,
|
||||
'ALTER TABLE user ADD COLUMN backup_hash TEXT DEFAULT ""',
|
||||
);
|
||||
}
|
||||
if (ucols.length && !uhas("backup_salt")) {
|
||||
await execSql(
|
||||
db,
|
||||
'ALTER TABLE user ADD COLUMN backup_salt TEXT DEFAULT ""',
|
||||
);
|
||||
}
|
||||
if (ucols.length && !uhas("key_cipher")) {
|
||||
await execSql(
|
||||
db,
|
||||
'ALTER TABLE user ADD COLUMN key_cipher TEXT DEFAULT ""',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = {
|
||||
/** 当前唯一用户(密码管理器为单机单用户) */
|
||||
async getUser(): Promise<UserRow | null> {
|
||||
const d = await getDb();
|
||||
if (d.kind === "sqlite") {
|
||||
const rows = await selectSql(d, "SELECT * FROM user ORDER BY id LIMIT 1");
|
||||
return rows.length ? (rows[0] as UserRow) : null;
|
||||
}
|
||||
const rows = readLocal<UserRow>(LS_USER);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async createUser(input: {
|
||||
patternHash: string;
|
||||
salt: string;
|
||||
backupHash: string;
|
||||
backupSalt: string;
|
||||
keyCipher: string;
|
||||
}): Promise<number> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(
|
||||
d,
|
||||
`INSERT INTO user (pattern_hash, salt, backup_hash, backup_salt, key_cipher, created_at, updated_at) VALUES (${q(input.patternHash)}, ${q(input.salt)}, ${q(input.backupHash)}, ${q(input.backupSalt)}, ${q(input.keyCipher)}, ${now}, ${now})`,
|
||||
);
|
||||
const rows = await selectSql(d, "SELECT last_insert_rowid() AS id");
|
||||
return Number(rows[0]?.id ?? 1);
|
||||
}
|
||||
const rows = readLocal<UserRow>(LS_USER);
|
||||
const id = (rows[0]?.id ?? 0) + 1;
|
||||
writeLocal(LS_USER, [
|
||||
{
|
||||
id,
|
||||
pattern_hash: input.patternHash,
|
||||
salt: input.salt,
|
||||
backup_hash: input.backupHash,
|
||||
backup_salt: input.backupSalt,
|
||||
key_cipher: input.keyCipher,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
},
|
||||
|
||||
/** 设置/修改备用密码:写入校验哈希与用备用密钥包裹的数据密钥 */
|
||||
async updateBackup(
|
||||
backupHash: string,
|
||||
backupSalt: string,
|
||||
keyCipher: string,
|
||||
): Promise<void> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(
|
||||
d,
|
||||
`UPDATE user SET backup_hash = ${q(backupHash)}, backup_salt = ${q(backupSalt)}, key_cipher = ${q(keyCipher)}, updated_at = ${now} WHERE id = 1`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rows = readLocal<UserRow>(LS_USER);
|
||||
if (rows[0]) {
|
||||
rows[0] = {
|
||||
...rows[0],
|
||||
backup_hash: backupHash,
|
||||
backup_salt: backupSalt,
|
||||
key_cipher: keyCipher,
|
||||
updated_at: now,
|
||||
};
|
||||
writeLocal(LS_USER, rows);
|
||||
}
|
||||
},
|
||||
|
||||
async updateUserPattern(patternHash: string, salt: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(
|
||||
d,
|
||||
`UPDATE user SET pattern_hash = ${q(patternHash)}, salt = ${q(salt)}, updated_at = ${now} WHERE id = 1`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rows = readLocal<UserRow>(LS_USER);
|
||||
if (rows[0]) {
|
||||
rows[0] = {
|
||||
...rows[0],
|
||||
pattern_hash: patternHash,
|
||||
salt,
|
||||
updated_at: now,
|
||||
};
|
||||
writeLocal(LS_USER, rows);
|
||||
}
|
||||
},
|
||||
|
||||
async listPasswords(): Promise<PasswordRow[]> {
|
||||
const d = await getDb();
|
||||
if (d.kind === "sqlite") {
|
||||
return (await selectSql(
|
||||
d,
|
||||
"SELECT * FROM passwords ORDER BY updated_at DESC",
|
||||
)) as PasswordRow[];
|
||||
}
|
||||
return readLocal<PasswordRow>(LS_PASSWORDS).sort(
|
||||
(a, b) => b.updated_at - a.updated_at,
|
||||
);
|
||||
},
|
||||
|
||||
async getPassword(id: number): Promise<PasswordRow | null> {
|
||||
const all = await this.listPasswords();
|
||||
return all.find((r) => r.id === id) ?? null;
|
||||
},
|
||||
|
||||
async createPassword(
|
||||
p: Omit<PasswordRow, "id" | "created_at" | "updated_at">,
|
||||
): Promise<number> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(
|
||||
d,
|
||||
`INSERT INTO passwords (title, account, pwd_cipher, note, category, image, created_at, updated_at) VALUES (${q(p.title)}, ${q(p.account)}, ${q(p.pwd_cipher)}, ${q(p.note)}, ${q(p.category || "")}, ${q(p.image || "")}, ${now}, ${now})`,
|
||||
);
|
||||
const rows = await selectSql(d, "SELECT last_insert_rowid() AS id");
|
||||
return Number(rows[0]?.id ?? 0);
|
||||
}
|
||||
const rows = readLocal<PasswordRow>(LS_PASSWORDS);
|
||||
const id = rows.reduce((m, r) => Math.max(m, r.id), 0) + 1;
|
||||
rows.push({ id, ...p, created_at: now, updated_at: now });
|
||||
writeLocal(LS_PASSWORDS, rows);
|
||||
return id;
|
||||
},
|
||||
|
||||
async updatePassword(
|
||||
id: number,
|
||||
p: Omit<PasswordRow, "id" | "created_at" | "updated_at">,
|
||||
): Promise<void> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(
|
||||
d,
|
||||
`UPDATE passwords SET title = ${q(p.title)}, account = ${q(p.account)}, pwd_cipher = ${q(p.pwd_cipher)}, note = ${q(p.note)}, category = ${q(p.category || "")}, image = ${q(p.image || "")}, updated_at = ${now} WHERE id = ${id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rows = readLocal<PasswordRow>(LS_PASSWORDS);
|
||||
const idx = rows.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) {
|
||||
rows[idx] = { ...rows[idx], ...p, updated_at: now };
|
||||
writeLocal(LS_PASSWORDS, rows);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePassword(id: number): Promise<void> {
|
||||
const d = await getDb();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(d, `DELETE FROM passwords WHERE id = ${id}`);
|
||||
return;
|
||||
}
|
||||
writeLocal(
|
||||
LS_PASSWORDS,
|
||||
readLocal<PasswordRow>(LS_PASSWORDS).filter((r) => r.id !== id),
|
||||
);
|
||||
},
|
||||
|
||||
/** 重置图案时批量重加密:替换全部密文与用户校验信息 */
|
||||
async rekeyAll(
|
||||
entries: { id: number; pwd_cipher: string }[],
|
||||
patternHash: string,
|
||||
salt: string,
|
||||
): Promise<void> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
const stmts = entries.map(
|
||||
(e) =>
|
||||
`UPDATE passwords SET pwd_cipher = ${q(e.pwd_cipher)}, updated_at = ${now} WHERE id = ${e.id}`,
|
||||
);
|
||||
stmts.push(
|
||||
`UPDATE user SET pattern_hash = ${q(patternHash)}, salt = ${q(salt)}, updated_at = ${now} WHERE id = 1`,
|
||||
);
|
||||
for (const s of stmts) await execSql(d, s);
|
||||
return;
|
||||
}
|
||||
const rows = readLocal<PasswordRow>(LS_PASSWORDS);
|
||||
for (const e of entries) {
|
||||
const idx = rows.findIndex((r) => r.id === e.id);
|
||||
if (idx >= 0) {
|
||||
rows[idx] = { ...rows[idx], pwd_cipher: e.pwd_cipher, updated_at: now };
|
||||
}
|
||||
}
|
||||
writeLocal(LS_PASSWORDS, rows);
|
||||
await this.updateUserPattern(patternHash, salt);
|
||||
},
|
||||
|
||||
/** 仅重加密密码列(备用密码变更 / 旧库迁移),不动用户校验信息 */
|
||||
async rekeyPasswords(
|
||||
entries: { id: number; pwd_cipher: string }[],
|
||||
): Promise<void> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
for (const e of entries) {
|
||||
await execSql(
|
||||
d,
|
||||
`UPDATE passwords SET pwd_cipher = ${q(e.pwd_cipher)}, updated_at = ${now} WHERE id = ${e.id}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rows = readLocal<PasswordRow>(LS_PASSWORDS);
|
||||
for (const e of entries) {
|
||||
const idx = rows.findIndex((r) => r.id === e.id);
|
||||
if (idx >= 0) {
|
||||
rows[idx] = { ...rows[idx], pwd_cipher: e.pwd_cipher, updated_at: now };
|
||||
}
|
||||
}
|
||||
writeLocal(LS_PASSWORDS, rows);
|
||||
},
|
||||
|
||||
/** 标记旧库迁移完成:清空 key_cipher(新库恒为空) */
|
||||
async setMigrated(): Promise<void> {
|
||||
const d = await getDb();
|
||||
const now = Date.now();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(
|
||||
d,
|
||||
`UPDATE user SET key_cipher = '', updated_at = ${now} WHERE id = 1`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rows = readLocal<UserRow>(LS_USER);
|
||||
if (rows[0]) {
|
||||
rows[0] = { ...rows[0], key_cipher: "", updated_at: now };
|
||||
writeLocal(LS_USER, rows);
|
||||
}
|
||||
},
|
||||
|
||||
/** 导出整库(供加密备份):原样返回 user 与 passwords 行 */
|
||||
async exportAll(): Promise<{
|
||||
user: UserRow | null;
|
||||
passwords: PasswordRow[];
|
||||
}> {
|
||||
const d = await getDb();
|
||||
if (d.kind === "sqlite") {
|
||||
const users = await selectSql(
|
||||
d,
|
||||
"SELECT * FROM user ORDER BY id LIMIT 1",
|
||||
);
|
||||
const pws = await selectSql(d, "SELECT * FROM passwords ORDER BY id");
|
||||
return {
|
||||
user: (users[0] as UserRow) || null,
|
||||
passwords: pws as PasswordRow[],
|
||||
};
|
||||
}
|
||||
const u = readLocal<UserRow>(LS_USER);
|
||||
return {
|
||||
user: u[0] || null,
|
||||
passwords: readLocal<PasswordRow>(LS_PASSWORDS),
|
||||
};
|
||||
},
|
||||
|
||||
/** 导入整库(备份恢复):先清空再按原 id 写回 */
|
||||
async importAll(dump: {
|
||||
user: UserRow | null;
|
||||
passwords: PasswordRow[];
|
||||
}): Promise<void> {
|
||||
const d = await getDb();
|
||||
await this.clearAll();
|
||||
if (d.kind === "sqlite") {
|
||||
const stmts: string[] = [];
|
||||
if (dump.user) {
|
||||
stmts.push(
|
||||
`INSERT INTO user (id, pattern_hash, salt, backup_hash, backup_salt, key_cipher, created_at, updated_at) VALUES (${dump.user.id}, ${q(dump.user.pattern_hash)}, ${q(dump.user.salt)}, ${q(dump.user.backup_hash || "")}, ${q(dump.user.backup_salt || "")}, ${q(dump.user.key_cipher || "")}, ${dump.user.created_at}, ${dump.user.updated_at})`,
|
||||
);
|
||||
}
|
||||
for (const p of dump.passwords || []) {
|
||||
stmts.push(
|
||||
`INSERT INTO passwords (id, title, account, pwd_cipher, note, category, image, created_at, updated_at) VALUES (${p.id}, ${q(p.title)}, ${q(p.account)}, ${q(p.pwd_cipher)}, ${q(p.note || "")}, ${q(p.category || "")}, ${q(p.image || "")}, ${p.created_at}, ${p.updated_at})`,
|
||||
);
|
||||
}
|
||||
for (const s of stmts) await execSql(d, s);
|
||||
return;
|
||||
}
|
||||
if (dump.user) writeLocal(LS_USER, [dump.user]);
|
||||
writeLocal(LS_PASSWORDS, dump.passwords || []);
|
||||
},
|
||||
|
||||
/** 清空全部数据(重置应用) */
|
||||
async clearAll(): Promise<void> {
|
||||
const d = await getDb();
|
||||
if (d.kind === "sqlite") {
|
||||
await execSql(d, "DELETE FROM passwords");
|
||||
await execSql(d, "DELETE FROM user");
|
||||
return;
|
||||
}
|
||||
uni.removeStorageSync(LS_USER);
|
||||
uni.removeStorageSync(LS_PASSWORDS);
|
||||
},
|
||||
};
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createSSRApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
return {
|
||||
app,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "安全小秘书",
|
||||
"appid": "__UNI__A3FEFA1",
|
||||
"description": "本地 SQLite 密码保险箱,图案密码登录",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules": {
|
||||
"sqlite": {}
|
||||
},
|
||||
/* 应用发布信息 */
|
||||
"distribute": {
|
||||
/* android打包配置 */
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios": {},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
"quickapp": {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-alipay": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-baidu": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-toutiao": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "安全小秘书"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "安全小秘书"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "安全小秘书",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F2F5FA"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- 底部放射状装饰 -->
|
||||
<view class="sunburst">
|
||||
<view v-for="i in 24" :key="i" class="ray" :style="{ transform: `rotate(${i * 15}deg)` }"></view>
|
||||
</view>
|
||||
|
||||
<view class="login-body">
|
||||
<view class="login-title">安全小秘书</view>
|
||||
<view class="login-tip">{{ tipText }}</view>
|
||||
|
||||
<!-- 图案绘制 / 校验 -->
|
||||
<PatternLock v-if="usePattern" :error="error" :reset-key="resetKey" @confirm="onConfirm" />
|
||||
|
||||
<!-- 图案锁定(错 5 次):可用备用密码解锁 -->
|
||||
<view v-if="mode === 'login' && patternLocked && !pwdLocked" class="lock-box">
|
||||
<text class="lock-title">🔒 图案登录已锁定</text>
|
||||
<text class="lock-sub">{{ lockText }}</text>
|
||||
<button class="pwd-btn" @click="onForgot">用备用密码解锁</button>
|
||||
</view>
|
||||
|
||||
<!-- 密码被禁(错 5 次):图案与密码均锁定一天 -->
|
||||
<view v-if="mode === 'login' && pwdLocked" class="lock-box">
|
||||
<text class="lock-title">🔒 登录已锁定</text>
|
||||
<text class="lock-sub">{{ pwdLockText }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 设置备用密码(首次) -->
|
||||
<view v-if="mode === 'setpwd'" class="pwd-box">
|
||||
<input v-model="pwd1" class="pwd-input" :password="true" placeholder="设置备用密码(至少 4 位)" placeholder-class="ph" />
|
||||
<input v-model="pwd2" class="pwd-input" :password="true" placeholder="再次输入以确认" placeholder-class="ph" />
|
||||
<button class="pwd-btn" @click="onSetBackup">完成设置</button>
|
||||
<text class="pwd-alt" @click="restartSetup">返回重设图案</text>
|
||||
</view>
|
||||
|
||||
<!-- 密码解锁(仅在图案错满 5 次锁定后,由锁定框的按钮进入) -->
|
||||
<view v-if="mode === 'pwd'" class="pwd-box">
|
||||
<input v-model="pwd1" class="pwd-input" :password="true" placeholder="请输入备用密码" placeholder-class="ph" confirm-type="done" @confirm="onPasswordLogin" />
|
||||
<button class="pwd-btn" @click="onPasswordLogin">解锁</button>
|
||||
<text class="pwd-alt" @click="backToPattern">返回图案解锁</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import PatternLock from '@/components/PatternLock.vue';
|
||||
import { hasUser, setupPattern, verifyPattern, hasBackup, loginWithPassword, getPatternLock, notePatternFail, getFailCount, getPwdLock, notePwdFail, getPwdFailCount } from '@/api/auth';
|
||||
|
||||
type Mode = 'setup' | 'confirm' | 'login' | 'setpwd' | 'pwd';
|
||||
|
||||
const mode = ref<Mode>('login');
|
||||
const error = ref(false);
|
||||
const resetKey = ref(0);
|
||||
const firstPattern = ref('');
|
||||
const pwd1 = ref('');
|
||||
const pwd2 = ref('');
|
||||
const patternLocked = ref(false);
|
||||
const lockRemain = ref(0);
|
||||
const pwdLocked = ref(false);
|
||||
const pwdLockRemain = ref(0);
|
||||
|
||||
const tipText = ref('正在初始化…');
|
||||
|
||||
const usePattern = computed(
|
||||
() => mode.value === 'setup' || mode.value === 'confirm' || (mode.value === 'login' && !patternLocked.value && !pwdLocked.value),
|
||||
);
|
||||
|
||||
function fmtRemain(ms: number): string {
|
||||
const total = Math.ceil(ms / 60000);
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return `${h} 小时 ${m} 分`;
|
||||
}
|
||||
|
||||
const lockText = computed(() => `连续错误过多,约 ${fmtRemain(lockRemain.value)} 后可重新使用图案,或立即用备用密码解锁。`);
|
||||
const pwdLockText = computed(() => `连续密码错误过多,图案与密码登录均已锁定,约 ${fmtRemain(pwdLockRemain.value)} 后可重试。`);
|
||||
|
||||
const TIPS: Record<Mode, string> = {
|
||||
setup: '首次使用,请绘制解锁图案',
|
||||
confirm: '请再次绘制以确认',
|
||||
login: '请输入解锁图案',
|
||||
setpwd: '请设置备用密码,忘记图案时可用它解锁',
|
||||
pwd: '请输入备用密码解锁',
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
mode.value = (await hasUser()) ? 'login' : 'setup';
|
||||
firstPattern.value = '';
|
||||
pwd1.value = '';
|
||||
pwd2.value = '';
|
||||
patternLocked.value = false;
|
||||
pwdLocked.value = false;
|
||||
if (mode.value === 'login') {
|
||||
const w = getPwdLock();
|
||||
pwdLocked.value = w.locked;
|
||||
pwdLockRemain.value = w.remainMs;
|
||||
const l = getPatternLock();
|
||||
patternLocked.value = l.locked;
|
||||
lockRemain.value = l.remainMs;
|
||||
if (w.locked) tipText.value = '登录已锁定';
|
||||
else if (l.locked) tipText.value = '图案登录已锁定';
|
||||
else tipText.value = TIPS.login;
|
||||
return;
|
||||
}
|
||||
tipText.value = TIPS[mode.value];
|
||||
});
|
||||
|
||||
function triggerError(tip: string) {
|
||||
error.value = true;
|
||||
tipText.value = tip;
|
||||
setTimeout(() => {
|
||||
error.value = false;
|
||||
resetKey.value++;
|
||||
tipText.value = TIPS[mode.value];
|
||||
}, 600);
|
||||
}
|
||||
|
||||
async function onConfirm(pattern: string) {
|
||||
if (mode.value === 'setup') {
|
||||
firstPattern.value = pattern;
|
||||
mode.value = 'confirm';
|
||||
tipText.value = TIPS.confirm;
|
||||
resetKey.value++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode.value === 'confirm') {
|
||||
if (pattern !== firstPattern.value) {
|
||||
triggerError('两次绘制不一致,请重新设置');
|
||||
mode.value = 'setup';
|
||||
firstPattern.value = '';
|
||||
return;
|
||||
}
|
||||
firstPattern.value = pattern;
|
||||
mode.value = 'setpwd';
|
||||
tipText.value = TIPS.setpwd;
|
||||
return;
|
||||
}
|
||||
|
||||
// login
|
||||
if (await verifyPattern(pattern)) {
|
||||
uni.reLaunch({ url: '/pages/index/index' });
|
||||
} else {
|
||||
const r = notePatternFail();
|
||||
if (r.locked) {
|
||||
lockRemain.value = r.remainMs;
|
||||
patternLocked.value = true;
|
||||
tipText.value = '图案登录已锁定';
|
||||
} else {
|
||||
triggerError(`图案密码错误,请重试(${getFailCount()}/5)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetBackup() {
|
||||
if (pwd1.value.length < 4) {
|
||||
uni.showToast({ title: '备用密码至少 4 位', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (pwd1.value !== pwd2.value) {
|
||||
uni.showToast({ title: '两次输入不一致', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
await setupPattern(firstPattern.value, pwd1.value);
|
||||
uni.reLaunch({ url: '/pages/index/index' });
|
||||
}
|
||||
|
||||
function restartSetup() {
|
||||
mode.value = 'setup';
|
||||
firstPattern.value = '';
|
||||
pwd1.value = '';
|
||||
pwd2.value = '';
|
||||
tipText.value = TIPS.setup;
|
||||
resetKey.value++;
|
||||
}
|
||||
|
||||
async function onForgot() {
|
||||
if (!(await hasBackup())) {
|
||||
uni.showToast({ title: '尚未设置备用密码,请用图案登录', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
mode.value = 'pwd';
|
||||
pwd1.value = '';
|
||||
tipText.value = TIPS.pwd;
|
||||
}
|
||||
|
||||
async function onPasswordLogin() {
|
||||
if (!pwd1.value) {
|
||||
uni.showToast({ title: '请输入备用密码', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (await loginWithPassword(pwd1.value)) {
|
||||
uni.reLaunch({ url: '/pages/index/index' });
|
||||
} else {
|
||||
const r = notePwdFail();
|
||||
if (r.locked) {
|
||||
pwdLocked.value = true;
|
||||
pwdLockRemain.value = r.remainMs;
|
||||
mode.value = 'login';
|
||||
pwd1.value = '';
|
||||
tipText.value = '登录已锁定';
|
||||
} else {
|
||||
triggerError(`备用密码错误,请重试(${getPwdFailCount()}/5)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function backToPattern() {
|
||||
mode.value = 'login';
|
||||
pwd1.value = '';
|
||||
tipText.value = TIPS.login;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a75d0 0%, #3f6fd0 70%, #5a6cc8 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sunburst {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -90px;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ray {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
height: 210px;
|
||||
margin-left: -5px;
|
||||
transform-origin: 50% 100%;
|
||||
background: repeating-linear-gradient(
|
||||
to top,
|
||||
rgba(255, 255, 255, 0.55) 0 12px,
|
||||
rgba(255, 255, 255, 0.18) 12px 22px,
|
||||
transparent 22px 34px
|
||||
);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 12vh 24px 40px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
letter-spacing: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 40px;
|
||||
min-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pwd-box {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lock-box {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.lock-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.lock-sub {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pwd-input {
|
||||
height: 46px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
color: #2b3240;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pwd-input .ph {
|
||||
color: #9aa3b2;
|
||||
}
|
||||
|
||||
.pwd-btn {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: #1a75d0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pwd-alt {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.login-foot {
|
||||
margin-top: auto;
|
||||
padding-top: 32px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.foot-link {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
export {}
|
||||
|
||||
declare module "vue" {
|
||||
type Hooks = App.AppInstance & Page.PageInstance;
|
||||
interface ComponentCustomOptions extends Hooks {}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 这里是uni-app内置的常用样式变量
|
||||
*
|
||||
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 文字基本颜色 */
|
||||
$uni-text-color: #333; // 基本色
|
||||
$uni-text-color-inverse: #fff; // 反色
|
||||
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
|
||||
$uni-text-color-placeholder: #808080;
|
||||
$uni-text-color-disable: #c0c0c0;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color: #fff;
|
||||
$uni-bg-color-grey: #f8f8f8;
|
||||
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
|
||||
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color: #c8c7cc;
|
||||
|
||||
/* 尺寸变量 */
|
||||
|
||||
/* 文字尺寸 */
|
||||
$uni-font-size-sm: 12px;
|
||||
$uni-font-size-base: 14px;
|
||||
$uni-font-size-lg: 16;
|
||||
|
||||
/* 图片尺寸 */
|
||||
$uni-img-size-sm: 20px;
|
||||
$uni-img-size-base: 26px;
|
||||
$uni-img-size-lg: 40px;
|
||||
|
||||
/* Border Radius */
|
||||
$uni-border-radius-sm: 2px;
|
||||
$uni-border-radius-base: 3px;
|
||||
$uni-border-radius-lg: 6px;
|
||||
$uni-border-radius-circle: 50%;
|
||||
|
||||
/* 水平间距 */
|
||||
$uni-spacing-row-sm: 5px;
|
||||
$uni-spacing-row-base: 10px;
|
||||
$uni-spacing-row-lg: 15px;
|
||||
|
||||
/* 垂直间距 */
|
||||
$uni-spacing-col-sm: 4px;
|
||||
$uni-spacing-col-base: 8px;
|
||||
$uni-spacing-col-lg: 12px;
|
||||
|
||||
/* 透明度 */
|
||||
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||
|
||||
/* 文章场景相关 */
|
||||
$uni-color-title: #2c405a; // 文章标题颜色
|
||||
$uni-font-size-title: 20px;
|
||||
$uni-color-subtitle: #555; // 二级标题颜色
|
||||
$uni-font-size-subtitle: 18px;
|
||||
$uni-color-paragraph: #3f536e; // 文章段落颜色
|
||||
$uni-font-size-paragraph: 15px;
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* App 端加密备份文件读写(仅 APP-PLUS 调用,H5 不编译此模块)
|
||||
* 备份文件存于 _doc/backups/,内容为 { v, salt, cipher }:
|
||||
* cipher = AES(deriveKey(备份文件密码, salt), JSON(明文快照))
|
||||
* 快照内“密码”字段为明文(导出时已用备份密码解出),整体受文件口令保护;
|
||||
* 恢复时用文件口令解密得到明文,再按新备份密码重新加密入库。
|
||||
*/
|
||||
import {
|
||||
deriveKey,
|
||||
encryptText,
|
||||
decryptText,
|
||||
generatePassword,
|
||||
} from "./crypto";
|
||||
|
||||
export interface BackupItem {
|
||||
name: string;
|
||||
localUrl: string;
|
||||
}
|
||||
|
||||
function plusAny(): any {
|
||||
return (globalThis as any).plus;
|
||||
}
|
||||
|
||||
function makeSalt(): string {
|
||||
return generatePassword({
|
||||
length: 24,
|
||||
upper: true,
|
||||
lower: true,
|
||||
digits: true,
|
||||
symbols: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成 yyyyMMddHHmmss 时间戳(本地时间) */
|
||||
function stamp(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function getBackupDir(): Promise<any> {
|
||||
const p = plusAny();
|
||||
return new Promise((resolve, reject) => {
|
||||
p.io.requestFileSystem(
|
||||
p.io.PRIVATE_DOC,
|
||||
(fs: any) => {
|
||||
fs.root.getDirectory(
|
||||
"backups",
|
||||
{ create: true },
|
||||
(dir: any) => resolve(dir),
|
||||
reject,
|
||||
);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成加密备份文件,返回本地路径(prefix 默认 security,自动备份传 auto) */
|
||||
export async function exportBackupFile(
|
||||
dump: unknown,
|
||||
password: string,
|
||||
prefix = "security",
|
||||
): Promise<string> {
|
||||
const salt = makeSalt();
|
||||
const key = deriveKey(password, salt);
|
||||
const cipher = encryptText(key, JSON.stringify(dump));
|
||||
const content = JSON.stringify({ v: 2, salt, cipher });
|
||||
const dir = await getBackupDir();
|
||||
const name = `${prefix}-${stamp(new Date())}.sec`;
|
||||
const fileEntry: any = await new Promise((res, rej) =>
|
||||
dir.getFile(name, { create: true }, res, rej),
|
||||
);
|
||||
await new Promise((res, rej) => {
|
||||
fileEntry.createWriter((w: any) => {
|
||||
w.onwrite = () => res(null);
|
||||
w.onerror = rej;
|
||||
w.write(content);
|
||||
}, rej);
|
||||
});
|
||||
return fileEntry.toLocalURL ? fileEntry.toLocalURL() : fileEntry.fullPath;
|
||||
}
|
||||
|
||||
/** 调起系统分享,把备份文件另存到网盘/微信收藏等(失败静默,不影响已生成文件) */
|
||||
export function shareBackupFile(localUrl: string): Promise<void> {
|
||||
const p = plusAny();
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
p.share.sendWithSystem(
|
||||
{ type: "file", files: [localUrl] },
|
||||
() => resolve(),
|
||||
() => resolve(),
|
||||
);
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 列出 _doc/backups 下的备份文件 */
|
||||
export async function listBackupFiles(): Promise<BackupItem[]> {
|
||||
const dir = await getBackupDir();
|
||||
const reader = dir.createReader();
|
||||
const entries: any[] = await new Promise((res, rej) =>
|
||||
reader.readEntries(res, rej),
|
||||
);
|
||||
return entries
|
||||
.filter((e) => e.name && e.name.endsWith(".sec"))
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
localUrl: e.toLocalURL ? e.toLocalURL() : e.fullPath,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 解密备份文本为明文快照 */
|
||||
function decryptDump(content: string, password: string): any {
|
||||
let obj: any;
|
||||
try {
|
||||
obj = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error("备份文件已损坏");
|
||||
}
|
||||
if (!obj || !obj.salt || !obj.cipher) throw new Error("备份文件格式不正确");
|
||||
const key = deriveKey(password, obj.salt);
|
||||
const plain = decryptText(key, obj.cipher);
|
||||
if (!plain) throw new Error("密码错误或文件已损坏");
|
||||
const dump = JSON.parse(plain);
|
||||
if (!dump || !Array.isArray(dump.passwords))
|
||||
throw new Error("备份内容不完整");
|
||||
return dump;
|
||||
}
|
||||
|
||||
/** 读取并用密码解密备份文件(按文件名从备份目录直取,避免相对路径解析失败) */
|
||||
export async function readBackupFile(
|
||||
name: string,
|
||||
password: string,
|
||||
): Promise<any> {
|
||||
const p = plusAny();
|
||||
const dir = await getBackupDir();
|
||||
const fileEntry: any = await new Promise((res, rej) =>
|
||||
dir.getFile(name, {}, res, rej),
|
||||
);
|
||||
const content: string = await new Promise((res, rej) => {
|
||||
fileEntry.file((f: any) => {
|
||||
const r = new p.io.FileReader();
|
||||
r.onloadend = (e: any) => res(e.target.result as string);
|
||||
r.onerror = rej;
|
||||
r.readAsText(f);
|
||||
}, rej);
|
||||
});
|
||||
return decryptDump(content, password);
|
||||
}
|
||||
|
||||
/** 删除 _doc/backups 下全部 .sec 备份,返回删除数量 */
|
||||
export async function clearBackupFiles(): Promise<number> {
|
||||
const dir = await getBackupDir();
|
||||
const reader = dir.createReader();
|
||||
const entries: any[] = await new Promise((res, rej) =>
|
||||
reader.readEntries(res, rej),
|
||||
);
|
||||
const targets = entries.filter((e) => e.name && e.name.endsWith(".sec"));
|
||||
for (const e of targets) {
|
||||
await new Promise<void>((res) => {
|
||||
e.remove(
|
||||
() => res(),
|
||||
() => res(),
|
||||
);
|
||||
});
|
||||
}
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
/** 调起系统文件选择器,返回所选文件的 content URI(取消或失败返回 null) */
|
||||
export function pickSecFile(): Promise<string | null> {
|
||||
const p = plusAny();
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const main = p.android.runtimeMainActivity();
|
||||
const Intent = p.android.importClass("android.content.Intent");
|
||||
const intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.setType("*/*");
|
||||
intent.addCategory("android.intent.category.OPENABLE");
|
||||
const REQUEST = 90217;
|
||||
const prev = main.onActivityResult;
|
||||
main.onActivityResult = function (
|
||||
requestCode: number,
|
||||
resultCode: number,
|
||||
data: any,
|
||||
) {
|
||||
if (requestCode === REQUEST) {
|
||||
if (resultCode === -1 && data) {
|
||||
const uri = data.getData();
|
||||
resolve(uri ? uri.toString() : null);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof prev === "function") prev(requestCode, resultCode, data);
|
||||
};
|
||||
main.startActivityForResult(
|
||||
Intent.createChooser(intent, "选择备份文件"),
|
||||
REQUEST,
|
||||
);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取 content URI 文本并用密码解密(供“从文件选择 .sec”恢复) */
|
||||
export async function readBackupFromUri(
|
||||
uriStr: string,
|
||||
password: string,
|
||||
): Promise<any> {
|
||||
const p = plusAny();
|
||||
const content: string = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const main = p.android.runtimeMainActivity();
|
||||
const Uri = p.android.importClass("android.net.Uri");
|
||||
const uri = Uri.parse(uriStr);
|
||||
const resolver = main.getContentResolver();
|
||||
const is = resolver.openInputStream(uri);
|
||||
const BufferedReader = p.android.importClass("java.io.BufferedReader");
|
||||
const InputStreamReader = p.android.importClass(
|
||||
"java.io.InputStreamReader",
|
||||
);
|
||||
const reader = new BufferedReader(new InputStreamReader(is));
|
||||
let out = "";
|
||||
let line = reader.readLine();
|
||||
while (line != null) {
|
||||
out += line;
|
||||
line = reader.readLine();
|
||||
if (line != null) out += "\n";
|
||||
}
|
||||
reader.close();
|
||||
resolve(out);
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error("读取所选文件失败"));
|
||||
}
|
||||
});
|
||||
return decryptDump(content, password);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
/**
|
||||
* 覆盖 crypto-js 的随机源。
|
||||
* Nox/部分 Android WebView 环境下 native crypto 不可用,AES 加密生成随机 salt、
|
||||
* PBKDF2 等操作会抛 "Native crypto module could not be used to get secure random number"。
|
||||
* 这里优先用 native getRandomValues,不可用/抛错时回退 Math.random,保证加密流程不中断。
|
||||
*/
|
||||
(function patchCryptoJsRandom() {
|
||||
const lib: any = (CryptoJS as any).lib;
|
||||
if (!lib || !lib.WordArray) return;
|
||||
lib.WordArray.random = function (nbytes: number) {
|
||||
const words: number[] = [];
|
||||
const native = (globalThis as any).crypto;
|
||||
let bytes: number[] | null = null;
|
||||
if (native && typeof native.getRandomValues === "function") {
|
||||
try {
|
||||
const arr = new Uint8Array(nbytes);
|
||||
native.getRandomValues(arr);
|
||||
bytes = Array.from(arr);
|
||||
} catch {
|
||||
bytes = null;
|
||||
}
|
||||
}
|
||||
if (!bytes) {
|
||||
bytes = [];
|
||||
for (let i = 0; i < nbytes; i++)
|
||||
bytes.push(Math.floor(Math.random() * 256));
|
||||
}
|
||||
for (let i = 0; i < nbytes; i++) {
|
||||
if ((i & 3) === 0) words[i >>> 2] = 0;
|
||||
words[i >>> 2] |= bytes[i] << (24 - (i % 4) * 8);
|
||||
}
|
||||
return lib.WordArray.create(words, nbytes);
|
||||
};
|
||||
})();
|
||||
|
||||
/** 图案序列(如 "0-4-8")拼入时间戳后做 SHA256,作为登录校验哈希 */
|
||||
export function hashPattern(pattern: string, stamp: number): string {
|
||||
return CryptoJS.SHA256(`pwd-mgr::${pattern}::${stamp}`).toString();
|
||||
}
|
||||
|
||||
/** 用图案 + 随机盐 PBKDF2 派生 256 位加密密钥(hex 字符串) */
|
||||
export function deriveKey(pattern: string, salt: string): string {
|
||||
return CryptoJS.PBKDF2(pattern, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 10000,
|
||||
hasher: CryptoJS.algo.SHA256,
|
||||
}).toString();
|
||||
}
|
||||
|
||||
/** AES 加密(密钥派生自图案,密文落库,明文不出设备内存) */
|
||||
export function encryptText(key: string, plain: string): string {
|
||||
return CryptoJS.AES.encrypt(plain, key).toString();
|
||||
}
|
||||
|
||||
export function decryptText(key: string, cipher: string): string {
|
||||
try {
|
||||
const bytes = CryptoJS.AES.decrypt(cipher, key);
|
||||
return bytes.toString(CryptoJS.enc.Utf8);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 跨端随机源:优先 WebCrypto,抛错/不可用时兜底 Math.random */
|
||||
function randomInt(maxExclusive: number): number {
|
||||
const g = globalThis as any;
|
||||
if (g.crypto && typeof g.crypto.getRandomValues === "function") {
|
||||
try {
|
||||
const buf = new Uint32Array(1);
|
||||
g.crypto.getRandomValues(buf);
|
||||
return buf[0] % maxExclusive;
|
||||
} catch {
|
||||
// native crypto 不可用,落到 Math.random
|
||||
}
|
||||
}
|
||||
return Math.floor(Math.random() * maxExclusive);
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
length?: number;
|
||||
upper?: boolean;
|
||||
lower?: boolean;
|
||||
digits?: boolean;
|
||||
symbols?: boolean;
|
||||
}
|
||||
|
||||
const UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
const LOWER = "abcdefghijkmnopqrstuvwxyz";
|
||||
const DIGITS = "23456789";
|
||||
const SYMBOLS = "!@#$%^&*()-_=+[]{}";
|
||||
|
||||
/** 随机密码生成器(默认 12 位,剔除易混淆字符) */
|
||||
export function generatePassword(opts: GenerateOptions = {}): string {
|
||||
const length = opts.length ?? 12;
|
||||
let pool = "";
|
||||
const mustPick: string[] = [];
|
||||
if (opts.upper !== false) {
|
||||
pool += UPPER;
|
||||
mustPick.push(UPPER[randomInt(UPPER.length)]);
|
||||
}
|
||||
if (opts.lower !== false) {
|
||||
pool += LOWER;
|
||||
mustPick.push(LOWER[randomInt(LOWER.length)]);
|
||||
}
|
||||
if (opts.digits !== false) {
|
||||
pool += DIGITS;
|
||||
mustPick.push(DIGITS[randomInt(DIGITS.length)]);
|
||||
}
|
||||
if (opts.symbols === true) {
|
||||
pool += SYMBOLS;
|
||||
mustPick.push(SYMBOLS[randomInt(SYMBOLS.length)]);
|
||||
}
|
||||
if (!pool) {
|
||||
pool = LOWER;
|
||||
}
|
||||
const chars = mustPick.slice();
|
||||
while (chars.length < length) {
|
||||
chars.push(pool[randomInt(pool.length)]);
|
||||
}
|
||||
// 洗牌,避免必选字符固定在头部
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = randomInt(i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.slice(0, length).join("");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 图片工具:将 uni.chooseImage 得到的临时文件转为 Base64 dataURL,
|
||||
* 以便作为文本存入本地 SQLite(随记录持久化在设备数据库中)。
|
||||
*/
|
||||
|
||||
/** H5:通过 FileReader 读取 File 对象为 dataURL */
|
||||
function h5ToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** App:通过 plus.io 读取本地文件为 dataURL */
|
||||
function appToBase64(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.io.resolveLocalFileSystemURL(
|
||||
path,
|
||||
(entry: any) => {
|
||||
entry.file((file: any) => {
|
||||
const reader = new plus.io.FileReader();
|
||||
reader.onloadend = (evt: any) => resolve(String(evt.target.result));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
(e: any) => reject(new Error("解析图片路径失败: " + JSON.stringify(e))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 选择图片(相册或拍照),返回首个图片的 Base64 dataURL;用户取消返回 null */
|
||||
export async function pickImageAsBase64(
|
||||
sourceType: ("album" | "camera")[] = ["album", "camera"],
|
||||
): Promise<string | null> {
|
||||
const chosen: any = await new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ["compressed"],
|
||||
sourceType,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (!chosen) return null;
|
||||
const path: string = chosen.tempFilePaths?.[0];
|
||||
if (!path) return null;
|
||||
|
||||
// 压缩降低体积(App/H5 均支持),失败则用原图
|
||||
let finalPath = path;
|
||||
const compressed: any = await new Promise((resolve) => {
|
||||
uni.compressImage({
|
||||
src: path,
|
||||
quality: 70,
|
||||
success: resolve,
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
if (compressed?.tempFilePath) finalPath = compressed.tempFilePath;
|
||||
|
||||
// #ifdef H5
|
||||
const file: File | undefined = chosen.tempFiles?.[0];
|
||||
if (file) return h5ToBase64(file);
|
||||
// 若压缩后仅得路径,退回按路径读取
|
||||
return fetchAsBase64(finalPath);
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
return appToBase64(finalPath);
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** H5 兜底:按 blob/网络地址 fetch 为 dataURL */
|
||||
function fetchAsBase64(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, true);
|
||||
xhr.responseType = "blob";
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 0 || xhr.status === 200) {
|
||||
h5ToBase64(xhr.response as File)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
} else {
|
||||
reject(new Error("加载图片失败"));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("加载图片失败"));
|
||||
xhr.send();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"lib": ["esnext", "dom"],
|
||||
"types": ["@dcloudio/types"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import uni from "@dcloudio/vite-plugin-uni";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
});
|
||||
Reference in new issue
Block a user