Files
Docker/uniapp/security/src/pages/index/index.vue
T
2026-10-30 11:00:36 +08:00

1373 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<!-- 顶部标题栏 -->
<view class="header">
<view class="logo">
<view class="ico ico-logo"></view>
</view>
<text class="header-title">安全小秘书</text>
<view class="spacer"></view>
<view class="gear-btn" @click="menuVisible = !menuVisible">
<view class="ico ico-gear"></view>
</view>
</view>
<!-- 设置下拉菜单 -->
<view v-if="menuVisible" class="menu-mask" @click="menuVisible = false">
<view class="menu-card" @click.stop>
<view class="menu-item" @click="menuAction('backup')">
<text>备用密码</text>
<text v-if="!hasBackupSet" class="menu-badge">未设置</text>
</view>
<view class="menu-item" @click="menuAction('backuplogin')">
<text>重新验证</text>
</view>
<!-- #ifdef APP-PLUS -->
<view class="menu-item" @click="menuAction('export')">
<text>备份(导出)</text>
</view>
<view class="menu-item" @click="menuAction('import')">
<text>恢复(导入)</text>
</view>
<view class="menu-item" @click="menuAction('clearbaks')">
<text>清空备份文件</text>
</view>
<!-- #endif -->
<view class="menu-item" @click="menuAction('clear')">
<text>清空全部数据</text>
</view>
<view class="menu-item menu-danger" @click="menuAction('logout')">
<text>退出登录</text>
</view>
</view>
</view>
<!-- 搜索 + 分类筛选 -->
<view class="toolbar">
<view class="search">
<view class="ico ico-search"></view>
<input v-model="keyword" class="search-input" placeholder="搜索账号/名称" placeholder-class="ph" confirm-type="search" />
</view>
<view class="filter" @click="openFilterPicker">
<view class="filter-inner">
<text>{{ filterOptions[filterIndex] }}</text>
<text class="caret">▾</text>
</view>
</view>
</view>
<view v-if="!hasBackupSet" class="backup-warn" @click="openBackup">
<text>尚未设置备用密码,点击设置(忘记图案时可用密码解锁)</text>
</view>
<!-- 列表 -->
<scroll-view scroll-y class="list">
<view v-if="!filtered.length" class="empty">
<text class="empty-text">{{ items.length ? '没有匹配的记录' : '暂无密码记录,点击右下角 + 添加' }}</text>
</view>
<view v-for="item in filtered" :key="item.id" class="card">
<view class="card-top">
<image v-if="item.image" class="card-thumb" :src="item.image" mode="aspectFill" @click="openPreview(item.image)" />
<text class="card-name">{{ item.title }}</text>
<text v-if="item.category" class="tag" :class="tagClass(item.category)">{{ item.category }}</text>
<view class="spacer"></view>
<view class="icon-btn" @click="openEdit(item.id)">
<view class="ico ico-edit"></view>
</view>
<view class="icon-btn" @click="removeItem(item)">
<view class="ico ico-del"></view>
</view>
</view>
<view class="card-account">账号:{{ item.account || '未填写' }}</view>
<view class="card-pwd">
<text class="pwd-text">{{ revealed.has(item.id) ? item.password : '••••••••' }}</text>
<view class="icon-btn" @click="toggleReveal(item.id)">
<view class="ico" :class="revealed.has(item.id) ? 'ico-eye-off' : 'ico-eye'"></view>
</view>
<view class="icon-btn" @click="copyPassword(item)">
<view class="ico ico-copy"></view>
</view>
</view>
</view>
</scroll-view>
<!-- 悬浮添加按钮 -->
<view class="fab" @click="openEdit()">
<view class="ico ico-plus"></view>
</view>
<!-- 底部弹层编辑器 -->
<view v-if="sheetVisible" class="mask" @click="closeSheet">
<view class="sheet" @click.stop>
<view class="handle"></view>
<view class="sheet-head">
<text class="sheet-title">{{ editingId ? '编辑密码' : '添加密码' }}</text>
<text class="sheet-close" @click="closeSheet">✕</text>
</view>
<view class="field">
<text class="field-label">名称 / 网站</text>
<input v-model="form.title" class="field-input" placeholder="如:阿里云 / 微信" placeholder-class="ph" />
</view>
<view class="field">
<text class="field-label">账号</text>
<input v-model="form.account" class="field-input" placeholder="用户名 / 邮箱 / 手机号" placeholder-class="ph" />
</view>
<view class="field">
<text class="field-label">密码</text>
<view class="field-row">
<input v-model="form.password" class="field-input" :password="!sheetShowPwd" placeholder="输入或生成" placeholder-class="ph" />
<text class="gen-link" @click="onGenerate">生成</text>
<text class="eye" @click="sheetShowPwd = !sheetShowPwd">{{ sheetShowPwd ? '🙈' : '👁' }}</text>
</view>
</view>
<view class="field">
<text class="field-label">分类</text>
<view class="field-picker" @click="openCategoryPicker">
<text>{{ form.category || '未分类' }}</text>
<text class="caret">⌄</text>
</view>
</view>
<view class="field">
<text class="field-label">图片(存入数据库)</text>
<view class="img-area">
<view v-if="form.image" class="img-box">
<image class="img-thumb" :src="form.image" mode="aspectFill" @click="openPreview(form.image)" />
<text class="img-del" @click="onRemoveImage">✕</text>
</view>
<view v-else class="img-add" @click="onPickImage">
<text class="img-add-icon">+</text>
<text class="img-add-text">{{ picking ? '处理中…' : '选择图片' }}</text>
</view>
</view>
</view>
<view class="sheet-actions">
<button class="btn-cancel" @click="closeSheet">取消</button>
<button class="btn-save" :loading="saving" @click="onSave">保存</button>
</view>
</view>
</view>
<!-- 自定义选项选择器(避免原生 picker 的英文按钮) -->
<view v-if="pickerVisible" class="opt-mask" @click="pickerVisible = false">
<view class="opt-panel" @click.stop>
<text class="opt-title">{{ pickerMode === 'filter' ? '按分类筛选' : '选择分类' }}</text>
<scroll-view scroll-y class="opt-list">
<view
v-for="(opt, i) in pickerOptions"
:key="opt"
class="opt-item"
:class="{ 'opt-active': i === pickerSelected }"
@click="pickOption(i)"
>
<text>{{ opt }}</text>
<text v-if="i === pickerSelected" class="opt-check">✓</text>
</view>
</scroll-view>
<view class="opt-cancel" @click="pickerVisible = false">取消</view>
</view>
</view>
<!-- 备用密码设置 -->
<view v-if="backupVisible" class="opt-mask" @click="backupVisible = false">
<view class="opt-panel" @click.stop>
<text class="opt-title">{{ hasBackupSet ? '修改备用密码' : '设置备用密码' }}</text>
<view class="backup-field">
<input v-model="bk1" class="field-input" :password="true" placeholder="备用密码(至少 4 位)" placeholder-class="ph" />
</view>
<view class="backup-field">
<input v-model="bk2" class="field-input" :password="true" placeholder="再次输入以确认" placeholder-class="ph" />
</view>
<text class="backup-tip">忘记解锁图案时,可用此备用密码登录,请务必牢记。</text>
<view class="sheet-actions">
<button class="btn-cancel" @click="backupVisible = false">取消</button>
<button class="btn-save" @click="onSaveBackup">保存</button>
</view>
</view>
</view>
<!-- 通用密码输入(备份/备份登录) -->
<view v-if="pwdPromptVisible" class="opt-mask" @click="cancelPwdPrompt">
<view class="opt-panel" @click.stop>
<text class="opt-title">{{ pwdPromptTitle }}</text>
<view class="backup-field">
<input v-model="pwdInput" class="field-input" :password="true" placeholder="请输入密码" placeholder-class="ph" confirm-type="done" @confirm="submitPwdPrompt" />
</view>
<view class="sheet-actions">
<button class="btn-cancel" @click="cancelPwdPrompt">取消</button>
<button class="btn-save" @click="submitPwdPrompt">确定</button>
</view>
</view>
</view>
<!-- #ifdef APP-PLUS -->
<!-- 备份文件列表 -->
<view v-if="importListVisible" class="opt-mask" @click="importListVisible = false">
<view class="opt-panel" @click.stop>
<text class="opt-title">选择要恢复的备份</text>
<view class="pick-file" @click="onPickFromFiles"><text>+ 从文件选择 .sec</text></view>
<scroll-view scroll-y class="opt-list">
<view v-for="b in importItems" :key="b.name" class="opt-item file-row">
<text class="file-name" @click="onPickBackup(b)">{{ b.name }}</text>
<view class="file-acts">
<view class="ico ico-path" @click.stop="onShowPath(b)"></view>
</view>
</view>
</scroll-view>
<view class="opt-cancel" @click="importListVisible = false">取消</view>
</view>
</view>
<!-- #endif -->
<!-- 图片全屏预览 -->
<view v-if="previewImage" class="preview" @click="previewImage = ''">
<image class="preview-img" :src="previewImage" mode="aspectFit" />
<text class="preview-close">点击任意处关闭</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { listPasswords, getPassword, savePassword, deletePassword, getSessionKey, isUnlocked, clearSession, resetApp, hasBackup, setBackupPassword, getExportPlainDump, restoreFromPlainDump, unlockVault, loginWithPassword, getLoginMode, type PwdItem, type PlainDump } from '@/api/auth';
import { generatePassword } from '@/utils/crypto';
import { pickImageAsBase64 } from '@/utils/image';
// #ifdef APP-PLUS
import { exportBackupFile, listBackupFiles, readBackupFile, clearBackupFiles, pickSecFile, readBackupFromUri } from '@/utils/backup';
// #endif
const ADVANCED = '高级';
const baseCategories = ['工作', '社交', '金融', '其他'];
const loginMode = ref('');
// 仅备用密码登录时可见“高级”分类(抵御逼问),且排在“其他”之前
const visibleCategories = computed(() => {
if (loginMode.value !== 'password') return baseCategories;
const arr = [...baseCategories];
const idx = arr.indexOf('其他');
arr.splice(idx < 0 ? arr.length : idx, 0, ADVANCED);
return arr;
});
const categoryOptions = computed(() => ['未分类', ...visibleCategories.value]);
const filterOptions = computed(() => ['全部', ...visibleCategories.value]);
const items = ref<PwdItem[]>([]);
const keyword = ref('');
const filterIndex = ref(0);
const revealed = ref<Set<number>>(new Set());
const sheetVisible = ref(false);
const editingId = ref(0);
const saving = ref(false);
const sheetShowPwd = ref(false);
const categoryIndex = ref(0);
const picking = ref(false);
const previewImage = ref('');
const pickerVisible = ref(false);
const pickerMode = ref<'filter' | 'category'>('filter');
const pickerOptions = ref<string[]>([]);
const pickerSelected = ref(0);
const backupVisible = ref(false);
const menuVisible = ref(false);
const hasBackupSet = ref(false);
const bk1 = ref('');
const bk2 = ref('');
// 通用密码输入(备份/备份登录)
const pwdPromptVisible = ref(false);
const pwdPromptTitle = ref('');
const pwdInput = ref('');
let pwdResolve: ((v: string | null) => void) | null = null;
// #ifdef APP-PLUS
const importListVisible = ref(false);
const importItems = ref<{ name: string; localUrl: string }[]>([]);
// #endif
const form = reactive({ title: '', account: '', password: '', note: '', category: '', image: '' });
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase();
const cat = filterIndex.value === 0 ? '' : filterOptions.value[filterIndex.value];
return items.value.filter((it) => {
if (loginMode.value !== 'password' && it.category === ADVANCED) return false;
if (cat && it.category !== cat) return false;
if (kw && !(it.title.toLowerCase().includes(kw) || it.account.toLowerCase().includes(kw))) return false;
return true;
});
});
onShow(async () => {
if (!isUnlocked() && !getSessionKey()) {
uni.reLaunch({ url: '/pages/login/login' });
return;
}
loginMode.value = getLoginMode();
if (filterIndex.value >= filterOptions.value.length) filterIndex.value = 0;
await refresh();
hasBackupSet.value = await hasBackup();
});
async function refresh() {
items.value = await listPasswords();
}
/** 确保会话持有保险库密钥(备份密码派生);无则提示输入。仅取密钥,不升级为“密码登录”(不暴露“高级”) */
async function ensureVault(): Promise<boolean> {
if (getSessionKey()) return true;
if (!hasBackupSet.value) {
uni.showToast({ title: '尚未设置备用密码', icon: 'none' });
return false;
}
const pwd = await askPassword('请输入备份密码');
if (pwd === null) return false;
if (await unlockVault(pwd, false)) {
await refresh();
return true;
}
uni.showToast({ title: '密码错误', icon: 'none' });
return false;
}
function tagClass(cat: string): string {
return { 工作: 'tag-work', 社交: 'tag-social', 金融: 'tag-finance', 其他: 'tag-other' }[cat] || 'tag-other';
}
function openFilterPicker() {
pickerMode.value = 'filter';
pickerOptions.value = filterOptions.value;
pickerSelected.value = filterIndex.value;
pickerVisible.value = true;
}
function openCategoryPicker() {
pickerMode.value = 'category';
pickerOptions.value = categoryOptions.value;
pickerSelected.value = categoryIndex.value;
pickerVisible.value = true;
}
function pickOption(i: number) {
if (pickerMode.value === 'filter') {
filterIndex.value = i;
} else {
categoryIndex.value = i;
form.category = i === 0 ? '' : categoryOptions.value[i];
}
pickerVisible.value = false;
}
function onLogout() {
uni.showModal({
title: '退出登录',
content: '退出后需重新绘制解锁图案才能查看,确定退出?',
confirmText: '退出',
cancelText: '取消',
success: (res) => {
if (!res.confirm) return;
clearSession();
uni.reLaunch({ url: '/pages/login/login' });
},
});
}
function onClearAll() {
uni.showModal({
title: '清除全部数据',
content: '将删除解锁图案和所有密码记录,且不可恢复。确定继续?',
confirmText: '确定清除',
confirmColor: '#e53935',
cancelText: '取消',
success: async (res) => {
if (!res.confirm) return;
await resetApp();
uni.reLaunch({ url: '/pages/login/login' });
},
});
}
async function openBackup() {
hasBackupSet.value = await hasBackup();
// 修改备份密码需当前保险库密钥解密旧密文
if (hasBackupSet.value && !(await ensureVault())) return;
bk1.value = '';
bk2.value = '';
backupVisible.value = true;
}
function menuAction(a: string) {
menuVisible.value = false;
if (a === 'backup') openBackup();
else if (a === 'backuplogin') onBackupLogin();
else if (a === 'clear') onClearAll();
else if (a === 'logout') onLogout();
// #ifdef APP-PLUS
else if (a === 'export') onExport();
else if (a === 'import') openImportList();
else if (a === 'clearbaks') onClearBackupFiles();
// #endif
}
async function onSaveBackup() {
if (bk1.value.length < 4) {
uni.showToast({ title: '备用密码至少 4 位', icon: 'none' });
return;
}
if (bk1.value !== bk2.value) {
uni.showToast({ title: '两次输入不一致', icon: 'none' });
return;
}
try {
await setBackupPassword(bk1.value);
hasBackupSet.value = true;
backupVisible.value = false;
uni.showToast({ title: '备用密码已保存', icon: 'none' });
} catch (e: any) {
uni.showToast({ title: e?.message || '保存失败', icon: 'none' });
}
}
// 通用密码输入(备份/备份登录,全平台可用)
function askPassword(title: string): Promise<string | null> {
pwdPromptTitle.value = title;
pwdInput.value = '';
pwdPromptVisible.value = true;
return new Promise((res) => {
pwdResolve = res;
});
}
function submitPwdPrompt() {
const v = pwdInput.value;
pwdPromptVisible.value = false;
const r = pwdResolve;
pwdResolve = null;
if (r) r(v);
}
function cancelPwdPrompt() {
pwdPromptVisible.value = false;
const r = pwdResolve;
pwdResolve = null;
if (r) r(null);
}
async function onBackupLogin() {
if (loginMode.value === 'password') {
uni.showToast({ title: '已通过备用密码登录', icon: 'none' });
return;
}
if (!hasBackupSet.value) {
uni.showToast({ title: '尚未设置备用密码', icon: 'none' });
return;
}
const pwd = await askPassword('请输入备用密码');
if (pwd === null) return;
if (await loginWithPassword(pwd)) {
loginMode.value = 'password';
filterIndex.value = 0;
await refresh();
uni.showToast({ title: '验证成功', icon: 'none' });
} else {
uni.showToast({ title: '密码错误', icon: 'none' });
}
}
// #ifdef APP-PLUS
async function onExport() {
if (!(await ensureVault())) return;
const pwd = await askPassword('设置备份文件密码(独立于备份密码)');
if (pwd === null) return;
if (pwd.length < 4) {
uni.showToast({ title: '密码至少 4 位', icon: 'none' });
return;
}
const pwd2 = await askPassword('再次输入以确认备份文件密码');
if (pwd2 === null) return;
if (pwd !== pwd2) {
uni.showToast({ title: '两次输入不一致', icon: 'none' });
return;
}
try {
const dump = await getExportPlainDump();
await exportBackupFile(dump, pwd);
uni.showToast({ title: '备份已生成,可在导入列表查看', icon: 'none' });
} catch (e: any) {
uni.showToast({ title: e?.message || '导出失败', icon: 'none' });
}
}
async function openImportList() {
try {
importItems.value = await listBackupFiles();
} catch {
importItems.value = [];
}
importListVisible.value = true;
}
async function onPickBackup(item: { name: string; localUrl: string }) {
importListVisible.value = false;
const pwd = await askPassword(`输入「${item.name}」的备份文件密码`);
if (pwd === null) return;
try {
const dump = await readBackupFile(item.name, pwd);
confirmRestore(dump, pwd);
} catch (e: any) {
uni.showToast({ title: e?.message || '恢复失败', icon: 'none' });
}
}
async function onPickFromFiles() {
const picked = await pickSecFile();
if (!picked.uri) {
uni.showToast({ title: picked.error || '未选择文件', icon: 'none' });
return;
}
const pwd = await askPassword('输入所选备份文件的密码');
if (pwd === null) return;
try {
const dump = await readBackupFromUri(picked.uri, pwd);
confirmRestore(dump, pwd);
} catch (e: any) {
uni.showToast({ title: e?.message || '恢复失败', icon: 'none' });
}
}
function confirmRestore(dump: PlainDump, filePwd: string) {
uni.showModal({
title: '确认恢复',
content: '将先用当前备份密码自动备份现有数据(auto-*.sec),再恢复此文件,并需设置新的备份密码。确定继续?',
confirmText: '恢复',
confirmColor: '#e53935',
success: (res) => {
if (res.confirm) doRestore(dump, filePwd);
},
});
}
async function doRestore(dump: PlainDump, filePwd: string) {
try {
// 1. 自动备份当前数据(需当前备份密码解锁以导出明文)
if (!(await ensureVault())) return;
const cur = await getExportPlainDump();
await exportBackupFile(cur, filePwd, 'auto');
// 2. 设置新备份密码(恢复后使用)
const np1 = await askPassword('设置新的备份密码(恢复后使用)');
if (np1 === null) return;
if (np1.length < 4) {
uni.showToast({ title: '备份密码至少 4 位', icon: 'none' });
return;
}
const np2 = await askPassword('再次输入以确认新备份密码');
if (np2 === null) return;
if (np1 !== np2) {
uni.showToast({ title: '两次输入不一致', icon: 'none' });
return;
}
// 3. 用新备份密码加密入库,退出重登
await restoreFromPlainDump(dump, np1);
clearSession();
uni.showToast({ title: '恢复成功,请重新登录', icon: 'none' });
uni.reLaunch({ url: '/pages/login/login' });
} catch (err: any) {
uni.showToast({ title: err?.message || '恢复失败', icon: 'none' });
}
}
function onShowPath(item: { name: string; localUrl: string }) {
uni.showModal({ title: '文件路径', content: item.localUrl, showCancel: false, confirmText: '知道了' });
}
function onClearBackupFiles() {
uni.showModal({
title: '清空备份文件',
content: '将删除本机 _doc/backups 下所有 .sec 备份(已转移到外部的副本不受影响)。确定继续?',
confirmText: '清空',
confirmColor: '#e53935',
success: async (res) => {
if (!res.confirm) return;
try {
const n = await clearBackupFiles();
uni.showToast({ title: `已清空 ${n} 个备份`, icon: 'none' });
importListVisible.value = false;
} catch (e: any) {
uni.showToast({ title: e?.message || '清空失败', icon: 'none' });
}
},
});
}
// #endif
async function toggleReveal(id: number) {
if (revealed.value.has(id)) {
const s = new Set(revealed.value);
s.delete(id);
revealed.value = s;
return;
}
if (!(await ensureVault())) return;
const s = new Set(revealed.value);
s.add(id);
revealed.value = s;
}
async function copyPassword(item: PwdItem) {
if (!(await ensureVault())) return;
const fresh = items.value.find((x) => x.id === item.id) || item;
uni.setClipboardData({
data: fresh.password,
success: () => uni.showToast({ title: '密码已复制', icon: 'none' }),
});
}
function resetForm() {
form.title = '';
form.account = '';
form.password = '';
form.note = '';
form.category = '';
form.image = '';
categoryIndex.value = 0;
sheetShowPwd.value = false;
}
async function openEdit(id?: number) {
if (!(await ensureVault())) return;
resetForm();
editingId.value = id || 0;
if (id) {
const item = await getPassword(id);
if (item) {
form.title = item.title;
form.account = item.account;
form.password = item.password;
form.note = item.note;
form.category = item.category;
form.image = item.image;
categoryIndex.value = Math.max(0, categoryOptions.value.indexOf(item.category || '未分类'));
}
}
sheetVisible.value = true;
}
function closeSheet() {
sheetVisible.value = false;
}
function onGenerate() {
form.password = generatePassword({ length: 14, symbols: true });
sheetShowPwd.value = true;
uni.showToast({ title: '已生成随机密码', icon: 'none' });
}
async function onPickImage() {
if (picking.value) return;
picking.value = true;
try {
const base64 = await pickImageAsBase64();
if (base64) {
form.image = base64;
uni.showToast({ title: '图片已就绪,保存后写入数据库', icon: 'none' });
}
} catch (e: any) {
uni.showToast({ title: e?.message || '选择图片失败', icon: 'none' });
} finally {
picking.value = false;
}
}
function onRemoveImage() {
form.image = '';
}
function openPreview(src: string) {
previewImage.value = src;
}
async function removeItem(item: PwdItem) {
uni.showModal({
title: '删除记录',
content: `确定删除「${item.title}」吗?此操作不可恢复。`,
confirmText: '删除',
confirmColor: '#e53935',
success: async (res) => {
if (!res.confirm) return;
await deletePassword(item.id);
await refresh();
uni.showToast({ title: '已删除', icon: 'none' });
},
});
}
async function onSave() {
if (!form.title.trim()) {
uni.showToast({ title: '请填写名称', icon: 'none' });
return;
}
if (!form.password) {
uni.showToast({ title: '请填写密码', icon: 'none' });
return;
}
saving.value = true;
try {
await savePassword({
id: editingId.value || undefined,
title: form.title.trim(),
account: form.account.trim(),
password: form.password,
note: form.note.trim(),
category: form.category,
image: form.image,
});
await refresh();
sheetVisible.value = false;
uni.showToast({ title: '已加密保存到本地数据库', icon: 'none' });
} catch (e: any) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' });
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f2f5fa;
display: flex;
flex-direction: column;
padding: 0 14px;
box-sizing: border-box;
}
.ico {
display: inline-block;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.ico-logo {
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M12.65 10A5.99 5.99 0 0 0 7 6a6 6 0 1 0 .01 12 5.99 5.99 0 0 0 5.65-4H17v4h4v-4h1v-4H12.65zM7 14a2 2 0 1 1 0-4 2 2 0 0 1 0 4z'/%3E%3C/svg%3E");
}
.ico-search {
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%239aa3b2'%3E%3Cpath d='M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z'/%3E%3C/svg%3E");
}
.ico-edit {
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%231a75d0'%3E%3Cpath d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E");
}
.ico-del {
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23e53935'%3E%3Cpath d='M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z'/%3E%3C/svg%3E");
}
.ico-eye {
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zm0 12.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z'/%3E%3C/svg%3E");
}
.ico-eye-off {
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M12 7a5 5 0 0 1 5 5 4.97 4.97 0 0 1-1.32 3.36l-7.04-7.04A4.97 4.97 0 0 1 12 7zM2.71 4.22 1.07 2.56 12 12l1.94 1.94 2.5 2.5A9.9 9.9 0 0 1 12 17c-5 0-9.27-3.11-11-7.44A11.5 11.5 0 0 1 5.6 6.27L2.71 4.22z'/%3E%3C/svg%3E");
}
.ico-copy {
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M16 1H4a2 2 0 0 0-2 2v14h2V4h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z'/%3E%3C/svg%3E");
}
.ico-plus {
width: 26px;
height: 26px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z'/%3E%3C/svg%3E");
}
.header {
display: flex;
align-items: center;
padding: 52px 0 14px;
}
.logo {
width: 34px;
height: 34px;
border-radius: 9px;
background: linear-gradient(135deg, #2b6fe0, #1a75d0);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.header-title {
font-size: 19px;
font-weight: 700;
color: #1f2733;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.search {
flex: 1;
display: flex;
align-items: center;
height: 40px;
background: #eef1f6;
border-radius: 10px;
padding: 0 12px;
}
.search-input {
flex: 1;
margin-left: 8px;
font-size: 14px;
color: #2b3240;
}
.ph {
color: #9aa3b2;
}
.filter {
flex-shrink: 0;
}
.filter-inner {
display: flex;
align-items: center;
height: 40px;
padding: 0 12px;
background: #ffffff;
border: 1px solid #e2e7ef;
border-radius: 10px;
font-size: 14px;
color: #2b3240;
}
.caret {
margin-left: 4px;
color: #9aa3b2;
font-size: 12px;
}
.logout-btn {
font-size: 14px;
color: #1a75d0;
padding: 4px 10px;
}
.ico-gear {
width: 22px;
height: 22px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z'/%3E%3C/svg%3E");
}
.gear-btn {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.menu-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.12);
z-index: 40;
}
.menu-card {
position: absolute;
top: 96px;
right: 14px;
width: 168px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 6px 20px rgba(31, 45, 61, 0.18);
padding: 6px 0;
overflow: hidden;
}
.menu-item {
height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
font-size: 14px;
color: #2b3240;
}
.menu-badge {
font-size: 11px;
color: #b26a00;
background: #fff3e0;
padding: 2px 6px;
border-radius: 6px;
}
.menu-danger {
color: #e53935;
}
.backup-warn {
background: #fff3e0;
color: #b26a00;
font-size: 12px;
padding: 8px 12px;
border-radius: 8px;
margin-bottom: 12px;
}
.backup-field {
padding: 0 20px;
margin-bottom: 12px;
}
.backup-tip {
display: block;
font-size: 12px;
color: #9aa3b2;
text-align: center;
padding: 0 20px;
}
.opt-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
z-index: 30;
}
.opt-panel {
width: 100%;
background: #ffffff;
border-radius: 18px 18px 0 0;
padding: 16px 0 20px;
box-sizing: border-box;
}
.opt-title {
display: block;
text-align: center;
font-size: 16px;
font-weight: 600;
color: #1f2733;
margin-bottom: 8px;
}
.opt-list {
max-height: 320px;
}
.opt-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 24px;
font-size: 15px;
color: #2b3240;
}
.opt-active {
color: #1a75d0;
font-weight: 600;
}
.opt-check {
color: #1a75d0;
font-size: 16px;
}
.opt-cancel {
margin-top: 6px;
height: 50px;
line-height: 50px;
text-align: center;
font-size: 15px;
color: #5a6472;
border-top: 1px solid #eef1f6;
}
.pick-file {
height: 46px;
line-height: 46px;
text-align: center;
color: #1a75d0;
font-size: 15px;
border-bottom: 1px solid #eef1f6;
}
.file-row {
padding: 0 16px 0 24px;
}
.file-name {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-acts {
display: flex;
align-items: center;
}
.ico-path {
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z'/%3E%3C/svg%3E");
}
.list {
flex: 1;
}
.empty {
padding-top: 100px;
text-align: center;
}
.empty-text {
font-size: 14px;
color: #9aa3b2;
}
.card {
background: #ffffff;
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 10px rgba(31, 45, 61, 0.05);
}
.card-top {
display: flex;
align-items: center;
}
.card-name {
font-size: 16px;
font-weight: 600;
color: #1f2733;
}
.card-thumb {
width: 34px;
height: 34px;
border-radius: 8px;
margin-right: 10px;
background: #eef1f6;
flex-shrink: 0;
}
.img-area {
display: flex;
}
.img-add {
width: 96px;
height: 96px;
border: 1px dashed #c3ccda;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f7f9fc;
}
.img-add-icon {
font-size: 26px;
color: #9aa3b2;
}
.img-add-text {
font-size: 12px;
color: #9aa3b2;
margin-top: 4px;
}
.img-box {
position: relative;
width: 96px;
height: 96px;
}
.img-thumb {
width: 96px;
height: 96px;
border-radius: 10px;
background: #eef1f6;
}
.img-del {
position: absolute;
top: -8px;
right: -8px;
width: 22px;
height: 22px;
line-height: 22px;
text-align: center;
border-radius: 11px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12px;
}
.preview {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 20;
}
.preview-img {
width: 100%;
height: 80vh;
}
.preview-close {
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
margin-top: 12px;
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
margin-left: 8px;
}
.tag-work {
color: #1a75d0;
background: #e8f1ff;
}
.tag-social {
color: #16a34a;
background: #e6f7ec;
}
.tag-finance {
color: #f57c00;
background: #fff3e0;
}
.tag-other {
color: #5a6472;
background: #eef1f6;
}
.spacer {
flex: 1;
}
.icon-btn {
padding: 4px 6px;
margin-left: 2px;
}
.card-account {
font-size: 13px;
color: #5a6472;
margin: 10px 0;
}
.card-pwd {
display: flex;
align-items: center;
}
.pwd-text {
flex: 1;
font-size: 15px;
letter-spacing: 2px;
color: #2b3240;
font-family: monospace;
}
.fab {
position: fixed;
right: 22px;
bottom: 34px;
width: 54px;
height: 54px;
border-radius: 27px;
background: linear-gradient(135deg, #2b6fe0, #1a75d0);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 16px rgba(26, 117, 208, 0.45);
z-index: 5;
}
.mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
z-index: 10;
}
.sheet {
width: 100%;
background: #ffffff;
border-radius: 18px 18px 0 0;
padding: 8px 20px 28px;
box-sizing: border-box;
}
.handle {
width: 40px;
height: 4px;
border-radius: 2px;
background: #d5dbe4;
margin: 4px auto 12px;
}
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sheet-title {
font-size: 18px;
font-weight: 700;
color: #1f2733;
}
.sheet-close {
font-size: 18px;
color: #9aa3b2;
padding: 4px;
}
.field {
margin-bottom: 14px;
}
.field-label {
font-size: 13px;
color: #5a6472;
margin-bottom: 6px;
display: block;
}
.field-input {
flex: 1;
height: 44px;
border: 1px solid #e2e7ef;
border-radius: 10px;
padding: 0 12px;
font-size: 14px;
color: #2b3240;
box-sizing: border-box;
background: #fff;
}
.field-row {
display: flex;
align-items: center;
gap: 10px;
}
.gen-link {
font-size: 14px;
color: #1a75d0;
flex-shrink: 0;
}
.eye {
font-size: 18px;
flex-shrink: 0;
}
.field-picker {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
border: 1px solid #e2e7ef;
border-radius: 10px;
padding: 0 12px;
font-size: 14px;
color: #2b3240;
}
.sheet-actions {
display: flex;
gap: 14px;
margin-top: 22px;
}
.btn-cancel {
flex: 1;
height: 46px;
line-height: 46px;
border-radius: 10px;
background: #f2f5fa;
color: #5a6472;
font-size: 15px;
border: none;
}
.btn-save {
flex: 1.4;
height: 46px;
line-height: 46px;
border-radius: 10px;
background: #2b6fe0;
color: #ffffff;
font-size: 15px;
border: none;
}
</style>