relay-sqlite-example (#1403)

* relay-sqlite-example

Manager+Adapter

* Update README.md
This commit is contained in:
ponzS 2025-06-15 16:39:24 +08:00 committed by GitHub
parent 4b43fa7f2c
commit 938697dee9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 3180 additions and 0 deletions

View File

@ -0,0 +1,26 @@
# gun-relay-sqlite
Manager+Adapter Stable version
Support iOS & Android
# Quickly build a relay manager and Gun storage adapter with sqlite persistence.
```base
yarn install
```
```base
yarn build
```
```base
npx cap sync
```
```base
npx cap open ios | android
```
# Preview
![relayios](https://github.com/user-attachments/assets/258050f2-328c-42f8-a8b0-0ab24efa9acf)

View File

@ -0,0 +1,43 @@
import { CapacitorConfig } from '@capacitor/cli';
import { CapacitorHttp } from '@capacitor/core';
import { KeyboardResize, KeyboardStyle } from '@capacitor/keyboard'
const config: CapacitorConfig = {
appId: 'com.gun.relay',
appName: 'Relay',
webDir: 'dist',
plugins: {
CapacitorHttp: {
enabled: true,
},
Keyboard: {
resize: KeyboardResize.None,
resizeOnFullScreen: true,
},
CapacitorSQLite: {
migrate: true,
iosDatabaseLocation: 'Library/CapacitorDatabase',
iosIsEncryption: true,
iosKeychainPrefix: 'gundb',
iosBiometric: {
biometricAuth: false,
biometricTitle : "Biometric login for capacitor sqlite"
},
androidIsEncryption: true,
androidBiometric: {
biometricAuth : false,
biometricTitle : "Biometric login for capacitor sqlite",
biometricSubTitle : "Log in using your biometric"
},
electronIsEncryption: true,
electronWindowsLocation: "C:\\ProgramData\\CapacitorDatabases",
electronMacLocation: "~/Databases/",
electronLinuxLocation: "Databases"
}
}
};
export default config;

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Gun-Relay-sqlite</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="shortcut icon" type="image/png" href="/favicon.png" />
<!-- add to homescreen for ios -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Gun-Relay-sqlite" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,204 @@
{
"name": "gun-relay-sqlite",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "npm run copy:sql:wasm && vite --host",
"build:web": "npm run copy:sql:wasm && npm run build",
"build:native": "npm run remove:sql:wasm && npm run build",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"ionic:serve:before": "npm run copy:sql:wasm",
"copy:sql:wasm": "copyfiles -u 3 node_modules/sql.js/dist/sql-wasm.wasm public/assets",
"remove:sql:wasm": "rimraf public/assets/sql-wasm.wasm",
"ios:start": "npm run remove:sql:wasm && npm run build:native && npx cap sync && npx cap copy && npx cap open ios",
"android:start": "npm run remove:sql:wasm && npm run build:native && npx cap sync && npx cap copy && npx cap open android",
"electron:install": "cd electron && npm install && cd ..",
"electron:prepare": "npm run remove:sql:wasm && npm run build && npx cap sync @capacitor-community/electron && npx cap copy @capacitor-community/electron",
"electron:start": "npm run electron:prepare && cd electron && npm run electron:start",
"clean:vite:cache": "vite clean",
"test:e2e": "cypress run",
"test:unit": "vitest",
"lint": "eslint"
},
"dependencies": {
"@angular/core": "^19.1.6",
"@aparajita/capacitor-biometric-auth": "^9.0.0",
"@aparajita/capacitor-secure-storage": "^6.0.1",
"@capacitor-community/barcode-scanner": "^4.0.1",
"@capacitor-community/electron": "^4.1.2",
"@capacitor-community/media": "^8.0.0",
"@capacitor-community/speech-recognition": "^6.0.1",
"@capacitor-community/sqlite": "^5.2.3",
"@capacitor/action-sheet": "^7.0.0",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/assets": "latest",
"@capacitor/background-runner": "^2.1.0",
"@capacitor/browser": "^7.0.0",
"@capacitor/camera": "^7.0.0",
"@capacitor/clipboard": "^7.0.0",
"@capacitor/core": "^7.0.0",
"@capacitor/device": "^7.0.0",
"@capacitor/dialog": "^7.0.0",
"@capacitor/filesystem": "^7.0.0",
"@capacitor/geolocation": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/local-notifications": "^7.0.0",
"@capacitor/network": "^7.0.0",
"@capacitor/preferences": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@capacitor/toast": "^7.0.0",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@gun-vue/composables": "^0.24.2",
"@gun-vue/gun-es": "^0.3.1240",
"@iconify/tailwind": "latest",
"@ionic-native/background-mode": "^5.36.0",
"@ionic-native/core": "^5.36.0",
"@ionic-native/fingerprint-aio": "^5.36.0",
"@ionic-native/media": "^5.36.0",
"@ionic-native/qr-scanner": "^5.36.0",
"@ionic-native/secure-storage": "^5.36.0",
"@ionic-native/sqlite": "^5.36.0",
"@ionic/cli": "latest",
"@ionic/pwa-elements": "^3.2.2",
"@ionic/storage": "^4.0.0",
"@ionic/storage-angular": "^4.0.0",
"@ionic/vue": "^7.0.0",
"@ionic/vue-router": "^7.0.0",
"@joyid/capacitor-native-passkey": "^0.0.3",
"@peculiar/webcrypto": "^1.5.0",
"@rollup/plugin-inject": "^5.0.5",
"@scure/bip39": "^1.6.0",
"@simplewebauthn/browser": "^13.1.0",
"@tresjs/core": "latest",
"@tweenjs/tween.js": "latest",
"@types/gun": "^0.9.6",
"@types/text-encoding": "^0.0.40",
"@types/uuid": "^10.0.0",
"@types/webrtc": "^0.0.44",
"@vue/language-plugin-pug": "^2.2.2",
"@vueuse/core": "latest",
"@vueuse/gesture": "^2.0.0",
"add": "^2.0.6",
"animate.css": "latest",
"autopass": "^2.1.0",
"axios": "latest",
"b4a": "^1.6.7",
"better-scroll": "^2.5.1",
"btoa": "^1.2.1",
"buffer": "^6.0.3",
"capacitor-voice-recorder": "^7.0.5",
"cordova-plugin-background-mode": "^0.7.3",
"cordova-plugin-device": "^3.0.0",
"cordova-plugin-fingerprint-aio": "^6.0.0",
"cordova-plugin-secure-storage-echo": "^5.1.1",
"cordova-sqlite-storage": "^7.0.0",
"corestore": "^7.1.0",
"crypto-browserify": "^3.12.1",
"crypto-js": "^4.2.0",
"cryptojs": "^2.5.3",
"elliptic": "^6.6.1",
"ffmpeg": "^0.0.4",
"gsap": "^3.13.0",
"gun": "^0.2020.1240",
"gun-avatar": "^2.2.2",
"gun-flint": "^0.0.28",
"ionicons": "^7.4.0",
"jsqr": "latest",
"localforage-cordovasqlitedriver": "^1.8.0",
"lodash": "^4.17.21",
"native-run": "^2.0.1",
"node-forge": "^1.3.1",
"nvm": "latest",
"peerjs": "^1.5.4",
"photoswipe": "^5.4.4",
"pinia": "^3.0.1",
"pinyin-pro": "^3.26.0",
"postcss": "latest",
"postcss-loader": "latest",
"pug": "^3.0.3",
"qrcode.vue": "latest",
"qrcodejs2": "latest",
"sass": "latest",
"socket.io-client": "latest",
"stats.js": "latest",
"stream-browserify": "^3.0.0",
"swiper": "^11.2.6",
"tailwind": "latest",
"text-encoding": "^0.7.0",
"three": "latest",
"three-stdlib": "^2.35.16",
"tweakpane": "latest",
"uint8arrays": "latest",
"unplugin-auto-import": "^19.0.0",
"uuid": "^11.1.0",
"vite-plugin-pwa": "latest",
"vm-browserify": "^1.1.2",
"vue": "latest",
"vue-chartjs": "latest",
"vue-count-to": "^1.0.13",
"vue-i18n": "latest",
"vue-image-lightbox": "^7.2.0",
"vue-lazyload": "^3.0.0",
"vue-router": "^4.1.6",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-carousel": "^0.15.0",
"vue3-photo-preview": "^0.3.0",
"vue3-qrcode-reader": "latest",
"vue3-touch-events": "latest",
"webrtc-adapter": "^9.0.3"
},
"devDependencies": {
"@capacitor/cli": "^7.0.0",
"@iconify-json/tabler": "latest",
"@iconify/json": "latest",
"@tsconfig/node22": "latest",
"@types/jsdom": "latest",
"@types/node": "^22.10.7",
"@types/stats.js": "latest",
"@types/three": "latest",
"@unocss/preset-icons": "^65.4.2",
"@unocss/preset-uno": "^65.4.2",
"@vitejs/plugin-legacy": "^4.0.2",
"@vitejs/plugin-vue": "^4.0.0",
"@vitest/eslint-plugin": "latest",
"@vue/eslint-config-prettier": "latest",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/test-utils": "^2.3.0",
"@vue/tsconfig": "latest",
"copyfiles": "^2.4.1",
"cypress": "^13.1.0",
"eslint": "^8.35.0",
"eslint-plugin-oxlint": "latest",
"eslint-plugin-vue": "^9.9.0",
"jsdom": "^22.1.0",
"npm-run-all2": "latest",
"oxlint": "latest",
"prettier": "latest",
"process": "^0.11.10",
"rimraf": "^5.0.1",
"tailwindcss": "latest",
"terser": "^5.37.0",
"typescript": "^5.1.6",
"unocss": "^65.4.2",
"unplugin-vue-components": "latest",
"vite": "^latest",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pages": "latest",
"vite-plugin-vue-devtools": "latest",
"vitest": "latest",
"vue-tsc": "latest"
},
"description": "gun-sqlite-relay",
"main": "index.js",
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@ -0,0 +1,131 @@
<template>
<ion-app>
<ion-router-outlet />
</ion-app>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { IonApp, IonRouterOutlet } from '@ionic/vue';
function setupNetworkListener() {
let debounceTimer: NodeJS.Timeout;
window.addEventListener('online', async () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
}, 500);
});
}
// console.warn
const originalConsoleWarn = console.warn;
function filterGunWarnings(...args: any[]) {
const message = args[0]?.toString() || '';
if (message.includes('Deprecated internal utility will break in next version')) {
return; // Gun.js
}
originalConsoleWarn.apply(console, args);
}
onMounted(async () => {
console.warn = filterGunWarnings;
await setupNetworkListener();
});
onUnmounted(() => {
window.removeEventListener('online', () => {});
window.removeEventListener('offline', () => {});
});
</script>
<style scoped>
/* 滚动条样式 */
::-webkit-scrollbar {
width: 0px; /* 滚动条宽度 */
background-color: transparent; /* 透明背景 */
}
/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
background-color: transparent; /* 半透明的滑块颜色 */
border-radius: 4px; /* 圆角 */
}
/* 滚动条轨道 */
::-webkit-scrollbar-track {
background-color: transparent; /* 透明轨道 */
}
.ion-page,
ion-content {
background: transparent !important;
--background: transparent !important;
}
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
/* touch-action: none;
touch-action: pan-x pan-y;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none; */
background-color: transparent;
overflow: hidden;
}
body {
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@ -0,0 +1,638 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useNetworkStatus } from '@/composables/useNetworkStatus';
import {
IonIcon,
IonToggle,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonItem,
IonLabel,
IonInput,
IonButton,
IonButtons
} from '@ionic/vue';
import { addCircleSharp, closeCircleSharp, searchSharp, closeSharp, refreshOutline } from 'ionicons/icons';
import StorageService from '@/services/storageService';
import { getCurrentInstance } from 'vue';
const appInstance = getCurrentInstance();
const storageServ = appInstance?.appContext.config.globalProperties.$storageServ as StorageService;
const {
networkStatus,
peersStatus,
currentMode,
peerStatuses,
peersList,
enabledPeer,
addPeer,
removePeer,
enablePeer,
disablePeer,
peersNotes,
savePeerNote,
} = useNetworkStatus(storageServ);
const newPeerUrl = ref('');
const searchQuery = ref('');
const selectedPeer = ref<string | null>(null);
const isModalOpen = ref(false);
const notes = ref<Record<string, string>>({});
const isResetting = ref(false);
onMounted(async () => {
try {
const savedNotes = localStorage.getItem('relayNotes');
if (savedNotes) {
const notes = JSON.parse(savedNotes);
for (const [peer, note] of Object.entries(notes)) {
await savePeerNote(peer as string, note as string);
}
localStorage.removeItem('relayNotes'); //
console.log('Migrated relay notes to SQLite');
}
} catch (e) {
console.error('Failed to migrate relay notes:', e);
}
});
watch(peersNotes, async (newNotes) => {
for (const [peer, note] of Object.entries(newNotes)) {
await savePeerNote(peer, note);
}
}, { deep: true });
const sortedPeers = computed(() => {
return peersList.value
.slice()
.sort((a, b) => {
if (a === enabledPeer.value) return -1;
if (b === enabledPeer.value) return 1;
return 0;
})
.filter((peer) => {
const searchLower = searchQuery.value.toLowerCase();
const peerLower = peer.toLowerCase();
const noteLower = (peersNotes.value[peer] || '').toLowerCase();
return peerLower.includes(searchLower) || noteLower.includes(searchLower);
});
});
const openModal = (peer: string) => {
selectedPeer.value = peer;
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
selectedPeer.value = null;
};
</script>
<template>
<div class="liquid-container" >
<div class="status-bar" :class="['indicator', networkStatus]">
<div >
</div>
</div>
<div class="add-peer">
<input
v-model="newPeerUrl"
placeholder="Enter relay URL"
@keyup.enter="addPeer(newPeerUrl)"
/>
<ion-icon
:icon="addCircleSharp"
class="addlink"
@click="addPeer(newPeerUrl);newPeerUrl = ''"
/>
</div>
<div class="peer-list">
<div class="peer-list-header">
<h3>Gun Relays</h3>
<div class="search-container">
<ion-icon :icon="searchSharp" class="search-icon" />
<input
v-model="searchQuery"
placeholder="Search relays..."
class="search-input"
/>
</div>
</div>
<div class="peer-scroll-container">
<div
v-for="peer in sortedPeers"
:key="peer"
class="peer-item"
@click="openModal(peer)"
>
<div class="peer-header">
<div :class="['status', peerStatuses[peer]]">
{{ peerStatuses[peer] || 'Checking' }}
</div>
</div>
<div class="peer-content">
<span class="peer-url">{{ notes[peer] || peer }}</span>
</div>
<div class="peer-actions">
<ion-toggle
@click.stop
:checked="enabledPeer === peer"
@ionChange="enabledPeer === peer ? disablePeer() : enablePeer(peer)"
color="primary"
/>
</div>
</div>
</div>
</div>
<ion-modal :is-open="isModalOpen" @didDismiss="closeModal">
<ion-content class="ion-padding">
<ion-item lines="none" v-if="selectedPeer">
<ion-label position="stacked">Relay URL</ion-label>
<ion-input :value="selectedPeer" readonly class="readonly-input" />
</ion-item>
<ion-item lines="none" v-if="selectedPeer">
<ion-label position="stacked">Status</ion-label>
<div :class="['status', peerStatuses[selectedPeer]]" class="status-display">
{{ peerStatuses[selectedPeer] || 'Checking' }}
</div>
</ion-item>
<ion-item lines="none" v-if="selectedPeer">
<ion-label position="stacked">Note</ion-label>
<ion-input
v-model="notes[selectedPeer]"
placeholder=""
clear-input
style=" --padding-start: 12px;
--padding-end: 12px;"
/>
</ion-item>
<div class="modal-actions">
<ion-button
expand="block"
color="danger"
@click="removePeer(selectedPeer!); closeModal()"
>
Remove Relay
</ion-button>
<ion-button
expand="block"
color="medium"
@click="closeModal"
>
Close
</ion-button>
</div>
</ion-content>
</ion-modal>
</div>
</template>
<style scoped>
.liquid-container {
padding: 16px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: transparent;
/* background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.05)); */
}
.status-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
padding: 6px 8px;
background: rgba(130, 130, 130, 0.15);
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
flex-wrap: wrap;
align-items: center;
}
.status-item {
display: flex;
align-items: center;
padding: 4px 8px;
transition: transform 0.2s ease;
}
.status-item:hover {
transform: translateY(-1px);
}
.reset-button-container {
margin-left: auto;
}
.reset-button {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(102, 204, 255, 0.2), rgba(102, 204, 255, 0.4));
border: none;
border-radius: 8px;
padding: 6px 12px;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
color: inherit;
font-weight: 500;
font-size: 13px;
}
.reset-button:hover {
background: linear-gradient(135deg, rgba(102, 204, 255, 0.3), rgba(102, 204, 255, 0.5));
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.reset-button:active {
transform: translateY(1px);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.reset-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.reset-icon {
font-size: 16px;
}
.rotating {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.label {
font-weight: 600;
font-size: 13px;
margin-right: 6px;
/* color: #333; */
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
.indicator {
width: 100%;
/* padding: 4px 8px; */
border-radius: 8px;
font-size: 12px;
font-weight: 500;
backdrop-filter: blur(4px);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.indicator.online,
.indicator.connected,
.indicator.relay {
background: linear-gradient(135deg, #88ff88, #55ccaa);
color: #2a2a2a;
}
.indicator.offline,
.indicator.disconnected,
.indicator.direct {
background: linear-gradient(135deg, #ff7777, #cc5555);
color: #fff;
}
.add-peer {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.add-peer input {
flex: 1;
padding: 10px 12px;
border: none;
border-radius: 12px;
background: rgba(150, 150, 150, 0.2);
font-size: 14px;
/* color: #333; */
transition: all 0.2s ease;
}
.add-peer input:hover,
.add-peer input:focus {
background: rgba(150, 150, 150, 0.25);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
outline: none;
}
.addlink {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
/* color: #66ccff; */
cursor: pointer;
transition: all 0.2s ease;
}
.addlink:hover {
/* color: #88ddff; */
transform: scale(1.1);
}
.peer-list {
flex: 1;
display: flex;
flex-direction: column;
}
.peer-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.peer-list h3 {
font-size: 18px;
font-weight: 600;
margin: 0;
/* color: #333; */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.search-container {
position: relative;
width: 220px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #777;
font-size: 16px;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 36px;
border: none;
border-radius: 12px;
background: rgba(150, 150, 150, 0.2);
font-size: 14px;
transition: all 0.2s ease;
}
.search-input:hover,
.search-input:focus {
background: rgba(150, 150, 150, 0.25);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
outline: none;
}
.peer-scroll-container {
border-radius: 12px;
max-height: 539px;
overflow-y: auto;
flex: 1;
}
.peer-scroll-container::-webkit-scrollbar {
width: 6px;
}
.peer-scroll-container::-webkit-scrollbar-track {
background: rgba(150, 150, 150, 0.2);
border-radius: 6px;
}
.peer-scroll-container::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #66ccff, #88ddff);
border-radius: 6px;
}
.peer-scroll-container::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #55bbff, #77ccff);
}
.peer-item {
padding: 12px;
background: linear-gradient(135deg, rgba(150, 150, 150, 0.15), rgba(255, 255, 255, 0.05));
border-radius: 12px;
margin-bottom: 12px;
transition: all 0.2s ease;
cursor: pointer;
}
.peer-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.peer-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.status {
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
text-align: center;
backdrop-filter: blur(4px);
text-transform: capitalize;
}
.status.connected {
background: linear-gradient(135deg, #88ff88, #55ccaa);
color: #2a2a2a;
}
.status.disconnected {
background: linear-gradient(135deg, #ff7777, #cc5555);
color: #fff;
}
.status.checking {
background: linear-gradient(135deg, #ffcc66, #ffaa33);
color: #333;
}
.peer-content {
margin-bottom: 10px;
}
.peer-url {
display: block;
word-break: break-all;
font-size: 13px;
line-height: 1.5;
padding: 6px 10px;
background: rgba(150, 150, 150, 0.1);
border-radius: 8px;
/* color: #444; */
}
.peer-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
ion-toggle {
/* --background: rgba(150, 150, 150, 0.2);
--background-checked: linear-gradient(135deg, #66ccff, #88ddff);
--handle-background: #fff;
--handle-background-checked: #fff; */
width: 48px;
height: 24px;
/* --handle-width: 20px;
--handle-height: 20px; */
}
/* Modal Styles */
ion-modal {
--border-radius: 12px;
--max-width: 400px;
--max-height: 46%;
--backdrop-opacity: 0.4;
}
/* ion-header {
--background: rgba(150, 150, 150, 0.1);
} */
ion-toolbar {
--background: transparent;
--border-width: 0;
}
ion-title {
font-size: 18px;
font-weight: 600;
color: #333;
padding: 0 12px;
}
ion-buttons {
margin-right: 8px;
}
ion-button {
--background-activated: transparent;
}
ion-button ion-icon {
font-size: 24px;
color: #666;
}
ion-button:hover ion-icon {
color: #333;
}
.ion-padding {
padding: 16px;
}
ion-item {
--background: transparent;
--padding-start: 0;
--inner-padding-end: 0;
/* margin-bottom: 16px; */
}
ion-label {
/* color: #333 !important; */
font-weight: 500;
margin-bottom: 4px;
}
ion-input {
--background: rgba(150, 150, 150, 0.15);
--padding-start: 12px;
--padding-end: 12px;
--padding-top: 8px;
--padding-bottom: 8px;
border-radius: 8px;
font-size: 14px;
}
.readonly-input {
--padding-start: 12px;
--padding-end: 12px;
--color: #666;
--background: rgba(150, 150, 150, 0.1);
}
.status-display {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
font-size: 14px;
text-align: center;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.modal-actions ion-button {
--border-radius: 8px;
--padding-start: 16px;
--padding-end: 16px;
flex: 1;
}
.modal-actions ion-button[color="danger"] {
--background: #ff6666;
--background-hover: #ff8888;
--background-activated: #ff5555;
}
.modal-actions ion-button[color="medium"] {
--background: #ccc;
--background-hover: #ddd;
--background-activated: #bbb;
}
</style>

View File

@ -0,0 +1,202 @@
import { ref, Ref } from 'vue';
import { Flint, NodeAdapter } from 'gun-flint';
import StorageService from '../services/storageService';
import { ISQLiteService } from '../services/sqliteService';
import { IDbVersionService } from '../services/dbVersionService';
// 日志工具
// const log = {
// debug: (msg: string, ...args: any[]) => console.debug(`[Gun-SQLite-Adapter] ${msg}`, ...args),
// info: (msg: string, ...args: any[]) => console.info(`[Gun-SQLite-Adapter] ${msg}`, ...args),
// warn: (msg: string, ...args: any[]) => console.warn(`[Gun-SQLite-Adapter] ${msg}`, ...args),
// error: (msg: string, ...args: any[]) => console.error(`[Gun-SQLite-Adapter] ${msg}`, ...args),
// };
// 请求队列管理,防止重复查询
class RequestQueue {
private queue: Map<string, { resolve: (data: any) => void; reject: (err: any) => void }[]> = new Map();
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
private storageServ: StorageService;
constructor(storageServ: StorageService) {
this.storageServ = storageServ;
}
async get(key: string): Promise<any> {
return new Promise((resolve, reject) => {
const handlers = this.queue.get(key) || [];
handlers.push({ resolve, reject });
this.queue.set(key, handlers);
if (!this.debounceTimers.has(key)) {
const timer = setTimeout(async () => {
const handlers = this.queue.get(key) || [];
this.queue.delete(key);
this.debounceTimers.delete(key);
try {
if (!this.storageServ.db) throw new Error('Database connection not available');
const result = await this.storageServ.query('SELECT value FROM gun_nodes WHERE key = ?', [key]);
const data = result.values && result.values.length > 0 ? JSON.parse(result.values[0].value) : null;
handlers.forEach(h => h.resolve(data));
} catch (err) {
//log.error(`Failed to get key=${key}:`, err);
handlers.forEach(h => h.reject(err));
}
}, 50);
this.debounceTimers.set(key, timer);
}
});
}
async put(soul: string, node: any): Promise<void> {
await this.storageServ.run(
'INSERT OR REPLACE INTO gun_nodes (key, value, timestamp) VALUES (?, ?, ?)',
[soul, JSON.stringify(node), Date.now()]
);
}
async batchPut(nodes: Record<string, any>): Promise<void> {
const updates: [string, string, number][] = [];
for (const soul in nodes) {
updates.push([soul, JSON.stringify(nodes[soul]), Date.now()]);
}
await this.storageServ.run(
'INSERT OR REPLACE INTO gun_nodes (key, value, timestamp) VALUES ' + updates.map(() => '(?, ?, ?)').join(','),
updates.flat()
);
}
}
// 定义适配器接口
interface GunAdapter {
opt?: (context: any, options: any) => void;
get: (key: string, done: (err: Error | null, node: any) => void) => void;
put: (node: any, done: (err: Error | null) => void) => void;
}
export interface IGunSQLiteAdapter {
initialize(): Promise<void>;
getAdapter(): GunAdapter;
isReady: Ref<boolean>;
}
// 单例实例
let instance: IGunSQLiteAdapter | null = null;
export function useGunSQLiteAdapter(
sqliteService: ISQLiteService,
dbVersionService: IDbVersionService,
storageService: StorageService
): IGunSQLiteAdapter {
if (instance) return instance;
const isReady: Ref<boolean> = ref(false);
const storageServ = storageService;
let queue: RequestQueue | null = null;
async function initialize() {
if (isReady.value) return;
try {
//log.info('Initializing Gun SQLite adapter...');
// 检查 StorageService 是否已初始化数据库
if (!storageServ.db) {
//log.warn('StorageService database not initialized, initializing now...');
const dbName = 'talkflowdb';
const loadToVersion = storageServ.loadToVersion || 2;
storageServ.db = await sqliteService.openDatabase(dbName, loadToVersion, false);
} else {
const isOpen = await storageServ.db.isDBOpen();
if (!isOpen) {
//log.warn('Database not open, reopening...');
await storageServ.db.open();
}
}
// 创建 gun_nodes 表
const result = await storageServ.execute(`
CREATE TABLE IF NOT EXISTS gun_nodes (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
timestamp INTEGER DEFAULT (strftime('%s', 'now'))
)
`);
if (result.changes && result.changes.changes >= 0) {
//log.info('gun_nodes table created or already exists');
} else {
throw new Error('Failed to create gun_nodes table: no changes returned');
}
queue = new RequestQueue(storageServ);
isReady.value = true;
//log.info('Gun SQLite adapter initialized successfully');
} catch (err) {
//log.error('Failed to initialize Gun SQLite adapter:', err);
throw err;
}
}
const adapterCore = {
storageServ,
queue,
opt: async function (context: any, options: any) {
// log.info('Adapter opt called:', { context, options });
await initialize();
return options;
},
get: async function (key: string, done: (err: Error | null, node: any) => void) {
try {
if (!isReady.value) await initialize();
if (!queue) throw new Error('Adapter not initialized');
const data = await queue.get(key);
done(null, data);
} catch (err) {
//log.error(`Get error for key=${key}:`, err);
done(err instanceof Error ? err : new Error('Unknown error'), null);
}
},
put: async function (node: any, done: (err: Error | null) => void) {
try {
if (!isReady.value) await initialize();
if (!queue) throw new Error('Adapter not initialized');
if (typeof node !== 'object' || node === null) throw new Error('Invalid node');
const souls = Object.keys(node).length > 1 ? Object.keys(node) : [node._?.['#'] || node._.id];
if (!souls[0]) throw new Error('Missing soul in node');
if (souls.length > 1) {
await queue.batchPut(node);
} else {
await queue.put(souls[0], node[souls[0]] || node);
}
done(null);
} catch (err) {
//log.error('Put error:', err);
done(err instanceof Error ? err : new Error('Unknown error'));
}
},
};
const adapter = new NodeAdapter(adapterCore);
Flint.register(adapter);
const gunSQLiteAdapter: IGunSQLiteAdapter = {
initialize,
getAdapter: () => adapter,
isReady,
};
instance = gunSQLiteAdapter;
return gunSQLiteAdapter;
}
export function getGunSQLiteAdapter(
sqliteService: ISQLiteService,
dbVersionService: IDbVersionService,
storageService: StorageService
): IGunSQLiteAdapter {
if (!instance) {
instance = useGunSQLiteAdapter(sqliteService, dbVersionService, storageService);
}
return instance;
}
export default getGunSQLiteAdapter;

View File

@ -0,0 +1,121 @@
import { ref } from 'vue';
import Gun, { IGunInstance } from 'gun';
// 模块级别的单例状态
const isOnline = ref(navigator.onLine);
const peersConnected = ref(false);
const checkInterval = 60000; // 每 60 秒检查一次
// 单例初始化标志和变量
let initialized = false;
let intervalId: number | null = null;
let currentGunInstance: IGunInstance<any> | null = null;
let instance: ReturnType<typeof createNetwork> | null = null;
function createNetwork(gunInstance: IGunInstance<any>) {
// 检查 Gun.js 对等节点,反复尝试直到成功
async function checkPeers(): Promise<boolean> {
const maxAttempts = 3;
let attempt = 0;
const retryDelay = 1000; // 每次尝试间隔 1 秒
while (attempt < maxAttempts) {
attempt++;
const result = await new Promise<boolean>((resolve) => {
let alive = false;
const off = gunInstance.get('~public').once((data) => {
alive = true;
off.off();
resolve(true);
});
setTimeout(() => {
if (!alive) {
resolve(false);
}
}, 5000); // 10 秒超时
});
if (result) {
peersConnected.value = true;
return true;
} else {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
return false; // 理论上不会到达这里,因为 maxAttempts 是 Infinity
}
async function updateNetworkStatus() {
isOnline.value = navigator.onLine;
peersConnected.value = await checkPeers();
}
function handleOnline() {
updateNetworkStatus();
}
function handleOffline() {
isOnline.value = false;
peersConnected.value = false;
}
function startChecking() {
updateNetworkStatus();
intervalId = window.setInterval(updateNetworkStatus, checkInterval);
}
function stopChecking() {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
}
// 单例初始化逻辑
if (!initialized || currentGunInstance !== gunInstance) {
if (initialized && currentGunInstance !== gunInstance) {
// 如果 Gun 实例变了,清理旧的事件监听器和定时器
stopChecking();
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
}
initialized = true;
currentGunInstance = gunInstance;
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
startChecking();
}
return {
isOnline,
peersConnected,
updateNetworkStatus,
checkPeers,
};
}
// 导出单例
export function useNetwork(gunInstance: IGunInstance<any>) {
if (!instance || currentGunInstance !== gunInstance) {
instance = createNetwork(gunInstance);
}
return instance;
}
// 清理函数(可选,用于测试或应用卸载时)
export function cleanupNetwork() {
if (instance) {
window.removeEventListener('online', instance.updateNetworkStatus);
window.removeEventListener('offline', instance.updateNetworkStatus);
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
instance = null;
initialized = false;
currentGunInstance = null;
}
}

View File

@ -0,0 +1,558 @@
import { ref, watch } from 'vue';
import { useToast } from '@/composables/useToast';
import { useNetwork } from '@/composables/useNetwork';
import StorageService from '@/services/storageService';
import Gun from 'gun';
import 'gun/sea';
// 模块级别的单例状态
const networkStatus = ref<'online' | 'offline'>('online');
const peersStatus = ref<'connected' | 'disconnected'>('disconnected');
const currentMode = ref<'direct' | 'relay'>('direct');
const peerStatuses = ref<Record<string, 'connected' | 'disconnected'>>({});
// 单例初始化标志
let initialized = false;
let instance: ReturnType<typeof createNetworkStatus> | null = null;
function createNetworkStatus(storageService: StorageService) {
const { showToast } = useToast();
const { isOnline, peersConnected, updateNetworkStatus, checkPeers } = useNetwork(gun);
const peersNotes = ref<Record<string, string>>({});
const peersList = ref<string[]>([
'https://peer.wallie.io/gun',
'https://gun.defucc.me/gun',
'https://talkflow.team/gun',
'https://gun-manhattan.herokuapp.com/gun',
'https://gundb-relay-mlccl.ondigitalocean.app/gun',
]);
const enabledPeer = ref<string>(peersList.value[0]);
let gun = Gun({
peers: [ enabledPeer.value],
radisk: true,
localStorage: false,
gunSQLiteAdapter: {
key: 'gundb',
},
});
// 确保 storageService 已初始化
async function ensureStorageReady() {
try {
if (!storageService.db || !(await storageService.db.isDBOpen())) {
console.log('[useNetworkStatus] Initializing StorageService database...');
await storageService.initializeDatabase();
if (!storageService.db) {
throw new Error('StorageService 初始化后仍无数据库连接');
}
console.log('[useNetworkStatus] StorageService database initialized');
}
} catch (err) {
console.error('[useNetworkStatus] Failed to initialize StorageService:', err);
showToast('数据库初始化失败,请重试', 'error');
throw err;
}
}
// 保存 enabledPeer 到 SQLite
async function saveEnabledPeer() {
try {
await ensureStorageReady();
await storageService.run('UPDATE network_peers SET is_enabled = 0');
if (enabledPeer.value) {
await storageService.run(
'INSERT OR REPLACE INTO network_peers (url, is_enabled, note) VALUES (?, ?, ?)',
[enabledPeer.value, 1, peersNotes.value[enabledPeer.value] || '']
);
}
console.log(`Enabled peer saved: ${enabledPeer.value}`);
} catch (err) {
console.error('[useNetworkStatus] Failed to save enabled peer:', err);
showToast('无法保存启用节点', 'error');
}
}
// 保存节点备注
async function savePeerNote(peer: string, note: string) {
try {
await ensureStorageReady();
await storageService.run(
'UPDATE network_peers SET note = ? WHERE url = ?',
[note, peer]
);
peersNotes.value[peer] = note;
console.log(`Peer note saved: ${peer} -> ${note}`);
} catch (err) {
console.error('[useNetworkStatus] Failed to save peer note:', err);
showToast('无法保存节点备注', 'error');
}
}
// 从 SQLite 加载 Peer 配置和备注
// async function loadPeers() {
// try {
// await ensureStorageReady();
// const result = await storageService.query('SELECT url, is_enabled, note FROM network_peers');
// const peers = result.values || [];
// peersList.value = peers.map((peer: { url: string }) => peer.url);
// peersNotes.value = peers.reduce((acc: Record<string, string>, peer: { url: string; note: string }) => {
// acc[peer.url] = peer.note || '';
// return acc;
// }, {});
// const enabled = peers.find((peer: { is_enabled: number }) => peer.is_enabled === 1);
// if (enabled && peersList.value.includes(enabled.url)) {
// enabledPeer.value = enabled.url;
// } else if (peersList.value.length > 0) {
// enabledPeer.value = peersList.value[0];
// await saveEnabledPeer();
// }
// gun.opt({ peers: peersList.value });
// } catch (err) {
// console.error('[useNetworkStatus] Failed to load peers:', err);
// showToast('无法加载节点列表', 'error');
// }
// }
async function loadPeers() {
try {
await ensureStorageReady();
const result = await storageService.query('SELECT url, is_enabled, note FROM network_peers');
const peers = result.values || [];
if (peers.length === 0) {
// 如果 SQLite 表为空,插入 TalkFlowCore 的预设节点
console.log('[useNetworkStatus] network_peers 表为空,插入预设节点');
const { peersList: defaultPeersList } = getTalkFlowCore(); // 获取预设节点
for (const peerUrl of defaultPeersList.value) {
await storageService.run(
'INSERT OR IGNORE INTO network_peers (url, is_enabled, note) VALUES (?, ?, ?)',
[peerUrl, 0, '']
);
}
// 重新查询以确保数据已插入
const newResult = await storageService.query('SELECT url, is_enabled, note FROM network_peers');
peersList.value = newResult.values.map((peer: { url: string }) => peer.url);
} else {
// 使用 SQLite 中的节点
peersList.value = peers.map((peer: { url: string }) => peer.url);
}
// 加载备注
peersNotes.value = peers.reduce((acc: Record<string, string>, peer: { url: string; note: string }) => {
acc[peer.url] = peer.note || '';
return acc;
}, {});
// 设置 enabledPeer
const enabled = peers.find((peer: { is_enabled: number }) => peer.is_enabled === 1);
if (enabled && peersList.value.includes(enabled.url)) {
enabledPeer.value = enabled.url;
} else if (peersList.value.length > 0) {
enabledPeer.value = peersList.value[0];
await saveEnabledPeer();
}
// 更新 Gun 配置
gun.opt({ peers: peersList.value });
console.log('[useNetworkStatus] Gun 配置节点:', peersList.value);
} catch (err) {
console.error('[useNetworkStatus] 加载节点失败:', err);
showToast('无法加载节点列表', 'error');
}
}
// 更新网络和 Peer 状态
async function updateStatus() {
networkStatus.value = isOnline.value ? 'online' : 'offline';
peersStatus.value = peersConnected.value ? 'connected' : 'disconnected';
currentMode.value = peersConnected.value && enabledPeer.value ? 'relay' : 'direct';
await updatePeerStatuses();
}
// 检查单个 Peer 的状态(用于 UI 展示)
async function checkPeerStatus(peer: string): Promise<'connected' | 'disconnected'> {
return new Promise((resolve) => {
const tempGun = Gun({ peers: [peer] });
let connected = false;
tempGun.on('hi', () => {
connected = true;
resolve('connected');
});
setTimeout(() => {
if (!connected) resolve('disconnected');
}, 5000);
});
}
// 更新所有 Peer 的状态(用于 UI 展示)
async function updatePeerStatuses() {
for (const peer of peersList.value) {
const status = await checkPeerStatus(peer);
peerStatuses.value[peer] = status;
}
}
// 用户手动启用某个 Peer
async function enablePeer(peer: string) {
if (!peersList.value.includes(peer)) {
showToast(`节点 ${peer} 不在列表中`, 'warning');
return;
}
if (enabledPeer.value === peer) {
showToast(`${peer} 已是启用状态`, 'info');
return;
}
enabledPeer.value = peer;
await saveEnabledPeer();
gun.opt({ peers: peersList.value, priorityPeer: peer });
showToast(`启用节点: ${peer}`, 'success');
const connected = await checkPeers();
if (!connected) {
showToast(`无法连接到 ${peer},将回退到其他节点`, 'warning');
}
await updateStatus();
}
// 用户禁用当前启用的 Peer
async function disablePeer() {
if (!enabledPeer.value) {
showToast('无启用的节点', 'info');
return;
}
const oldPeer = enabledPeer.value;
enabledPeer.value = peersList.value.length > 1 ? peersList.value.find(p => p !== oldPeer) || '' : '';
await saveEnabledPeer();
gun.opt({ peers: peersList.value });
showToast(`禁用节点: ${oldPeer}`, 'success');
await updateStatus();
}
// 添加 Peer允许任意输入
async function addPeer(url: string) {
if (!url) {
showToast('请输入节点地址', 'warning');
return;
}
const trimmedUrl = url.trim();
if (peersList.value.includes(trimmedUrl)) {
showToast('该节点已存在', 'warning');
return;
}
try {
await ensureStorageReady();
console.log('[useNetworkStatus] 添加节点:', trimmedUrl);
await storageService.run(
'INSERT INTO network_peers (url, is_enabled, note) VALUES (?, ?, ?)',
[trimmedUrl, 0, '']
);
// 重新查询 network_peers 表,确保数据一致
const result = await storageService.query('SELECT url FROM network_peers');
peersList.value = result.values.map((peer: { url: string }) => peer.url);
console.log('[useNetworkStatus] 更新 peersList:', peersList.value);
gun.opt({ peers: peersList.value });
showToast(`节点已添加: ${trimmedUrl}`, 'success');
await updatePeerStatuses();
} catch (err: any) {
console.error('[useNetworkStatus] Failed to add peer:', err);
showToast(`添加节点失败: ${err.message || '未知错误'}`, 'error');
}
}
// 移除 Peer
async function removePeer(peer: string) {
try {
await ensureStorageReady();
if (enabledPeer.value === peer) {
await disablePeer();
}
await storageService.run('DELETE FROM network_peers WHERE url = ?', [peer]);
peersList.value = peersList.value.filter(p => p !== peer);
delete peerStatuses.value[peer];
gun.opt({ peers: peersList.value });
showToast(`删除节点 ${peer}`, 'success');
await updatePeerStatuses();
} catch (err) {
console.error('[useNetworkStatus] Failed to remove peer:', err);
showToast('删除节点失败', 'error');
}
}
// 处理网络状态变化
function handleOnline() {
updateNetworkStatus();
updateStatus();
}
function handleOffline() {
updateNetworkStatus();
updateStatus();
}
// 初始化逻辑,只执行一次
if (!initialized) {
initialized = true;
loadPeers();
updateStatus();
// 添加网络事件监听
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 监听 peersList 和 enabledPeer 的变化
watch(peersList, () => {
updatePeerStatuses();
});
watch(enabledPeer, () => {
saveEnabledPeer();
updateStatus();
});
}
return {
networkStatus,
peersStatus,
currentMode,
peerStatuses,
peersList,
enabledPeer,
addPeer,
removePeer,
enablePeer,
disablePeer,
updateStatus,
peersNotes,
savePeerNote,
};
}
// 导出单例
export function useNetworkStatus(storageService: StorageService) {
if (!instance) {
instance = createNetworkStatus(storageService);
}
return instance;
}
// import { ref, watch } from 'vue';
// import { useToast } from '@/composables/useToast';
// import { useNetwork } from '@/composables/useNetwork';
// // 模块级别的单例状态
// const networkStatus = ref<'online' | 'offline'>('online');
// const peersStatus = ref<'connected' | 'disconnected'>('disconnected');
// const currentMode = ref<'direct' | 'relay'>('direct');
// const peerStatuses = ref<Record<string, 'connected' | 'disconnected'>>({});
// // 单例初始化标志
// let initialized = false;
// let instance: ReturnType<typeof createNetworkStatus> | null = null;
// // 持久化 enabledPeer 的本地存储键
// const ENABLED_PEER_KEY = 'enabledPeer';
// function createNetworkStatus() {
// const { gun, peersList, enabledPeer } = getTalkFlowCore();
// const { showToast } = useToast();
// const { isOnline, peersConnected, updateNetworkStatus, checkPeers } = useNetwork(gun);
// // 保存 enabledPeer 到 localStorage
// function saveEnabledPeer() {
// localStorage.setItem(ENABLED_PEER_KEY, enabledPeer.value);
// }
// // 从 localStorage 加载 Peer 配置
// function loadPeers() {
// const savedPeers = localStorage.getItem('peers');
// if (savedPeers) {
// peersList.value = JSON.parse(savedPeers);
// }
// const savedPeer = localStorage.getItem(ENABLED_PEER_KEY);
// if (savedPeer && peersList.value.includes(savedPeer)) {
// enabledPeer.value = savedPeer;
// } else if (peersList.value.length > 0) {
// enabledPeer.value = peersList.value[0];
// saveEnabledPeer();
// }
// // 初始化时使用完整的 peersList
// gun.opt({ peers: peersList.value });
// }
// // 更新网络和 Peer 状态
// async function updateStatus() {
// networkStatus.value = isOnline.value ? 'online' : 'offline';
// peersStatus.value = peersConnected.value ? 'connected' : 'disconnected';
// currentMode.value = peersConnected.value && enabledPeer.value ? 'relay' : 'direct';
// await updatePeerStatuses();
// }
// // 检查单个 Peer 的状态(用于 UI 展示)
// async function checkPeerStatus(peer: string): Promise<'connected' | 'disconnected'> {
// return new Promise((resolve) => {
// const tempGun = Gun({ peers: [peer] });
// let connected = false;
// tempGun.on('hi', () => {
// connected = true;
// resolve('connected');
// });
// setTimeout(() => {
// if (!connected) resolve('disconnected');
// }, 5000);
// });
// }
// // 更新所有 Peer 的状态(用于 UI 展示)
// async function updatePeerStatuses() {
// for (const peer of peersList.value) {
// const status = await checkPeerStatus(peer);
// peerStatuses.value[peer] = status;
// }
// }
// // 用户手动启用某个 Peer
// async function enablePeer(peer: string) {
// if (!peersList.value.includes(peer)) {
// showToast(`Peer ${peer} not in list`, 'warning');
// return;
// }
// if (enabledPeer.value === peer) {
// showToast(`${peer} is already enabled`, 'info');
// return;
// }
// enabledPeer.value = peer;
// saveEnabledPeer();
// gun.opt({ peers: peersList.value, priorityPeer: peer }); // 自定义选项,优先尝试该 Peer
// showToast(`Enabled peer: ${peer}`, 'success');
// // 检查连接状态
// const connected = await checkPeers();
// if (!connected) {
// showToast(`Failed to connect to ${peer}, falling back to other peers`, 'warning');
// }
// await updateStatus();
// }
// // 用户禁用当前启用的 Peer
// function disablePeer() {
// if (!enabledPeer.value) {
// showToast('No peer enabled', 'info');
// return;
// }
// const oldPeer = enabledPeer.value;
// enabledPeer.value = peersList.value.length > 1 ? peersList.value.find(p => p !== oldPeer) || '' : '';
// saveEnabledPeer();
// gun.opt({ peers: peersList.value }); // 重置为完整列表
// showToast(`Disabled peer: ${oldPeer}`, 'success');
// updateStatus();
// }
// // 添加 Peer
// function addPeer(url: string) {
// if (!url) {
// showToast('Please enter the node URL', 'warning');
// return;
// }
// if (peersList.value.includes(url)) {
// showToast('This node already exists.', 'warning');
// return;
// }
// peersList.value.push(url);
// localStorage.setItem('peers', JSON.stringify(peersList.value));
// gun.opt({ peers: peersList.value });
// showToast(`Node added: ${url}`, 'success');
// updatePeerStatuses();
// }
// // 移除 Peer
// function removePeer(peer: string) {
// if (enabledPeer.value === peer) {
// disablePeer();
// }
// peersList.value = peersList.value.filter(p => p !== peer);
// delete peerStatuses.value[peer];
// localStorage.setItem('peers', JSON.stringify(peersList.value));
// gun.opt({ peers: peersList.value });
// showToast(`Deleted node ${peer}`, 'success');
// updatePeerStatuses();
// }
// // 处理网络状态变化
// function handleOnline() {
// updateNetworkStatus();
// updateStatus();
// }
// function handleOffline() {
// updateNetworkStatus();
// updateStatus();
// }
// // 初始化逻辑,只执行一次
// if (!initialized) {
// initialized = true;
// loadPeers();
// updateStatus();
// // 添加网络事件监听
// window.addEventListener('online', handleOnline);
// window.addEventListener('offline', handleOffline);
// // 监听 peersList 和 enabledPeer 的变化
// watch(peersList, () => {
// // gun.opt({ peers: peersList.value });
// updatePeerStatuses();
// });
// watch(enabledPeer, () => {
// saveEnabledPeer();
// updateStatus();
// });
// }
// return {
// networkStatus,
// peersStatus,
// currentMode,
// peerStatuses,
// peersList,
// enabledPeer,
// addPeer,
// removePeer,
// enablePeer,
// disablePeer,
// updateStatus,
// };
// }
// // 导出单例
// export function useNetworkStatus() {
// if (!instance) {
// instance = createNetworkStatus();
// }
// return instance;
// }
// 清理函数(用于测试或应用卸载时)
// export function cleanupNetworkStatus() {
// if (instance) {
// window.removeEventListener('online', instance.updateStatus);
// window.removeEventListener('offline', instance.updateStatus);
// instance = null;
// initialized = false;
// }
// }

View File

@ -0,0 +1,88 @@
// src/composables/useToast.ts
import { ref, onMounted } from 'vue';
import { Directory, Encoding, Filesystem } from '@capacitor/filesystem';
type ToastType = 'info' | 'success' | 'error' | 'warning';
interface ToastMessage {
id: number;
text: string;
type: ToastType;
duration: number;
}
const messages = ref<ToastMessage[]>([]);
let idCounter = 0;
const isEnabled = ref(false); // 默认开启提示
const SETTINGS_FILE = 'toast_settings.json';
async function loadSettings(): Promise<{ isToastEnabled: boolean }> {
const defaultSettings = { isToastEnabled: false };
try {
const result = await Filesystem.readFile({
path: SETTINGS_FILE,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const data = typeof result.data === 'string' ? result.data : await result.data.text();
return JSON.parse(data) || defaultSettings;
} catch (err) {
console.log('未找到提示设置文件,使用默认值');
return defaultSettings;
}
}
async function saveSettings(): Promise<void> {
try {
await Filesystem.writeFile({
path: SETTINGS_FILE,
data: JSON.stringify({ isToastEnabled: isEnabled.value }),
directory: Directory.Data,
encoding: Encoding.UTF8,
});
console.log('提示设置已保存:', { isToastEnabled: isEnabled.value });
} catch (err) {
console.error('保存提示设置失败:', err);
}
}
export function showToast(msg: string, msgType: ToastType = 'info', customDuration = 3000) {
if (!isEnabled.value) return; // 如果关闭则不显示
const toast = {
id: idCounter++,
text: msg,
type: msgType,
duration: customDuration,
};
messages.value.push(toast);
setTimeout(() => {
messages.value = messages.value.filter(m => m.id !== toast.id);
}, customDuration);
}
function hideToast(id: number) {
messages.value = messages.value.filter(m => m.id !== id);
}
function toggleToast(enabled: boolean) {
isEnabled.value = enabled;
saveSettings(); // 保存设置
}
// 初始化加载设置
onMounted(async () => {
const settings = await loadSettings();
isEnabled.value = settings.isToastEnabled;
});
export function useToast() {
return {
messages,
isEnabled,
showToast,
hideToast,
toggleToast,
};
}

View File

@ -0,0 +1,97 @@
import { createApp } from 'vue'
import App from './src/App.vue'
import router from './router';
import { IonicVue } from '@ionic/vue';
import { useRouter } from 'vue-router'
/* Core CSS required for Ionic components to work properly */
import '@ionic/vue/css/core.css';
/* Basic CSS for apps built with Ionic */
import '@ionic/vue/css/normalize.css';
import '@ionic/vue/css/structure.css';
import '@ionic/vue/css/typography.css';
import '@ionic/vue/css/ionic.bundle.css'
/* Optional CSS utils that can be commented out */
import '@ionic/vue/css/padding.css';
import '@ionic/vue/css/float-elements.css';
import '@ionic/vue/css/text-alignment.css';
import '@ionic/vue/css/text-transformation.css';
import '@ionic/vue/css/flex-utils.css';
import '@ionic/vue/css/display.css';
/* Theme variables */
import './theme/variables.css';
import { Capacitor } from '@capacitor/core';
import { JeepSqlite } from 'jeep-sqlite/dist/components/jeep-sqlite';
import { defineCustomElements as pwaElements} from '@ionic/pwa-elements/loader';
import SqliteService from './services/sqliteService';
import DbVersionService from './services/dbVersionService';
import StorageService from './services/storageService';
import InitializeAppService from './services/initializeAppService';
pwaElements(window);
customElements.define('jeep-sqlite', JeepSqlite);
const platform = Capacitor.getPlatform();
const app = createApp(App)
.use(IonicVue)
.use(useRouter)
.use(router);
// Set the platform as global properties on the app
app.config.globalProperties.$platform = platform;
// Define and instantiate the required services
const sqliteServ = new SqliteService();
const dbVersionServ = new DbVersionService();
const storageServ = new StorageService(sqliteServ, dbVersionServ);
// Set the services as global properties on the app
app.config.globalProperties.$sqliteServ = sqliteServ;
app.config.globalProperties.$dbVersionServ = dbVersionServ;
app.config.globalProperties.$storageServ = storageServ;
//Define and instantiate the InitializeAppService
const initAppServ = new InitializeAppService(sqliteServ, storageServ);
const mountApp = () => {
initAppServ.initializeApp()
.then(() => {
router.isReady().then(() => {
app.mount('#app');
});
})
.catch((error) => {
console.error('App Initialization error:', error);
});
}
if (platform !== "web") {
mountApp();
} else {
window.addEventListener('DOMContentLoaded', async () => {
const jeepEl = document.createElement("jeep-sqlite");
document.body.appendChild(jeepEl);
customElements.whenDefined('jeep-sqlite').then(() => {
mountApp();
})
.catch ((err) => {
console.error('jeep-sqlite creation error:', err);
});
});
}

View File

@ -0,0 +1,313 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
IonPage, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonIcon,
IonModal, IonToggle
} from '@ionic/vue';
import { addCircleSharp, closeCircleSharp, helpCircleOutline, closeOutline } from 'ionicons/icons';
import RelayMode from '@/components/RelayMode.vue';
const showHelpModal = ref(false);
</script>
<template>
<ion-page>
<ion-header :translucent="true" collapse="fade">
<ion-toolbar class="liquid-toolbar">
<ion-buttons slot="start">
<ion-back-button text="Discover" color="dark"></ion-back-button>
</ion-buttons>
<ion-title>Network Status</ion-title>
<ion-buttons slot="end">
<ion-button color="dark" @click="showHelpModal = true">
<ion-icon :icon="helpCircleOutline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true" :scroll-y="false">
<RelayMode/>
</ion-content>
</ion-page>
</template>
<style scoped>
.addlink {
width: 39px;
height: 39px;
display: flex;
align-items: center;
justify-content: center;
}
/* Ionic Toolbar */
.liquid-toolbar {
--border-color: transparent;
}
/* Ionic Content */
.liquid-content {
--background: transparent;
--padding-start: 0;
--padding-end: 0;
--padding-top: 0;
--padding-bottom: 0;
}
/* Original Container Styles */
.liquid-container {
padding: 15px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.status-item {
display: flex;
align-items: center;
margin-bottom: 15px;
background: rgba(130, 130, 130, 0.1);
padding: 10px;
border-radius: 15px;
transition: transform 0.3s ease;
}
.status-item:hover {
transform: scale(1.02);
}
.label {
font-weight: 500;
margin-right: 10px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.indicator {
display: inline-block;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
backdrop-filter: blur(5px);
}
.indicator.online,
.indicator.connected,
.indicator.relay {
background: linear-gradient(45deg, #99ff99, #66ffcc);
color: #333;
}
.indicator.offline,
.indicator.disconnected,
.indicator.direct {
background: linear-gradient(45deg, #ff6666, #ff9999);
}
.add-peer {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.add-peer input {
flex: 1;
padding: 10px;
border: none;
border-radius: 15px;
background: rgba(134, 134, 134, 0.25);
font-size: 14px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.add-peer input:hover,
.add-peer input:focus {
transform: scale(1.02);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
outline: none;
}
.peer-list {
margin-top: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.peer-list h3 {
font-size: 18px;
margin-bottom: 15px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.peer-scroll-container {
border-radius: 10px;
max-height: 390px;
overflow-y: auto;
flex: 1;
}
.peer-scroll-container::-webkit-scrollbar {
width: 8px;
}
.peer-scroll-container::-webkit-scrollbar-track {
background: rgba(125, 125, 125, 0.451);
border-radius: 10px;
}
.peer-scroll-container::-webkit-scrollbar-thumb {
background: linear-gradient(45deg, #66ccff, #99eeff);
border-radius: 10px;
}
.peer-scroll-container::-webkit-scrollbar-thumb:hover {
background: linear-gradient(45deg, #00b7ff, #66ddff);
}
.peer-item {
padding: 15px;
background: linear-gradient(135deg, rgba(127, 127, 127, 0.15), rgba(255, 255, 255, 0.05));
border-radius: 15px;
margin-bottom: 15px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.peer-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.status {
display: inline-block;
padding: 6px 12px;
border-radius: 12px;
font-size: 12px;
text-align: center;
backdrop-filter: blur(5px);
animation: pulse 2s infinite ease-in-out;
}
.status.connected {
background: linear-gradient(45deg, #99ff99, #66ffcc);
color: #333;
}
.status.disconnected {
background: linear-gradient(45deg, #ff6666, #ff9999);
}
.status.checking {
background: linear-gradient(45deg, #ffcc66, #ffdd99);
color: #333;
}
.peer-content {
margin-bottom: 15px;
}
.peer-url {
display: block;
word-break: break-all;
font-size: 14px;
line-height: 1.4;
padding: 5px 10px;
background: rgba(124, 124, 124, 0.1);
border-radius: 10px;
}
.peer-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 15px;
}
ion-toggle {
--background: rgba(128, 128, 128, 0.2);
--background-checked: #66ccff;
--handle-background: #fff;
--handle-background-checked: #fff;
width: 50px;
height: 24px;
}
.remove-icon {
color: #ff6666;
font-size: 30px;
cursor: pointer;
transition: transform 0.3s ease;
}
.remove-icon:hover {
transform: scale(1.1);
color: #ff9999;
}
.help-modal {
--border-radius: 16px;
}
.help-modal ion-toolbar {
--border-width: 0;
--background: transparent;
}
.help-content {
padding: 0 0 20px;
}
.help-content h2 {
font-size: 1.25rem;
margin: 20px 0 10px;
color: #333;
}
.help-content h3 {
font-size: 1.1rem;
margin: 15px 0 10px;
color: #333;
}
.help-content ion-list {
background: transparent;
margin-bottom: 15px;
}
.help-content ion-item {
--background: transparent;
--padding-start: 0;
--inner-padding-end: 0;
}
.help-content ion-label {
color: #666 !important;
font-size: 1rem;
}
ion-button[color="dark"] {
--background: #333;
--border-radius: 12px;
height: 44px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
ion-button[color="dark"]:hover {
--background: #444;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
</style>

View File

@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from '@ionic/vue-router'
import { createAnimation } from '@ionic/vue'
import routes from 'virtual:generated-pages'
// import { useChatFlow } from '@/composables/TalkFlowCore'
// const { isLoggedIn } = useChatFlow()
const customRoutes = [
{
path: '/',
component: () => import('@/pages/index.vue'),
}
...routes,
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: customRoutes,
})
// 导航守卫
// router.beforeEach((to, from, next) => {
// // 如果用户没有登录,且访问的不是 'i18n.vue' 页面,则重定向到 'i18n.vue'
// if (!isLoggedIn.value && to.path !== '/') {
// next('/'); // 重定向到 i18n 页面
// } else {
// next(); // 允许访问目标路由
// }
// });
// router.beforeEach(async (to, from, next) => {
// const { isLoggedIn } = await import('@/composables/TalkFlowCore');
// if (!isLoggedIn.value && to.path !== '/') {
// next('/');
// } else {
// next();
// }
// });
export default router

View File

@ -0,0 +1,19 @@
export interface IDbVersionService {
setDbVersion(dbName: string, version: number): void
getDbVersion(dbName: string):number| undefined
};
class DbVersionService implements IDbVersionService {
dbNameVersionDict: Map<string, number> = new Map();
setDbVersion(dbName: string, version: number) {
this.dbNameVersionDict.set(dbName, version);
console.log(`设置数据库 ${dbName} 版本为: ${version}`);
}
getDbVersion(dbName: string): number | undefined {
const version = this.dbNameVersionDict.get(dbName);
console.log(`获取数据库 ${dbName} 版本: ${version}`);
return version;
}
}
export default DbVersionService;

View File

@ -0,0 +1,9 @@
import SQLiteService from './sqliteService';
import DbVersionService from './dbVersionService';
import StorageService from './storageService';
const sqliteServ = new SQLiteService();
const dbVerServ = new DbVersionService();
const storageServ = new StorageService(sqliteServ, dbVerServ);
export { sqliteServ, dbVerServ, storageServ };

View File

@ -0,0 +1,45 @@
import {ISQLiteService } from '../services/sqliteService';
import {IStorageService } from '../services/storageService';
export interface IInitializeAppService {
initializeApp(): Promise<boolean>
};
class InitializeAppService implements IInitializeAppService {
appInit = false;
sqliteServ!: ISQLiteService;
storageServ!: IStorageService;
platform!: string;
static platform: string;
constructor(sqliteService: ISQLiteService, storageService: IStorageService) {
this.sqliteServ = sqliteService;
this.storageServ = storageService;
this.platform = this.sqliteServ.getPlatform();
}
async initializeApp(): Promise<boolean> {
if (!this.appInit) {
try {
console.log('开始应用初始化');
if (this.platform === 'web') {
await this.sqliteServ.initWebStore();
console.log('Web 存储初始化完成');
}
await this.storageServ.initializeDatabase();
console.log('数据库初始化完成');
// if (this.platform === 'web') {
// await this.sqliteServ.saveToStore(this.storageServ.getDatabaseName());
// console.log('数据库保存到 Web 存储完成');
// }
this.appInit = true;
console.log('应用初始化成功');
} catch (error: any) {
const msg = error.message ? error.message : error;
console.error(`initializeAppError.initializeApp: ${msg}`, error);
throw new Error(`initializeAppError.initializeApp: ${msg}`);
}
}
return this.appInit;
}
}
export default InitializeAppService;

View File

@ -0,0 +1,114 @@
import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection, capSQLiteUpgradeOptions } from '@capacitor-community/sqlite';
import { Capacitor } from '@capacitor/core';
export interface ISQLiteService {
getPlatform(): string
initWebStore(): Promise<void>
addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise<void>
openDatabase(dbName: string, loadToVersion: number, readOnly: boolean): Promise<SQLiteDBConnection>
closeDatabase(dbName: string, readOnly: boolean): Promise<void>
saveToStore(dbName: string): Promise<void>
saveToLocalDisk(dbName: string): Promise<void>
isConnection(dbName: string, readOnly: boolean): Promise<boolean>
};
class SQLiteService implements ISQLiteService {
platform = Capacitor.getPlatform();
sqlitePlugin = CapacitorSQLite;
sqliteConnection = new SQLiteConnection(CapacitorSQLite);
dbNameVersionDict: Map<string, number> = new Map();
getPlatform(): string {
return this.platform;
}
async initWebStore() : Promise<void> {
try {
await this.sqliteConnection.initWebStore();
} catch(error: any) {
const msg = error.message ? error.message : error;
throw new Error(`sqliteService.initWebStore: ${msg}`);
}
return;
}
async addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise<void> {
try {
await this.sqlitePlugin.addUpgradeStatement(options);
} catch(error: any) {
const msg = error.message ? error.message : error;
throw new Error(`sqliteService.addUpgradeStatement: ${msg}`);
}
return;
}
async openDatabase(dbName:string, loadToVersion: number,
readOnly: boolean): Promise<SQLiteDBConnection> {
this.dbNameVersionDict.set(dbName, loadToVersion);
let encrypted = false;
const mode = encrypted ? "secret" : "no-encryption";
try {
let db: SQLiteDBConnection;
const retCC = (await this.sqliteConnection.checkConnectionsConsistency()).result;
let isConn = (await this.sqliteConnection.isConnection(dbName, readOnly)).result;
if(retCC && isConn) {
db = await this.sqliteConnection.retrieveConnection(dbName, readOnly);
} else {
db = await this.sqliteConnection
.createConnection(dbName, encrypted, mode, loadToVersion, readOnly);
}
const jeepSQlEL = document.querySelector("jeep-sqlite")
await db.open();
const res = await db.isDBOpen();
return db;
} catch(error: any) {
const msg = error.message ? error.message : error;
throw new Error(`sqliteService.openDatabase: ${msg}`);
}
}
async isConnection(dbName:string, readOnly: boolean): Promise<boolean> {
try {
const isConn = (await this.sqliteConnection.isConnection(dbName, readOnly)).result;
if (isConn != undefined) {
return isConn
} else {
throw new Error(`sqliteService.isConnection undefined`);
}
} catch(error: any) {
const msg = error.message ? error.message : error;
throw new Error(`sqliteService.isConnection: ${msg}`);
}
}
async closeDatabase(dbName:string, readOnly: boolean):Promise<void> {
try {
const isConn = (await this.sqliteConnection.isConnection(dbName, readOnly)).result;
if(isConn) {
await this.sqliteConnection.closeConnection(dbName, readOnly);
}
return;
} catch(error: any) {
const msg = error.message ? error.message : error;
throw new Error(`sqliteService.closeDatabase: ${msg}`);
}
}
async saveToStore(dbName: string): Promise<void> {
try {
await this.sqliteConnection.saveToStore(dbName);
return;
} catch(error: any) {
const msg = error.message ? error.message : error;
throw new Error(`sqliteService.saveToStore: ${msg}`);
}
}
async saveToLocalDisk(dbName: string): Promise<void> {
try {
await this.sqliteConnection.saveToLocalDisk(dbName);
return;
} catch(err:any) {
const msg = err.message ? err.message : err;
throw new Error(`sqliteService.saveToLocalDisk: ${msg}`);
}
}
}
export default SQLiteService;

View File

@ -0,0 +1,153 @@
import { BehaviorSubject } from 'rxjs';
import { CapacitorSQLite, SQLiteDBConnection } from '@capacitor-community/sqlite';
import { getCurrentInstance } from 'vue';
import { ISQLiteService } from './sqliteService';
import { IDbVersionService } from './dbVersionService';
export interface IStorageService {
initializeDatabase(): Promise<void>;
query(sql: string, params?: any[]): Promise<any>;
run(sql: string, params?: any[]): Promise<any>;
execute(sql: string): Promise<any>;
}
class StorageService implements IStorageService {
versionUpgrades = [
{
toVersion: 1,
statements: [
`CREATE TABLE IF NOT EXISTS network_peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE,
is_enabled INTEGER DEFAULT 0,
note TEXT DEFAULT ''
);`,
`CREATE TABLE IF NOT EXISTS gun_nodes (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
timestamp INTEGER DEFAULT (strftime('%s', 'now'))
);`,
],
},
];
loadToVersion = 1;
momentsVersion = 2;
db!: SQLiteDBConnection;
database: string = 'gundb';
sqliteServ!: ISQLiteService;
dbVerServ!: IDbVersionService;
isInitCompleted = new BehaviorSubject(false);
appInstance = getCurrentInstance();
platform!: string;
private isInitialized = false;
constructor(sqliteService: ISQLiteService, dbVersionService: IDbVersionService) {
this.sqliteServ = sqliteService;
this.dbVerServ = dbVersionService;
this.platform = this.appInstance?.appContext.config.globalProperties.$platform || 'web';
}
async initializeDatabase(): Promise<void> {
if (this.isInitialized) {
console.log('数据库已初始化,跳过重复调用');
return;
}
try {
console.log('开始初始化数据库:', this.database);
await this.sqliteServ.addUpgradeStatement({
database: this.database,
upgrade: this.versionUpgrades,
});
console.log('核心升级语句已添加');
this.db = await this.sqliteServ.openDatabase(this.database, this.loadToVersion, false);
console.log('数据库已打开,目标版本:', this.loadToVersion);
const currentVersionResult = await this.db.getVersion();
const currentVersion: number = currentVersionResult.version ?? 0;
console.log('当前数据库版本:', currentVersion);
for (const upgrade of this.versionUpgrades) {
console.log(`执行核心升级到版本 ${upgrade.toVersion}`);
for (const stmt of upgrade.statements) {
try {
await this.db.execute(stmt);
console.log('执行语句成功:', stmt);
} catch (err) {
console.error('执行语句失败:', stmt, err);
throw err;
}
}
}
this.dbVerServ.setDbVersion(this.database, this.loadToVersion);
console.log('核心数据库版本已设置为:', this.loadToVersion);
if (this.platform === 'web') {
try {
await this.sqliteServ.saveToStore(this.database);
console.log('数据库已保存到 Web 存储');
} catch (err) {
console.warn('Web 存储保存失败(非致命错误):', err);
}
}
const tablesAfter = await this.db.query("SELECT name FROM sqlite_master WHERE type='table'");
console.log('初始化后的表:', tablesAfter.values);
this.isInitCompleted.next(true);
this.isInitialized = true;
console.log('SQLite 数据库初始化成功');
} catch (error: any) {
console.error(`storageService.initializeDatabase: ${error.message || error}`, error);
throw new Error(`storageService.initializeDatabase: ${error.message || error}`);
}
}
async query(sql: string, params: any[] = []): Promise<any> {
try {
return await this.db.query(sql, params);
} catch (err) {
console.error(`执行查询 ${sql} 失败:`, err);
throw err;
}
}
async run(sql: string, params: any[] = []): Promise<any> {
try {
return await this.db.run(sql, params);
} catch (err) {
console.error(`执行语句 ${sql} 失败:`, err);
throw err;
}
}
async execute(sql: string): Promise<any> {
try {
return await this.db.execute(sql);
} catch (err) {
console.error(`执行语句 ${sql} 失败:`, err);
throw err;
}
}
}
export default StorageService;

View File

@ -0,0 +1,237 @@
/* Ionic Variables and Theming. For more info, please see:
http://ionicframework.com/docs/theming/ */
/** Ionic CSS Variables **/
:root {
--ion-background-color: #ffffff;
/** primary **/
--ion-color-primary: #3880ff;
--ion-color-primary-rgb: 56, 128, 255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #3171e0;
--ion-color-primary-tint: #4c8dff;
/** secondary **/
--ion-color-secondary: #3dc2ff;
--ion-color-secondary-rgb: 61, 194, 255;
--ion-color-secondary-contrast: #e1dfdf;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #36abe0;
--ion-color-secondary-tint: #50c8ff;
/** tertiary **/
--ion-color-tertiary: #5260ff;
--ion-color-tertiary-rgb: 82, 96, 255;
--ion-color-tertiary-contrast: #e3e3e3;
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #4854e0;
--ion-color-tertiary-tint: #6370ff;
/** success **/
--ion-color-success: #2dd36f;
--ion-color-success-rgb: 45, 211, 111;
--ion-color-success-contrast: #ededed;
--ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #28ba62;
--ion-color-success-tint: #42d77d;
/** warning #222428 **/
--ion-color-warning: #ffc409;
--ion-color-warning-rgb: 255, 196, 9;
--ion-color-warning-contrast: #222428;
--ion-color-warning-contrast-rgb: 0, 0, 0;
--ion-color-warning-shade: #e0ac08;
--ion-color-warning-tint: #ffca22;
/** danger **/
--ion-color-danger: #eb445a;
--ion-color-danger-rgb: 235, 68, 90;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #cf3c4f;
--ion-color-danger-tint: #ed576b;
/** dark #000000 **/
--ion-color-dark: #000000;
--ion-color-dark-rgb: 34, 36, 40;
--ion-color-dark-contrast: #e1e1e1;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
/** medium **/
--ion-color-medium: #92949c;
--ion-color-medium-rgb: 146, 148, 156;
--ion-color-medium-contrast: #e6e6e6;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #808289;
--ion-color-medium-tint: #9d9fa6;
/** light **/
--ion-color-light: #e8e8e8;
--ion-color-light-rgb: 244, 245, 248;
--ion-color-light-contrast: #000000;
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
}
@media (prefers-color-scheme: dark) {
/*
* Dark Colors
* -------------------------------------------
*/
body {
--ion-color-primary: #428cff;
--ion-color-primary-rgb: 66,140,255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255,255,255;
--ion-color-primary-shade: #3a7be0;
--ion-color-primary-tint: #5598ff;
--ion-color-secondary: #50c8ff;
--ion-color-secondary-rgb: 80,200,255;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255,255,255;
--ion-color-secondary-shade: #46b0e0;
--ion-color-secondary-tint: #62ceff;
--ion-color-tertiary: #6a64ff;
--ion-color-tertiary-rgb: 106,100,255;
--ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 255,255,255;
--ion-color-tertiary-shade: #5d58e0;
--ion-color-tertiary-tint: #7974ff;
--ion-color-success: #2fdf75;
--ion-color-success-rgb: 47,223,117;
--ion-color-success-contrast: #000000;
--ion-color-success-contrast-rgb: 0,0,0;
--ion-color-success-shade: #29c467;
--ion-color-success-tint: #44e283;
--ion-color-warning: #ffd534;
--ion-color-warning-rgb: 255,213,52;
--ion-color-warning-contrast: #000000;
--ion-color-warning-contrast-rgb: 0,0,0;
--ion-color-warning-shade: #e0bb2e;
--ion-color-warning-tint: #ffd948;
--ion-color-danger: #ff4961;
--ion-color-danger-rgb: 255,73,97;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255,255,255;
--ion-color-danger-shade: #e04055;
--ion-color-danger-tint: #ff5b71;
--ion-color-dark: #dfdfdf;
--ion-color-dark-rgb: 244,245,248;
--ion-color-dark-contrast: #141414;
--ion-color-dark-contrast-rgb: 0,0,0;
--ion-color-dark-shade: #b5b5b5;
--ion-color-dark-tint: #dedfe0;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152,154,162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0,0,0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-light: #222428;
--ion-color-light-rgb: 34,36,40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255,255,255;
--ion-color-light-shade: #1e2023;
--ion-color-light-tint: #383a3e;
}
/*
* iOS Dark Theme #111111
* -------------------------------------------
*/
.ios body {
--ion-background-color: #000000;
--ion-background-color-rgb: 0,0,0;
--ion-text-color: #bfcbc7;
--ion-text-color-rgb: 255,255,255;
--ion-color-step-50: #0d0d0d;
--ion-color-step-100: #1a1a1a;
--ion-color-step-150: #262626;
--ion-color-step-200: #333333;
--ion-color-step-250: #404040;
--ion-color-step-300: #4d4d4d;
--ion-color-step-350: #595959;
--ion-color-step-400: #666666;
--ion-color-step-450: #737373;
--ion-color-step-500: #808080;
--ion-color-step-550: #8c8c8c;
--ion-color-step-600: #999999;
--ion-color-step-650: #a6a6a6;
--ion-color-step-700: #b3b3b3;
--ion-color-step-750: #bfbfbf;
--ion-color-step-800: #cccccc;
--ion-color-step-850: #d9d9d9;
--ion-color-step-900: #e6e6e6;
--ion-color-step-950: #f2f2f2;
--ion-item-background: #000000;
--ion-card-background: #1c1c1d;
}
.ios ion-modal {
--ion-background-color: var(--ion-color-step-50);
--ion-toolbar-background: var(--ion-color-step-100);
--ion-toolbar-border-color: var(--ion-color-step-150);
}
/*
* Material Design Dark Theme
* -------------------------------------------
*/
.md body {
--ion-background-color: #000000;
--ion-background-color-rgb: 18,18,18;
--ion-text-color: #dfdede;
--ion-text-color-rgb: 255,255,255;
--ion-border-color: #222222;
--ion-color-step-50: #1e1e1e;
--ion-color-step-100: #2a2a2a;
--ion-color-step-150: #363636;
--ion-color-step-200: #414141;
--ion-color-step-250: #4d4d4d;
--ion-color-step-300: #595959;
--ion-color-step-350: #656565;
--ion-color-step-400: #717171;
--ion-color-step-450: #7d7d7d;
--ion-color-step-500: #898989;
--ion-color-step-550: #949494;
--ion-color-step-600: #a0a0a0;
--ion-color-step-650: #acacac;
--ion-color-step-700: #b8b8b8;
--ion-color-step-750: #c4c4c4;
--ion-color-step-800: #d0d0d0;
--ion-color-step-850: #dbdbdb;
--ion-color-step-900: #e7e7e7;
--ion-color-step-950: #f3f3f3;
--ion-item-background: #000000;
--ion-toolbar-background: #000000;
--ion-tab-bar-background: #000000;
--ion-card-background: #1e1e1e;
}
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"],
"bare-plugin": ["./bare-plugin/dist/plugin"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,73 @@
import legacy from '@vitejs/plugin-legacy'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Pages from 'vite-plugin-pages'
import { VitePWA } from 'vite-plugin-pwa'
// import inject from "@rollup/plugin-inject";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
legacy(),
Components(),
// inject({
// Buffer: ["buffer", "Buffer"],
// }),
AutoImport({
imports: ['vue', 'vue-router'],
dirs: [
'src/composables',
],
dts: 'src/auto-imports.d.ts',
}),
Pages(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 5000 * 1024 * 1024,
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
crypto: 'crypto-browserify',
buffer: "buffer",
},
},
define: {
global: "window",
},
build: {
sourcemap: true,
rollupOptions: {
external: ['text-encoding'],
output:{
manualChunks(id) {
if (id.includes('node_modules')) {
return id.toString().split('node_modules/')[1].split('/')[0].toString();
}
}
}
}
},
// test: {
// globals: true,
// environment: 'jsdom'
// }
})