@hostlink/nuxt-light 1.37.1 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -0
- package/dist/runtime/components/l-app-main.vue +1 -0
- package/dist/runtime/components/l-editor.vue +4 -4
- package/dist/runtime/components/l-input.vue +1 -0
- package/dist/runtime/components/l-login.vue +1 -1
- package/dist/runtime/composables/useLight.d.ts +9 -5
- package/dist/runtime/composables/useLight.js +4 -0
- package/dist/runtime/composables/useRoles.d.ts +7 -0
- package/dist/runtime/composables/useRoles.js +39 -0
- package/dist/runtime/composables/useWebAuthn.d.ts +5 -0
- package/dist/runtime/composables/useWebAuthn.js +5 -0
- package/dist/runtime/pages/Permission/all.vue +175 -48
- package/dist/runtime/pages/System/database/backup.vue +113 -5
- package/dist/runtime/pages/System/database/restore.vue +207 -0
- package/dist/runtime/pages/System/database/restore.vue.d.ts +2 -0
- package/dist/runtime/pages/System/database/table.vue +2 -0
- package/dist/runtime/pages/User/setting/open_id.vue +160 -70
- package/dist/runtime/pages/User/setting/password.vue +3 -2
- package/dist/runtime/pages/User/setting/two-factor-auth.vue +213 -47
- package/package.json +9 -9
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -136,6 +136,11 @@ const routes = [
|
|
|
136
136
|
path: "/System/database/process",
|
|
137
137
|
file: "runtime/pages/System/database/process.vue"
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
name: "System-database-restore",
|
|
141
|
+
path: "/System/database/restore",
|
|
142
|
+
file: "runtime/pages/System/database/restore.vue"
|
|
143
|
+
},
|
|
139
144
|
{
|
|
140
145
|
name: "System-database-backup",
|
|
141
146
|
path: "/System/database/backup",
|
|
@@ -55,6 +55,7 @@ let my = reactive(tt.my);
|
|
|
55
55
|
const light = useLight();
|
|
56
56
|
light.devMode = tt.system.devMode;
|
|
57
57
|
light.init(my.styles);
|
|
58
|
+
light.setMyRoles(my.roles);
|
|
58
59
|
light.setPermissions(my.permissions);
|
|
59
60
|
light.setMyFavorites(toRaw(my.myFavorites));
|
|
60
61
|
const myFavorites = computed(() => {
|
|
@@ -7,20 +7,20 @@ const textColorRef = ref(null);
|
|
|
7
7
|
const textHightlightRef = ref(null);
|
|
8
8
|
const highlight = ref("#ffff00aa");
|
|
9
9
|
const foreColor = ref("#000000");
|
|
10
|
-
const TextColorCMD = (cmd, name) => {
|
|
10
|
+
const TextColorCMD = ((cmd, name) => {
|
|
11
11
|
const edit = editorRef.value;
|
|
12
12
|
textColorRef.value.hide();
|
|
13
13
|
edit.caret.restore();
|
|
14
14
|
edit.runCmd(cmd, name);
|
|
15
15
|
edit.focus();
|
|
16
|
-
};
|
|
17
|
-
const TextHightlightCMD = (cmd, name) => {
|
|
16
|
+
});
|
|
17
|
+
const TextHightlightCMD = ((cmd, name) => {
|
|
18
18
|
const edit = editorRef.value;
|
|
19
19
|
textHightlightRef.value.hide();
|
|
20
20
|
edit.caret.restore();
|
|
21
21
|
edit.runCmd(cmd, name);
|
|
22
22
|
edit.focus();
|
|
23
|
-
};
|
|
23
|
+
});
|
|
24
24
|
const emit = defineEmits(["update:modelValue"]);
|
|
25
25
|
const props = defineProps({
|
|
26
26
|
fullscreen: { type: Boolean, required: false, skipCheck: true },
|
|
@@ -12,6 +12,7 @@ const props = defineProps({
|
|
|
12
12
|
fillMask: { type: [Boolean, String], required: false, skipCheck: true },
|
|
13
13
|
reverseFillMask: { type: Boolean, required: false, skipCheck: true },
|
|
14
14
|
unmaskedValue: { type: Boolean, required: false, skipCheck: true },
|
|
15
|
+
maskTokens: { type: null, required: false },
|
|
15
16
|
modelValue: { type: null, required: true },
|
|
16
17
|
error: { type: [Boolean, null], required: false, skipCheck: true },
|
|
17
18
|
errorMessage: { type: null, required: false },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, reactive, onMounted, resolveComponent } from "vue";
|
|
3
3
|
import { useQuasar } from "quasar";
|
|
4
|
-
import { api, useHead
|
|
4
|
+
import { api, useHead } from "#imports";
|
|
5
5
|
import { useI18n } from "vue-i18n";
|
|
6
6
|
const { t } = useI18n();
|
|
7
7
|
const emits = defineEmits(["login"]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QDialogOptions, QNotifyCreateOptions
|
|
1
|
+
import type { QDialogOptions, QNotifyCreateOptions } from 'quasar';
|
|
2
2
|
declare module 'vue' {
|
|
3
3
|
interface ComponentCustomProperties {
|
|
4
4
|
$light: typeof light;
|
|
@@ -99,7 +99,7 @@ declare const light: {
|
|
|
99
99
|
increment: (amount?: number) => void;
|
|
100
100
|
setDefaults: (props: import("quasar").QLoadingBarOptions) => void;
|
|
101
101
|
};
|
|
102
|
-
notify: (opts: QNotifyCreateOptions | string) => (props?: QNotifyUpdateOptions) => void;
|
|
102
|
+
notify: (opts: QNotifyCreateOptions | string) => (props?: import("quasar").QNotifyUpdateOptions) => void;
|
|
103
103
|
platform: {
|
|
104
104
|
userAgent: string;
|
|
105
105
|
is: {
|
|
@@ -589,8 +589,9 @@ declare const light: {
|
|
|
589
589
|
isAdmin: boolean;
|
|
590
590
|
permissions: string[];
|
|
591
591
|
myFavorites: any[];
|
|
592
|
+
myRoles: string[];
|
|
592
593
|
$t: any;
|
|
593
|
-
notify: (opts: QNotifyCreateOptions | string) => (props?: QNotifyUpdateOptions) => void;
|
|
594
|
+
notify: (opts: QNotifyCreateOptions | string) => (props?: import("quasar").QNotifyUpdateOptions) => void;
|
|
594
595
|
dialog: (opts: QDialogOptions) => import("quasar").DialogChainObject;
|
|
595
596
|
users: {
|
|
596
597
|
create: (user: User) => Promise<User>;
|
|
@@ -603,6 +604,7 @@ declare const light: {
|
|
|
603
604
|
};
|
|
604
605
|
getColorValue: () => string;
|
|
605
606
|
setMyFavorites: (favorites: Array<any>) => void;
|
|
607
|
+
setMyRoles: (roles: Array<string>) => void;
|
|
606
608
|
reloadMyFavorites: () => Promise<void>;
|
|
607
609
|
getMyFavorites: () => any[];
|
|
608
610
|
isDarkMode: () => boolean;
|
|
@@ -703,7 +705,7 @@ declare const _default: () => {
|
|
|
703
705
|
increment: (amount?: number) => void;
|
|
704
706
|
setDefaults: (props: import("quasar").QLoadingBarOptions) => void;
|
|
705
707
|
};
|
|
706
|
-
notify: (opts: QNotifyCreateOptions | string) => (props?: QNotifyUpdateOptions) => void;
|
|
708
|
+
notify: (opts: QNotifyCreateOptions | string) => (props?: import("quasar").QNotifyUpdateOptions) => void;
|
|
707
709
|
platform: {
|
|
708
710
|
userAgent: string;
|
|
709
711
|
is: {
|
|
@@ -1193,8 +1195,9 @@ declare const _default: () => {
|
|
|
1193
1195
|
isAdmin: boolean;
|
|
1194
1196
|
permissions: string[];
|
|
1195
1197
|
myFavorites: any[];
|
|
1198
|
+
myRoles: string[];
|
|
1196
1199
|
$t: any;
|
|
1197
|
-
notify: (opts: QNotifyCreateOptions | string) => (props?: QNotifyUpdateOptions) => void;
|
|
1200
|
+
notify: (opts: QNotifyCreateOptions | string) => (props?: import("quasar").QNotifyUpdateOptions) => void;
|
|
1198
1201
|
dialog: (opts: QDialogOptions) => import("quasar").DialogChainObject;
|
|
1199
1202
|
users: {
|
|
1200
1203
|
create: (user: User) => Promise<User>;
|
|
@@ -1207,6 +1210,7 @@ declare const _default: () => {
|
|
|
1207
1210
|
};
|
|
1208
1211
|
getColorValue: () => string;
|
|
1209
1212
|
setMyFavorites: (favorites: Array<any>) => void;
|
|
1213
|
+
setMyRoles: (roles: Array<string>) => void;
|
|
1210
1214
|
reloadMyFavorites: () => Promise<void>;
|
|
1211
1215
|
getMyFavorites: () => any[];
|
|
1212
1216
|
isDarkMode: () => boolean;
|
|
@@ -74,6 +74,7 @@ const light = reactive({
|
|
|
74
74
|
isAdmin: false,
|
|
75
75
|
permissions: Array(),
|
|
76
76
|
myFavorites: Array(),
|
|
77
|
+
myRoles: Array(),
|
|
77
78
|
$t: null,
|
|
78
79
|
notify: (opts) => {
|
|
79
80
|
if (light.$q == null) {
|
|
@@ -158,6 +159,9 @@ const light = reactive({
|
|
|
158
159
|
setMyFavorites: (favorites) => {
|
|
159
160
|
light.myFavorites = favorites;
|
|
160
161
|
},
|
|
162
|
+
setMyRoles: (roles) => {
|
|
163
|
+
light.myRoles = roles;
|
|
164
|
+
},
|
|
161
165
|
reloadMyFavorites: async () => {
|
|
162
166
|
const data = await q({
|
|
163
167
|
my: {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import useLight from "./useLight.js";
|
|
3
|
+
import q from "./q.js";
|
|
4
|
+
export default function useRoles() {
|
|
5
|
+
const light = useLight();
|
|
6
|
+
const roles = computed(() => light.myRoles || []);
|
|
7
|
+
function hasRole(role) {
|
|
8
|
+
if (!role) return false;
|
|
9
|
+
if (light.isAdmin) return true;
|
|
10
|
+
return light.myRoles.includes(role);
|
|
11
|
+
}
|
|
12
|
+
function hasAny(...args) {
|
|
13
|
+
const list = Array.isArray(args[0]) ? args[0] : args;
|
|
14
|
+
if (light.isAdmin) return true;
|
|
15
|
+
return list.some((r) => light.myRoles.includes(r));
|
|
16
|
+
}
|
|
17
|
+
function hasAll(wanted = []) {
|
|
18
|
+
if (light.isAdmin) return true;
|
|
19
|
+
return wanted.every((r) => light.myRoles.includes(r));
|
|
20
|
+
}
|
|
21
|
+
async function reload() {
|
|
22
|
+
const data = await q({
|
|
23
|
+
my: {
|
|
24
|
+
roles
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const newRoles = (data?.my?.roles || []).map((r) => r.role || r);
|
|
28
|
+
light.myRoles = newRoles;
|
|
29
|
+
light.isAdmin = newRoles.includes("Administrators");
|
|
30
|
+
return newRoles;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
roles,
|
|
34
|
+
hasRole,
|
|
35
|
+
hasAny,
|
|
36
|
+
hasAll,
|
|
37
|
+
reload
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -2,78 +2,205 @@
|
|
|
2
2
|
import { computed, ref } from "vue";
|
|
3
3
|
import { m, api } from "#imports";
|
|
4
4
|
import { useI18n } from "vue-i18n";
|
|
5
|
+
import { useQuasar } from "quasar";
|
|
5
6
|
const { t } = useI18n();
|
|
7
|
+
const $q = useQuasar();
|
|
8
|
+
const loading = ref(true);
|
|
9
|
+
const updating = ref(/* @__PURE__ */ new Set());
|
|
6
10
|
const fetchApp = async () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
try {
|
|
12
|
+
loading.value = true;
|
|
13
|
+
const { app: app2 } = await api.query({
|
|
14
|
+
app: {
|
|
15
|
+
permissions: true,
|
|
16
|
+
roles: {
|
|
17
|
+
name: true,
|
|
18
|
+
permissions: true
|
|
19
|
+
}
|
|
13
20
|
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
});
|
|
22
|
+
return app2;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
$q.notify({
|
|
25
|
+
type: "negative",
|
|
26
|
+
message: t("Failed to load permissions")
|
|
27
|
+
});
|
|
28
|
+
throw error;
|
|
29
|
+
} finally {
|
|
30
|
+
loading.value = false;
|
|
31
|
+
}
|
|
17
32
|
};
|
|
18
33
|
const app = ref(await fetchApp());
|
|
19
|
-
const roles = computed(() => app.value
|
|
34
|
+
const roles = computed(() => app.value?.roles || []);
|
|
20
35
|
const columns = computed(() => [
|
|
21
|
-
{
|
|
36
|
+
{
|
|
37
|
+
label: t("Permission"),
|
|
38
|
+
field: "permission",
|
|
39
|
+
align: "left",
|
|
40
|
+
style: "font-weight: bold; background-color: rgba(0,0,0,0.02);",
|
|
41
|
+
headerStyle: "font-weight: bold;"
|
|
42
|
+
},
|
|
22
43
|
...roles.value.map((role) => ({
|
|
23
44
|
label: role.name,
|
|
24
45
|
field: role.name,
|
|
25
|
-
align: "
|
|
46
|
+
align: "center",
|
|
47
|
+
headerStyle: "font-weight: bold;"
|
|
26
48
|
}))
|
|
27
49
|
]);
|
|
28
|
-
const rows = computed(
|
|
29
|
-
(
|
|
50
|
+
const rows = computed(() => {
|
|
51
|
+
if (!app.value?.permissions) return [];
|
|
52
|
+
return app.value.permissions.map((permission) => {
|
|
30
53
|
const row = { permission };
|
|
31
54
|
roles.value.forEach((role) => {
|
|
32
55
|
row[role.name] = role.permissions.includes(permission);
|
|
33
56
|
});
|
|
34
57
|
return row;
|
|
35
|
-
})
|
|
36
|
-
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
37
60
|
const onUpdate = async (value, role, permission) => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
const updateKey = `${role}-${permission}`;
|
|
62
|
+
updating.value.add(updateKey);
|
|
63
|
+
try {
|
|
64
|
+
if (value) {
|
|
65
|
+
await m("addPermission", { value: permission, role });
|
|
66
|
+
$q.notify({
|
|
67
|
+
type: "positive",
|
|
68
|
+
message: t("Permission granted successfully")
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
await m("removePermission", { value: permission, role });
|
|
72
|
+
$q.notify({
|
|
73
|
+
type: "positive",
|
|
74
|
+
message: t("Permission removed successfully")
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
app.value = await fetchApp();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
$q.notify({
|
|
80
|
+
type: "negative",
|
|
81
|
+
message: t("Failed to update permission")
|
|
82
|
+
});
|
|
83
|
+
app.value = await fetchApp();
|
|
84
|
+
} finally {
|
|
85
|
+
updating.value.delete(updateKey);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const toggleAllForRole = async (role, grant) => {
|
|
89
|
+
const updates = [];
|
|
90
|
+
for (const permission of app.value.permissions) {
|
|
91
|
+
const currentState = role.permissions.includes(permission);
|
|
92
|
+
if (currentState !== grant) {
|
|
93
|
+
updates.push({ permission, role: role.name, grant });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (updates.length === 0) return;
|
|
97
|
+
try {
|
|
98
|
+
loading.value = true;
|
|
99
|
+
for (const update of updates) {
|
|
100
|
+
if (update.grant) {
|
|
101
|
+
await m("addPermission", { value: update.permission, role: update.role });
|
|
102
|
+
} else {
|
|
103
|
+
await m("removePermission", { value: update.permission, role: update.role });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
app.value = await fetchApp();
|
|
107
|
+
$q.notify({
|
|
108
|
+
type: "positive",
|
|
109
|
+
message: t(grant ? "All permissions granted" : "All permissions removed")
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
$q.notify({
|
|
113
|
+
type: "negative",
|
|
114
|
+
message: t("Failed to update permissions")
|
|
115
|
+
});
|
|
116
|
+
} finally {
|
|
117
|
+
loading.value = false;
|
|
42
118
|
}
|
|
43
|
-
app.value = await fetchApp();
|
|
44
119
|
};
|
|
45
120
|
const filter = ref("");
|
|
121
|
+
const selectedRole = ref("");
|
|
46
122
|
const filteredRows = computed(() => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
123
|
+
let filtered = rows.value;
|
|
124
|
+
if (filter.value) {
|
|
125
|
+
filtered = filtered.filter(
|
|
126
|
+
(row) => row.permission.toLowerCase().includes(filter.value.toLowerCase())
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (selectedRole.value) {
|
|
130
|
+
filtered = filtered.filter((row) => row[selectedRole.value]);
|
|
131
|
+
}
|
|
132
|
+
return filtered;
|
|
51
133
|
});
|
|
52
134
|
</script>
|
|
53
135
|
|
|
54
136
|
<template>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
137
|
+
<l-page>
|
|
138
|
+
|
|
139
|
+
<!-- 載入指示器 -->
|
|
140
|
+
<div v-if="loading" class="text-center q-pa-md">
|
|
141
|
+
<q-spinner-dots size="50px" :color="$light.color" />
|
|
142
|
+
<div class="q-mt-sm">{{ $t('Loading permissions...') }}</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- 主要內容 -->
|
|
146
|
+
<div v-else>
|
|
147
|
+
<!-- 篩選控制 -->
|
|
148
|
+
<div class="row q-gutter-md q-mb-md">
|
|
149
|
+
<div class="col-md-6 col-12">
|
|
150
|
+
<q-input :color="$light.color" v-model="filter" :placeholder="$t('Filter permissions')" dense clearable
|
|
151
|
+
outlined>
|
|
152
|
+
<template v-slot:append>
|
|
153
|
+
<q-icon name="sym_o_search" />
|
|
64
154
|
</template>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
155
|
+
</q-input>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="col-md-6 col-12">
|
|
158
|
+
<q-select :color="$light.color" v-model="selectedRole"
|
|
159
|
+
:options="[{ label: $t('All Roles'), value: '' }, ...roles.map(r => ({ label: r.name, value: r.name }))]"
|
|
160
|
+
:placeholder="$t('Filter by role')" dense clearable outlined emit-value map-options />
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<!-- 權限表格 -->
|
|
165
|
+
<q-table :columns="columns" flat bordered :rows="filteredRows" :pagination="{ rowsPerPage: 0 }" dense
|
|
166
|
+
class="permission-table">
|
|
167
|
+
|
|
168
|
+
<template #body="props">
|
|
169
|
+
<q-tr :props="props">
|
|
170
|
+
<q-td :style="columns[0].style" class="text-weight-medium">
|
|
171
|
+
<q-icon name="security" class="q-mr-sm text-grey-6" />
|
|
172
|
+
{{ props.row.permission }}
|
|
173
|
+
</q-td>
|
|
174
|
+
<q-td v-for="role in roles" :key="role.name" class="text-center">
|
|
175
|
+
<q-checkbox v-model="props.row[role.name]"
|
|
176
|
+
@update:model-value="onUpdate($event, role.name, props.row.permission)" :color="$light.color"
|
|
177
|
+
:loading="updating.has(`${role.name}-${props.row.permission}`)"
|
|
178
|
+
:disable="updating.has(`${role.name}-${props.row.permission}`)" />
|
|
179
|
+
</q-td>
|
|
180
|
+
</q-tr>
|
|
181
|
+
</template>
|
|
182
|
+
|
|
183
|
+
<template #no-data>
|
|
184
|
+
<div class="full-width text-center q-pa-lg">
|
|
185
|
+
<q-icon name="search_off" size="48px" class="text-grey-5" />
|
|
186
|
+
<div class="text-h6 text-grey-7 q-mt-md">
|
|
187
|
+
{{ $t('No permissions found') }}
|
|
188
|
+
</div>
|
|
189
|
+
<div class="text-body2 text-grey-6">
|
|
190
|
+
{{ $t('Try adjusting your search filters') }}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</template>
|
|
194
|
+
</q-table>
|
|
195
|
+
|
|
196
|
+
<!-- 結果統計 -->
|
|
197
|
+
<div class="q-mt-md text-caption text-grey-7">
|
|
198
|
+
{{ $t('Showing') }} {{ filteredRows.length }} {{ $t('of') }} {{ rows.length }} {{ $t('permissions') }}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</l-page>
|
|
79
202
|
</template>
|
|
203
|
+
|
|
204
|
+
<style scoped>
|
|
205
|
+
.permission-table{box-shadow:0 2px 8px rgba(0,0,0,.1)}.permission-table :deep(.q-table__top){background-color:rgba(0,0,0,.02);padding:16px}.permission-table :deep(thead tr th){background-color:rgba(0,0,0,.05);font-weight:700}.permission-table :deep(tbody tr:hover){background-color:rgba(0,0,0,.03)}.permission-table :deep(tbody tr td:first-child){border-right:2px solid rgba(0,0,0,.1)}
|
|
206
|
+
</style>
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { q } from "#imports";
|
|
3
3
|
import { Loading, Notify } from "quasar";
|
|
4
|
+
import { ref, onMounted } from "vue";
|
|
5
|
+
const isDownloading = ref(false);
|
|
6
|
+
const databaseInfo = ref(null);
|
|
7
|
+
const fetchDatabaseInfo = async () => {
|
|
8
|
+
try {
|
|
9
|
+
const info = await q({
|
|
10
|
+
system: {
|
|
11
|
+
database: {
|
|
12
|
+
type: true,
|
|
13
|
+
sizeBytes: true,
|
|
14
|
+
version: true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
databaseInfo.value = info.system.database;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.error("Failed to fetch database info:", e);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
4
23
|
const onClickDownload = async () => {
|
|
5
|
-
|
|
24
|
+
isDownloading.value = true;
|
|
25
|
+
Loading.show({ message: "\u6B63\u5728\u532F\u51FA\u8CC7\u6599\u5EAB\uFF0C\u8ACB\u7A0D\u5019..." });
|
|
6
26
|
try {
|
|
7
27
|
const data = await q({
|
|
8
28
|
system: {
|
|
@@ -11,23 +31,111 @@ const onClickDownload = async () => {
|
|
|
11
31
|
}
|
|
12
32
|
}
|
|
13
33
|
});
|
|
34
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/:/g, "-");
|
|
35
|
+
const filename = `database-backup-${timestamp}.sql`;
|
|
14
36
|
const blob = new Blob([data.system.database.export], { type: "text/plain;charset=utf-8" });
|
|
15
37
|
const url = URL.createObjectURL(blob);
|
|
16
38
|
const a = document.createElement("a");
|
|
17
|
-
a.download =
|
|
39
|
+
a.download = filename;
|
|
18
40
|
a.href = url;
|
|
19
41
|
a.click();
|
|
20
42
|
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
43
|
+
Notify.create({
|
|
44
|
+
type: "positive",
|
|
45
|
+
message: "\u8CC7\u6599\u5EAB\u5099\u4EFD\u5DF2\u6210\u529F\u4E0B\u8F09\uFF01",
|
|
46
|
+
position: "top"
|
|
47
|
+
});
|
|
21
48
|
} catch (e) {
|
|
22
|
-
Notify.create({
|
|
49
|
+
Notify.create({
|
|
50
|
+
type: "negative",
|
|
51
|
+
message: "\u4E0B\u8F09\u5931\u6557\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66\u3002",
|
|
52
|
+
position: "top"
|
|
53
|
+
});
|
|
23
54
|
} finally {
|
|
24
55
|
Loading.hide();
|
|
56
|
+
isDownloading.value = false;
|
|
25
57
|
}
|
|
26
58
|
};
|
|
59
|
+
onMounted(() => {
|
|
60
|
+
fetchDatabaseInfo();
|
|
61
|
+
});
|
|
27
62
|
</script>
|
|
28
63
|
|
|
29
64
|
<template>
|
|
30
|
-
<l-page title="
|
|
31
|
-
<
|
|
65
|
+
<l-page title="資料庫備份">
|
|
66
|
+
<div class="backup-container">
|
|
67
|
+
<!-- 頁面說明 -->
|
|
68
|
+
<l-card class="info-card">
|
|
69
|
+
<div class="card-header">
|
|
70
|
+
<h5 class="card-title">
|
|
71
|
+
<q-icon name="sym_o_info" class="text-primary" />
|
|
72
|
+
備份說明
|
|
73
|
+
</h5>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="card-content">
|
|
76
|
+
<p class="description">
|
|
77
|
+
資料庫備份功能可以將整個資料庫匯出為 SQL 檔案,包含所有資料表結構和資料。
|
|
78
|
+
建議定期進行備份以確保資料安全。
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
</l-card>
|
|
82
|
+
|
|
83
|
+
<!-- 備份資訊 -->
|
|
84
|
+
<l-card class="status-card">
|
|
85
|
+
<div class="card-header">
|
|
86
|
+
<h5 class="card-title">
|
|
87
|
+
<q-icon name="sym_o_database" class="text-secondary" />
|
|
88
|
+
資料庫資訊
|
|
89
|
+
</h5>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="card-content">
|
|
92
|
+
<div class="info-grid">
|
|
93
|
+
<div class="info-item" v-if="databaseInfo">
|
|
94
|
+
<q-icon name="sym_o_storage" class="info-icon" />
|
|
95
|
+
<div class="info-content">
|
|
96
|
+
<span class="info-label">資料庫類型</span>
|
|
97
|
+
<span class="info-value">{{ databaseInfo.type || '未知' }}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="info-item" v-if="databaseInfo">
|
|
101
|
+
<q-icon name="sym_o_storage" class="info-icon" />
|
|
102
|
+
<div class="info-content">
|
|
103
|
+
<span class="info-label">資料庫大小</span>
|
|
104
|
+
<span class="info-value">{{ databaseInfo.sizeBytes ? (databaseInfo.sizeBytes / 1024 /
|
|
105
|
+
1024).toFixed(2) + ' MB' : '計算中...' }}</span>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="info-item" v-if="databaseInfo && databaseInfo.version">
|
|
109
|
+
<q-icon name="sym_o_info" class="info-icon" />
|
|
110
|
+
<div class="info-content">
|
|
111
|
+
<span class="info-label">資料庫版本</span>
|
|
112
|
+
<span class="info-value">{{ databaseInfo.version }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</l-card>
|
|
118
|
+
|
|
119
|
+
<!-- 安全提醒 -->
|
|
120
|
+
<l-alert type="warning" class="security-alert">
|
|
121
|
+
<template #prefix>
|
|
122
|
+
<q-icon name="sym_o_security" />
|
|
123
|
+
</template>
|
|
124
|
+
<strong>安全提醒:</strong>備份檔案包含敏感資料,請妥善保管並避免在不安全的環境中傳輸。
|
|
125
|
+
</l-alert>
|
|
126
|
+
|
|
127
|
+
<!-- 下載按鈕區域 -->
|
|
128
|
+
<div class="download-section">
|
|
129
|
+
<l-btn :label="isDownloading ? '正在備份中...' : '開始備份'" icon="sym_o_download" color="primary" size="lg"
|
|
130
|
+
:loading="isDownloading" :disable="isDownloading" @click="onClickDownload" class="download-btn" />
|
|
131
|
+
<p class="download-hint">
|
|
132
|
+
點擊按鈕將會下載包含完整資料庫內容的 SQL 檔案
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
32
136
|
</l-page>
|
|
33
137
|
</template>
|
|
138
|
+
|
|
139
|
+
<style scoped>
|
|
140
|
+
.backup-container{display:flex;flex-direction:column;gap:24px;margin:0 auto;max-width:800px;padding:20px}.info-card,.status-card{border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.1)}.card-header{padding:20px 24px 0}.card-title{align-items:center;color:#2c3e50;display:flex;font-weight:600;gap:8px;margin:0}.card-content{padding:16px 24px 24px}.description{color:#5a6c7d;line-height:1.6;margin:0}.info-grid{display:grid;gap:16px}.info-item{align-items:center;background:#f8f9fa;border-radius:8px;display:flex;gap:12px;padding:12px}.info-icon{color:#6c757d;font-size:24px}.info-content{display:flex;flex-direction:column;gap:4px}.info-label{color:#6c757d;font-size:14px;font-weight:500}.info-value{color:#2c3e50;font-size:16px;font-weight:600}.security-alert{border-radius:8px}.download-section{background:linear-gradient(135deg,#f8f9fa,#e9ecef);border:2px dashed #dee2e6;border-radius:16px;padding:32px 24px;text-align:center}.download-btn{border-radius:8px;box-shadow:0 4px 12px rgba(0,123,255,.3);font-size:16px;font-weight:600;height:48px;margin-bottom:12px;min-width:200px;transition:all .3s ease}.download-btn:hover{box-shadow:0 6px 20px rgba(0,123,255,.4);transform:translateY(-2px)}.download-hint{color:#6c757d;font-size:14px;margin:0 auto;max-width:400px}@media (max-width:768px){.backup-container{gap:20px;padding:16px}.card-content{padding:12px 20px 20px}.download-section{padding:24px 20px}.download-btn{min-width:100%}}
|
|
141
|
+
</style>
|