This commit is contained in:
owenchen committed 2026-10-29 16:44:09 +08:00
1 parent e9af73007e
commit 2909e9b0af
28 files changed
+17789

No files matched your search

+21
View File
@@ -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?
+9
View File
@@ -0,0 +1,9 @@
{
"version" : "1.0",
"configurations" : [
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}
+20
View File
@@ -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>
+13835
View File
File diff suppressed because it is too large. Load diff
+85
View File
@@ -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"
}
}
+239
View File
@@ -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;
}
}
+10
View File
@@ -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 {
}
}
+13
View File
@@ -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>
+429
View File
@@ -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>
+475
View File
@@ -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);
},
};
+8
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
return {
app,
};
}
+74
View File
@@ -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"
}
+24
View File
@@ -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
+356
View File
@@ -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>
+6
View File
@@ -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

+76
View File
@@ -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;
+242
View File
@@ -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);
}
+128
View File
@@ -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("");
}
+98
View File
@@ -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);
}
});
}
+13
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [uni()],
});