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