1373 lines
37 KiB
Vue
1373 lines
37 KiB
Vue
<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>
|