@hostlink/nuxt-light 1.37.1 → 1.38.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/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 +1 -1
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",
|
|
@@ -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>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { q, m } from "#imports";
|
|
3
|
+
import { Loading, Notify } from "quasar";
|
|
4
|
+
import { ref, onMounted } from "vue";
|
|
5
|
+
const isUploading = ref(false);
|
|
6
|
+
const selectedFile = ref(null);
|
|
7
|
+
const fileInput = ref(null);
|
|
8
|
+
const databaseInfo = ref(null);
|
|
9
|
+
const fetchDatabaseInfo = async () => {
|
|
10
|
+
try {
|
|
11
|
+
const info = await q({
|
|
12
|
+
system: {
|
|
13
|
+
database: {
|
|
14
|
+
type: true,
|
|
15
|
+
sizeBytes: true,
|
|
16
|
+
version: true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
databaseInfo.value = info.system.database;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error("Failed to fetch database info:", e);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const onFileChange = (event) => {
|
|
26
|
+
const file = event.target.files[0];
|
|
27
|
+
if (file) {
|
|
28
|
+
if (file.type === "application/sql" || file.name.endsWith(".sql")) {
|
|
29
|
+
selectedFile.value = file;
|
|
30
|
+
} else {
|
|
31
|
+
Notify.create({
|
|
32
|
+
type: "negative",
|
|
33
|
+
message: "\u8ACB\u9078\u64C7 SQL \u6A94\u6848 (.sql)",
|
|
34
|
+
position: "top"
|
|
35
|
+
});
|
|
36
|
+
event.target.value = "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const clearFile = () => {
|
|
41
|
+
selectedFile.value = null;
|
|
42
|
+
if (fileInput.value) {
|
|
43
|
+
fileInput.value.value = "";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const onClickRestore = async () => {
|
|
47
|
+
if (!selectedFile.value) {
|
|
48
|
+
Notify.create({
|
|
49
|
+
type: "negative",
|
|
50
|
+
message: "\u8ACB\u5148\u9078\u64C7\u8981\u9084\u539F\u7684 SQL \u6A94\u6848",
|
|
51
|
+
position: "top"
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
isUploading.value = true;
|
|
56
|
+
Loading.show({ message: "\u6B63\u5728\u9084\u539F\u8CC7\u6599\u5EAB\uFF0C\u8ACB\u7A0D\u5019..." });
|
|
57
|
+
try {
|
|
58
|
+
const result = await m("restoreDatabase", {
|
|
59
|
+
file: selectedFile.value
|
|
60
|
+
});
|
|
61
|
+
Notify.create({
|
|
62
|
+
type: "positive",
|
|
63
|
+
message: "\u8CC7\u6599\u5EAB\u9084\u539F\u6210\u529F\uFF01",
|
|
64
|
+
position: "top"
|
|
65
|
+
});
|
|
66
|
+
clearFile();
|
|
67
|
+
await fetchDatabaseInfo();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error("Restore failed:", e);
|
|
70
|
+
Notify.create({
|
|
71
|
+
type: "negative",
|
|
72
|
+
message: "\u9084\u539F\u5931\u6557\uFF0C\u8ACB\u6AA2\u67E5\u6A94\u6848\u683C\u5F0F\u6216\u7A0D\u5F8C\u518D\u8A66\u3002",
|
|
73
|
+
position: "top"
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
Loading.hide();
|
|
77
|
+
isUploading.value = false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const formatFileSize = (bytes) => {
|
|
81
|
+
if (bytes === 0) return "0 B";
|
|
82
|
+
const k = 1024;
|
|
83
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
84
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
85
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
86
|
+
};
|
|
87
|
+
onMounted(() => {
|
|
88
|
+
fetchDatabaseInfo();
|
|
89
|
+
});
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<l-page title="資料庫還原">
|
|
94
|
+
<div class="import-container">
|
|
95
|
+
<!-- 頁面說明 -->
|
|
96
|
+
<l-card class="info-card">
|
|
97
|
+
<div class="card-header">
|
|
98
|
+
<h5 class="card-title">
|
|
99
|
+
<q-icon name="sym_o_info" class="text-primary" />
|
|
100
|
+
還原說明
|
|
101
|
+
</h5>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="card-content">
|
|
104
|
+
<p class="description">
|
|
105
|
+
資料庫還原功能可以從 SQL 備份檔案恢復資料庫內容。
|
|
106
|
+
請確保上傳的 SQL 檔案來源可信且格式正確。
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
</l-card>
|
|
110
|
+
|
|
111
|
+
<!-- 當前資料庫資訊 -->
|
|
112
|
+
<l-card class="status-card">
|
|
113
|
+
<div class="card-header">
|
|
114
|
+
<h5 class="card-title">
|
|
115
|
+
<q-icon name="sym_o_database" class="text-secondary" />
|
|
116
|
+
當前資料庫資訊
|
|
117
|
+
</h5>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="card-content">
|
|
120
|
+
<div class="info-grid">
|
|
121
|
+
<div class="info-item" v-if="databaseInfo">
|
|
122
|
+
<q-icon name="sym_o_storage" class="info-icon" />
|
|
123
|
+
<div class="info-content">
|
|
124
|
+
<span class="info-label">資料庫類型</span>
|
|
125
|
+
<span class="info-value">{{ databaseInfo.type || '未知' }}</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="info-item" v-if="databaseInfo">
|
|
129
|
+
<q-icon name="sym_o_storage" class="info-icon" />
|
|
130
|
+
<div class="info-content">
|
|
131
|
+
<span class="info-label">當前大小</span>
|
|
132
|
+
<span class="info-value">{{ databaseInfo.sizeBytes ? (databaseInfo.sizeBytes / 1024 /
|
|
133
|
+
1024).toFixed(2) + ' MB' : '計算中...' }}</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="info-item" v-if="databaseInfo && databaseInfo.version">
|
|
137
|
+
<q-icon name="sym_o_info" class="info-icon" />
|
|
138
|
+
<div class="info-content">
|
|
139
|
+
<span class="info-label">資料庫版本</span>
|
|
140
|
+
<span class="info-value">{{ databaseInfo.version }}</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</l-card>
|
|
146
|
+
|
|
147
|
+
<!-- 安全警告 -->
|
|
148
|
+
<l-alert type="negative" class="security-alert">
|
|
149
|
+
<template #prefix>
|
|
150
|
+
<q-icon name="sym_o_warning" />
|
|
151
|
+
</template>
|
|
152
|
+
<strong>重要警告:</strong>還原資料庫將會完全覆蓋現有資料,此操作無法復原。建議在還原前先進行當前資料庫的備份。
|
|
153
|
+
</l-alert>
|
|
154
|
+
|
|
155
|
+
<!-- 檔案上傳區域 -->
|
|
156
|
+
<l-card class="upload-card">
|
|
157
|
+
<div class="card-header">
|
|
158
|
+
<h5 class="card-title">
|
|
159
|
+
<q-icon name="sym_o_upload" class="text-primary" />
|
|
160
|
+
選擇備份檔案
|
|
161
|
+
</h5>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="card-content">
|
|
164
|
+
<div class="upload-area" :class="{ 'has-file': selectedFile }">
|
|
165
|
+
<input ref="fileInput" type="file" accept=".sql,application/sql" @change="onFileChange"
|
|
166
|
+
class="file-input" id="sql-file-input" />
|
|
167
|
+
|
|
168
|
+
<div v-if="!selectedFile" class="upload-placeholder">
|
|
169
|
+
<q-icon name="sym_o_cloud_upload" class="upload-icon" />
|
|
170
|
+
<p class="upload-text">點擊選擇 SQL 檔案</p>
|
|
171
|
+
<p class="upload-hint">支援 .sql 格式的檔案</p>
|
|
172
|
+
<label for="sql-file-input" class="upload-button">
|
|
173
|
+
<l-btn label="選擇檔案" icon="sym_o_folder_open" color="primary" />
|
|
174
|
+
</label>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div v-else class="file-info">
|
|
178
|
+
<div class="file-details">
|
|
179
|
+
<q-icon name="sym_o_description" class="file-icon" />
|
|
180
|
+
<div class="file-content">
|
|
181
|
+
<span class="file-name">{{ selectedFile.name }}</span>
|
|
182
|
+
<span class="file-size">{{ formatFileSize(selectedFile.size) }}</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<l-btn icon="sym_o_close" color="negative" flat round @click="clearFile"
|
|
186
|
+
class="remove-btn" />
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</l-card>
|
|
191
|
+
|
|
192
|
+
<!-- 還原按鈕區域 -->
|
|
193
|
+
<div class="restore-section">
|
|
194
|
+
<l-btn :label="isUploading ? '正在還原中...' : '開始還原資料庫'" icon="sym_o_restore" color="primary" size="lg"
|
|
195
|
+
:loading="isUploading" :disabled="isUploading || !selectedFile" @click="onClickRestore"
|
|
196
|
+
class="restore-btn" />
|
|
197
|
+
<p class="restore-hint">
|
|
198
|
+
請確認已備份當前資料庫,還原操作將無法復原
|
|
199
|
+
</p>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</l-page>
|
|
203
|
+
</template>
|
|
204
|
+
|
|
205
|
+
<style scoped>
|
|
206
|
+
.import-container{display:flex;flex-direction:column;gap:24px;margin:0 auto;max-width:800px;padding:20px}.info-card,.status-card,.upload-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}.upload-area{background:#fafbfc;border:2px dashed #dee2e6;border-radius:12px;min-height:200px;position:relative;transition:all .3s ease}.upload-area:hover{background:#f8f9ff;border-color:#007bff}.upload-area.has-file{background:#f8fff9;border-color:#28a745}.file-input{cursor:pointer;height:100%;opacity:0;position:absolute;width:100%;z-index:1}.upload-placeholder{align-items:center;display:flex;flex-direction:column;height:200px;justify-content:center;text-align:center}.upload-icon{color:#007bff;font-size:64px;margin-bottom:16px}.upload-text{color:#2c3e50;font-size:18px;font-weight:600;margin:0 0 8px}.upload-hint{color:#6c757d;font-size:14px;margin:0 0 20px}.upload-button{cursor:pointer;position:relative;z-index:2}.file-info{height:200px;justify-content:space-between;padding:20px}.file-details,.file-info{align-items:center;display:flex}.file-details{gap:16px}.file-icon{color:#28a745;font-size:48px}.file-content{display:flex;flex-direction:column;gap:4px}.file-name{color:#2c3e50;font-size:16px;font-weight:600}.file-size{color:#6c757d;font-size:14px}.remove-btn{flex-shrink:0}.restore-section{background:linear-gradient(135deg,#fff3cd,#ffeeba);border:2px solid #ffc107;border-radius:16px;padding:32px 24px;text-align:center}.restore-btn{border-radius:8px;box-shadow:0 4px 12px rgba(255,193,7,.3);font-size:16px;font-weight:600;height:48px;margin-bottom:12px;min-width:240px;transition:all .3s ease}.restore-btn:hover:not(:disabled){box-shadow:0 6px 20px rgba(255,193,7,.4);transform:translateY(-2px)}.restore-btn:disabled{opacity:.6}.restore-hint{color:#856404;font-size:14px;font-weight:500;margin:0 auto;max-width:400px}@media (max-width:768px){.import-container{gap:20px;padding:16px}.card-content{padding:12px 20px 20px}.upload-area{min-height:160px}.file-info,.upload-placeholder{height:160px}.file-info{padding:16px}.restore-section{padding:24px 20px}.restore-btn{min-width:100%}}
|
|
207
|
+
</style>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -7,6 +7,7 @@ const { data, refresh } = await useAsyncData("database", async () => {
|
|
|
7
7
|
system: {
|
|
8
8
|
database: {
|
|
9
9
|
table: true,
|
|
10
|
+
type: true,
|
|
10
11
|
version: true,
|
|
11
12
|
tableStatus: true
|
|
12
13
|
}
|
|
@@ -171,6 +172,7 @@ const truncatTable = async () => {
|
|
|
171
172
|
|
|
172
173
|
<l-card>
|
|
173
174
|
<l-list>
|
|
175
|
+
<l-item label="Type">{{ data.type }}</l-item>
|
|
174
176
|
<l-item label="Version">{{ data.version }}</l-item>
|
|
175
177
|
</l-list>
|
|
176
178
|
</l-card>
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { reactive, onMounted, nextTick } from "vue";
|
|
3
3
|
import { useQuasar } from "quasar";
|
|
4
|
-
import { q,
|
|
5
|
-
import {
|
|
4
|
+
import { q, useLight, api } from "#imports";
|
|
5
|
+
import { fabGoogle, fabFacebook, fabMicrosoft } from "@quasar/extras/fontawesome-v6";
|
|
6
6
|
const light = useLight();
|
|
7
|
-
const { t } = useI18n();
|
|
8
7
|
const quasar = useQuasar();
|
|
9
8
|
let { app, my } = await q({
|
|
10
9
|
app: {
|
|
@@ -139,78 +138,169 @@ const onUnlinkFacebook = async () => {
|
|
|
139
138
|
</script>
|
|
140
139
|
|
|
141
140
|
<template>
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<
|
|
146
|
-
|
|
141
|
+
<div class="q-pa-md">
|
|
142
|
+
<!-- 頁面標題 -->
|
|
143
|
+
<div class="q-mb-lg">
|
|
144
|
+
<h1 class="text-h4 q-mb-sm">{{ $t('Social Account Linking') }}</h1>
|
|
145
|
+
<p class="text-grey-6">{{ $t('Link your social accounts to enable quick sign-in and enhanced features.') }}
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
<!-- 社交平台連結區域 -->
|
|
150
|
+
<div class="row q-col-gutter-md">
|
|
151
|
+
<!-- Google -->
|
|
152
|
+
<div class="col-12 col-md-4">
|
|
153
|
+
<q-card class="full-height" :class="my.google ? 'bg-green-1 border-green' : 'bg-grey-1'">
|
|
154
|
+
<q-card-section class="text-center q-pb-none">
|
|
155
|
+
<q-avatar size="64px" class="q-mb-md" :color="my.google ? 'green' : 'grey-5'"
|
|
156
|
+
text-color="white">
|
|
157
|
+
<q-icon :name="fabGoogle" size="32px" />
|
|
158
|
+
</q-avatar>
|
|
159
|
+
<div class="text-h6 q-mb-xs">Google</div>
|
|
160
|
+
<q-badge v-if="my.google" color="green" class="q-mb-md">
|
|
161
|
+
{{ $t('Connected') }}
|
|
162
|
+
</q-badge>
|
|
163
|
+
<q-badge v-else color="grey-5" class="q-mb-md">
|
|
164
|
+
{{ $t('Not Connected') }}
|
|
165
|
+
</q-badge>
|
|
166
|
+
</q-card-section>
|
|
151
167
|
|
|
152
|
-
|
|
168
|
+
<q-card-section v-if="app.googleClientId">
|
|
169
|
+
<template v-if="my.google">
|
|
170
|
+
<div class="text-center q-mb-md">
|
|
171
|
+
<div class="text-caption text-grey-6">{{ $t('Connected Account') }}</div>
|
|
172
|
+
<div class="text-body2 text-weight-medium q-mt-xs">{{ my.google }}</div>
|
|
173
|
+
</div>
|
|
174
|
+
<q-btn class="full-width" color="negative" outline @click="onUnlink" icon="sym_o_link_off"
|
|
175
|
+
:label="$t('Unlink Account')" />
|
|
176
|
+
</template>
|
|
177
|
+
<template v-else>
|
|
178
|
+
<div class="text-center q-mb-md text-grey-6">
|
|
179
|
+
{{ $t('Click to link your Google account for quick sign-in') }}
|
|
180
|
+
</div>
|
|
181
|
+
<div id="g_id_signin" class="flex justify-center"></div>
|
|
182
|
+
</template>
|
|
183
|
+
</q-card-section>
|
|
184
|
+
<q-card-section v-else>
|
|
185
|
+
<q-banner class="bg-orange-2 text-orange-9">
|
|
186
|
+
<template v-slot:avatar>
|
|
187
|
+
<q-icon name="sym_o_warning" />
|
|
188
|
+
</template>
|
|
189
|
+
{{ $t('Service not configured. Please contact administrator.') }}
|
|
190
|
+
</q-banner>
|
|
191
|
+
</q-card-section>
|
|
192
|
+
</q-card>
|
|
193
|
+
</div>
|
|
153
194
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
195
|
+
<!-- Microsoft -->
|
|
196
|
+
<div class="col-12 col-md-4">
|
|
197
|
+
<q-card class="full-height" :class="my.microsoft ? 'bg-blue-1 border-blue' : 'bg-grey-1'">
|
|
198
|
+
<q-card-section class="text-center q-pb-none">
|
|
199
|
+
<q-avatar size="64px" class="q-mb-md" :color="my.microsoft ? 'blue' : 'grey-5'"
|
|
200
|
+
text-color="white">
|
|
201
|
+
<q-icon :name="fabMicrosoft" size="32px" />
|
|
202
|
+
</q-avatar>
|
|
203
|
+
<div class="text-h6 q-mb-xs">Microsoft</div>
|
|
204
|
+
<q-badge v-if="my.microsoft" color="blue" class="q-mb-md">
|
|
205
|
+
{{ $t('Connected') }}
|
|
206
|
+
</q-badge>
|
|
207
|
+
<q-badge v-else color="grey-5" class="q-mb-md">
|
|
208
|
+
{{ $t('Not Connected') }}
|
|
209
|
+
</q-badge>
|
|
210
|
+
</q-card-section>
|
|
159
211
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
212
|
+
<q-card-section v-if="app.microsoftClientId">
|
|
213
|
+
<template v-if="my.microsoft">
|
|
214
|
+
<div class="text-center q-mb-md">
|
|
215
|
+
<div class="text-caption text-grey-6">{{ $t('Connected Account') }}</div>
|
|
216
|
+
<div class="text-body2 text-weight-medium q-mt-xs">{{ my.microsoft }}</div>
|
|
217
|
+
</div>
|
|
218
|
+
<q-btn class="full-width" color="negative" outline @click="onUnlinkMicrosoft"
|
|
219
|
+
icon="sym_o_link_off" :label="$t('Unlink Account')" />
|
|
220
|
+
</template>
|
|
221
|
+
<template v-else>
|
|
222
|
+
<div class="text-center q-mb-md text-grey-6">
|
|
223
|
+
{{ $t('Click to link your Microsoft account for quick sign-in') }}
|
|
224
|
+
</div>
|
|
225
|
+
<div class="flex justify-center">
|
|
226
|
+
<l-microsoft-button :client-id="app.microsoftClientId"
|
|
227
|
+
:tenant-id="app.microsoftTenantId" @login="onLinkMicrosoft" />
|
|
228
|
+
</div>
|
|
229
|
+
</template>
|
|
230
|
+
</q-card-section>
|
|
231
|
+
<q-card-section v-else>
|
|
232
|
+
<q-banner class="bg-orange-2 text-orange-9">
|
|
233
|
+
<template v-slot:avatar>
|
|
234
|
+
<q-icon name="sym_o_warning" />
|
|
235
|
+
</template>
|
|
236
|
+
{{ $t('Service not configured. Please contact administrator.') }}
|
|
237
|
+
</q-banner>
|
|
238
|
+
</q-card-section>
|
|
239
|
+
</q-card>
|
|
240
|
+
</div>
|
|
164
241
|
|
|
242
|
+
<!-- Facebook -->
|
|
243
|
+
<div class="col-12 col-md-4">
|
|
244
|
+
<q-card class="full-height" :class="my.facebook ? 'bg-indigo-1 border-indigo' : 'bg-grey-1'">
|
|
245
|
+
<q-card-section class="text-center q-pb-none">
|
|
246
|
+
<q-avatar size="64px" class="q-mb-md" :color="my.facebook ? 'indigo' : 'grey-5'"
|
|
247
|
+
text-color="white">
|
|
248
|
+
<q-icon :name="fabFacebook" size="32px" />
|
|
249
|
+
</q-avatar>
|
|
250
|
+
<div class="text-h6 q-mb-xs">Facebook</div>
|
|
251
|
+
<q-badge v-if="my.facebook" color="indigo" class="q-mb-md">
|
|
252
|
+
{{ $t('Connected') }}
|
|
253
|
+
</q-badge>
|
|
254
|
+
<q-badge v-else color="grey-5" class="q-mb-md">
|
|
255
|
+
{{ $t('Not Connected') }}
|
|
256
|
+
</q-badge>
|
|
257
|
+
</q-card-section>
|
|
165
258
|
|
|
166
|
-
|
|
259
|
+
<q-card-section v-if="app.facebookAppId">
|
|
260
|
+
<template v-if="my.facebook">
|
|
261
|
+
<div class="text-center q-mb-md">
|
|
262
|
+
<div class="text-caption text-grey-6">{{ $t('Connected Account') }}</div>
|
|
263
|
+
<div class="text-body2 text-weight-medium q-mt-xs">{{ my.facebook }}</div>
|
|
264
|
+
</div>
|
|
265
|
+
<q-btn class="full-width" color="negative" outline @click="onUnlinkFacebook"
|
|
266
|
+
icon="sym_o_link_off" :label="$t('Unlink Account')" />
|
|
267
|
+
</template>
|
|
268
|
+
<template v-else>
|
|
269
|
+
<div class="text-center q-mb-md text-grey-6">
|
|
270
|
+
{{ $t('Click to link your Facebook account for quick sign-in') }}
|
|
271
|
+
</div>
|
|
272
|
+
<div class="flex justify-center">
|
|
273
|
+
<l-facebook-button @login="onLinkFacebook" />
|
|
274
|
+
</div>
|
|
275
|
+
</template>
|
|
276
|
+
</q-card-section>
|
|
277
|
+
<q-card-section v-else>
|
|
278
|
+
<q-banner class="bg-orange-2 text-orange-9">
|
|
279
|
+
<template v-slot:avatar>
|
|
280
|
+
<q-icon name="sym_o_warning" />
|
|
281
|
+
</template>
|
|
282
|
+
{{ $t('Service not configured. Please contact administrator.') }}
|
|
283
|
+
</q-banner>
|
|
284
|
+
</q-card-section>
|
|
285
|
+
</q-card>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
167
288
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
<
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
@login="onLinkMicrosoft" />
|
|
182
|
-
</div>
|
|
183
|
-
</template>
|
|
184
|
-
</template>
|
|
185
|
-
<template v-else>
|
|
186
|
-
Micorsoft login is not available. Please set authentication microsft client id in server settings.
|
|
187
|
-
</template>
|
|
188
|
-
</q-card-section>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<q-separator />
|
|
192
|
-
<q-card-section>
|
|
193
|
-
<h2 class="text-h6">Facebook</h2>
|
|
194
|
-
<template v-if="app.facebookAppId">
|
|
195
|
-
<template v-if="my.facebook">
|
|
196
|
-
{{ $t('You have already linked your Facebook account.') }}<br />
|
|
197
|
-
Your account id is {{ my.facebook }}
|
|
198
|
-
<l-btn label="Unlink" @click="onUnlinkFacebook" icon="sym_o_link_off"></l-btn>
|
|
199
|
-
</template>
|
|
200
|
-
<template v-else>
|
|
201
|
-
<div>{{ $t('Click the button below to link your Facebook account.') }}</div>
|
|
202
|
-
<l-facebook-button @login="onLinkFacebook" />
|
|
203
|
-
</template>
|
|
204
|
-
</template>
|
|
205
|
-
<template v-else>
|
|
206
|
-
Facebook login is not available. Please set authentication facebook app id in server settings.
|
|
207
|
-
</template>
|
|
208
|
-
</q-card-section>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
</q-card>
|
|
289
|
+
<!-- 安全提示 -->
|
|
290
|
+
<q-card class="q-mt-lg bg-blue-1">
|
|
291
|
+
<q-card-section>
|
|
292
|
+
<div class="row items-center">
|
|
293
|
+
<q-icon name="sym_o_security" color="blue" size="24px" class="q-mr-sm" />
|
|
294
|
+
<div class="text-h6 text-blue-8">{{ $t('Security Notice') }}</div>
|
|
295
|
+
</div>
|
|
296
|
+
<p class="text-blue-7 q-mb-none q-mt-sm">
|
|
297
|
+
{{ $t('Your social accounts are securely linked using industry-standard OAuth protocols. You can unlink them at any time.') }}
|
|
298
|
+
</p>
|
|
299
|
+
</q-card-section>
|
|
300
|
+
</q-card>
|
|
301
|
+
</div>
|
|
216
302
|
</template>
|
|
303
|
+
|
|
304
|
+
<style scoped>
|
|
305
|
+
.border-green{border:2px solid #4caf50!important}.border-blue{border:2px solid #2196f3!important}.border-indigo{border:2px solid #3f51b5!important}.q-card{border-radius:12px;transition:all .3s ease}.q-card:hover{box-shadow:0 8px 24px rgba(0,0,0,.12);transform:translateY(-2px)}.q-avatar{transition:all .3s ease}.q-card:hover .q-avatar{transform:scale(1.05)}.full-height{height:100%;min-height:300px}@media (max-width:768px){.full-height{min-height:auto}}
|
|
306
|
+
</style>
|
|
@@ -43,13 +43,14 @@ const policies = computed(() => {
|
|
|
43
43
|
validation="required|confirm" />
|
|
44
44
|
</FormKit>
|
|
45
45
|
|
|
46
|
-
<q-
|
|
46
|
+
<q-separator spaced />
|
|
47
|
+
|
|
48
|
+
<q-card flat :bordered="false">
|
|
47
49
|
<q-card-section>
|
|
48
50
|
<div>{{ $t('Password policy') }}</div>
|
|
49
51
|
<ul>
|
|
50
52
|
<li v-for="policy in policies" :key="policy">{{ policy }}</li>
|
|
51
53
|
</ul>
|
|
52
|
-
|
|
53
54
|
</q-card-section>
|
|
54
55
|
</q-card>
|
|
55
56
|
</template>
|
|
@@ -11,81 +11,247 @@ const { my } = await q({
|
|
|
11
11
|
const obj = reactive({
|
|
12
12
|
code: ""
|
|
13
13
|
});
|
|
14
|
+
const currentStep = ref(1);
|
|
15
|
+
const secretVisible = ref(false);
|
|
16
|
+
const loading = ref(false);
|
|
14
17
|
const save = async () => {
|
|
18
|
+
loading.value = true;
|
|
15
19
|
try {
|
|
16
20
|
await m("updateMy2FA", {
|
|
17
21
|
code: obj.code,
|
|
18
22
|
secret: my.my2FA.secret
|
|
19
23
|
});
|
|
20
24
|
$q.notify({
|
|
21
|
-
message: "
|
|
22
|
-
color: "positive"
|
|
25
|
+
message: "\u{1F389} Two-Factor Authentication has been successfully enabled!",
|
|
26
|
+
color: "positive",
|
|
27
|
+
timeout: 5e3,
|
|
28
|
+
actions: [
|
|
29
|
+
{ label: "Dismiss", color: "white" }
|
|
30
|
+
]
|
|
23
31
|
});
|
|
32
|
+
await navigateTo("/User/setting");
|
|
24
33
|
} catch (e) {
|
|
25
34
|
$q.notify({
|
|
26
|
-
message: e.message
|
|
27
|
-
color: "negative"
|
|
35
|
+
message: `\u274C Setup failed: ${e.message}`,
|
|
36
|
+
color: "negative",
|
|
37
|
+
timeout: 5e3,
|
|
38
|
+
actions: [
|
|
39
|
+
{ label: "Retry", color: "white" }
|
|
40
|
+
]
|
|
28
41
|
});
|
|
42
|
+
} finally {
|
|
43
|
+
loading.value = false;
|
|
29
44
|
}
|
|
30
45
|
};
|
|
31
46
|
const show = ref(true);
|
|
32
47
|
if (my.twoFactorEnabled) {
|
|
33
48
|
show.value = false;
|
|
49
|
+
currentStep.value = 3;
|
|
34
50
|
}
|
|
35
|
-
const onCopy = () => {
|
|
36
|
-
|
|
51
|
+
const onCopy = async () => {
|
|
52
|
+
try {
|
|
53
|
+
await navigator.clipboard.writeText(my.my2FA.secret);
|
|
37
54
|
$q.notify({
|
|
38
|
-
message: "Secret copied",
|
|
39
|
-
color: "positive"
|
|
55
|
+
message: "\u2705 Secret key copied to clipboard",
|
|
56
|
+
color: "positive",
|
|
57
|
+
timeout: 2e3,
|
|
58
|
+
icon: "content_copy"
|
|
40
59
|
});
|
|
41
|
-
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
const textArea = document.createElement("textarea");
|
|
62
|
+
textArea.value = my.my2FA.secret;
|
|
63
|
+
document.body.appendChild(textArea);
|
|
64
|
+
textArea.select();
|
|
65
|
+
document.execCommand("copy");
|
|
66
|
+
document.body.removeChild(textArea);
|
|
42
67
|
$q.notify({
|
|
43
|
-
message: "
|
|
44
|
-
color: "
|
|
68
|
+
message: "\u2705 Secret key copied to clipboard",
|
|
69
|
+
color: "positive",
|
|
70
|
+
timeout: 2e3
|
|
45
71
|
});
|
|
46
|
-
}
|
|
72
|
+
}
|
|
47
73
|
};
|
|
48
74
|
</script>
|
|
49
75
|
|
|
76
|
+
<style scoped>
|
|
77
|
+
.two-factor-setup{margin:0 auto;max-width:800px}.setup-header{text-align:center}.app-card{transition:all .3s ease}.app-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.1);transform:translateY(-2px)}.qr-card{background:linear-gradient(135deg,#f5f7fa,#c3cfe2);border:2px dashed #1976d2}.secret-input{font-family:Courier New,monospace;font-size:14px}.code-input .q-field__control input{font-family:Courier New,monospace;font-size:24px;font-weight:700;letter-spacing:8px;text-align:center}.verify-section{text-align:center}@media (max-width:600px){.app-card{margin-bottom:1rem}.qr-card img{width:180px!important}.code-input .q-field__control input{font-size:20px;letter-spacing:4px}}.q-btn--loading{pointer-events:none}.q-stepper--vertical .q-stepper__step--done .q-stepper__label{opacity:.7}.q-btn,.q-card{transition:all .2s ease}.q-btn:hover{transform:translateY(-1px)}
|
|
78
|
+
</style>
|
|
79
|
+
|
|
50
80
|
<template>
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
<div class="two-factor-setup">
|
|
82
|
+
<!-- Header -->
|
|
83
|
+
<div class="setup-header q-mb-lg">
|
|
84
|
+
<h5 class="q-my-md">{{ $t('Setup Two-Factor Authentication') }}</h5>
|
|
85
|
+
<q-stepper v-model="currentStep" vertical color="primary" animated flat>
|
|
86
|
+
<q-step :name="1" title="Download Authenticator App" icon="download" :done="currentStep > 1">
|
|
87
|
+
<div class="q-mb-md">
|
|
88
|
+
<p class="text-body1 q-mb-md">{{ $t('Choose and download an authenticator app for your device:') }}</p>
|
|
89
|
+
|
|
90
|
+
<!-- App Selection Cards -->
|
|
91
|
+
<div class="row q-gutter-md q-mb-lg">
|
|
92
|
+
<div class="col-12 col-md-5">
|
|
93
|
+
<q-card flat bordered class="app-card">
|
|
94
|
+
<q-card-section class="q-pa-md">
|
|
95
|
+
<div class="text-h6 q-mb-sm">📱 Android</div>
|
|
96
|
+
<div class="q-gutter-sm">
|
|
97
|
+
<q-btn
|
|
98
|
+
outline
|
|
99
|
+
color="primary"
|
|
100
|
+
size="sm"
|
|
101
|
+
target="_blank"
|
|
102
|
+
href="https://play.google.com/store/apps/details?id=com.azure.authenticator"
|
|
103
|
+
label="Microsoft Authenticator"
|
|
104
|
+
class="full-width"
|
|
105
|
+
/>
|
|
106
|
+
<q-btn
|
|
107
|
+
outline
|
|
108
|
+
color="primary"
|
|
109
|
+
size="sm"
|
|
110
|
+
target="_blank"
|
|
111
|
+
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2"
|
|
112
|
+
label="Google Authenticator"
|
|
113
|
+
class="full-width"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</q-card-section>
|
|
117
|
+
</q-card>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div class="col-12 col-md-5">
|
|
121
|
+
<q-card flat bordered class="app-card">
|
|
122
|
+
<q-card-section class="q-pa-md">
|
|
123
|
+
<div class="text-h6 q-mb-sm">🍎 iOS</div>
|
|
124
|
+
<div class="q-gutter-sm">
|
|
125
|
+
<q-btn
|
|
126
|
+
outline
|
|
127
|
+
color="primary"
|
|
128
|
+
size="sm"
|
|
129
|
+
target="_blank"
|
|
130
|
+
href="https://apps.apple.com/us/app/microsoft-authenticator/id983156458"
|
|
131
|
+
label="Microsoft Authenticator"
|
|
132
|
+
class="full-width"
|
|
133
|
+
/>
|
|
134
|
+
<q-btn
|
|
135
|
+
outline
|
|
136
|
+
color="primary"
|
|
137
|
+
size="sm"
|
|
138
|
+
target="_blank"
|
|
139
|
+
href="https://apps.apple.com/us/app/google-authenticator/id388497605"
|
|
140
|
+
label="Google Authenticator"
|
|
141
|
+
class="full-width"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</q-card-section>
|
|
145
|
+
</q-card>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
61
148
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
149
|
+
<q-btn
|
|
150
|
+
color="primary"
|
|
151
|
+
@click="currentStep = 2"
|
|
152
|
+
:label="$t('I have installed the app')"
|
|
153
|
+
icon-right="arrow_forward"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
</q-step>
|
|
66
157
|
|
|
67
|
-
|
|
158
|
+
<q-step :name="2" title="Scan QR Code" icon="qr_code" :done="currentStep > 2">
|
|
159
|
+
<div class="text-center q-mb-md">
|
|
160
|
+
<p class="text-body1 q-mb-md">{{ $t('Scan this QR code with your authenticator app:') }}</p>
|
|
161
|
+
|
|
162
|
+
<q-card flat bordered class="qr-card inline-block q-pa-lg">
|
|
163
|
+
<q-img :src="my.my2FA.image" width="200px" class="q-mb-md" />
|
|
164
|
+
|
|
165
|
+
<!-- Manual Entry Option -->
|
|
166
|
+
<q-expansion-item
|
|
167
|
+
icon="key"
|
|
168
|
+
:label="$t('Enter manually instead')"
|
|
169
|
+
class="text-left"
|
|
170
|
+
>
|
|
171
|
+
<div class="q-pa-md bg-grey-1 rounded-borders">
|
|
172
|
+
<div class="text-caption text-grey-7 q-mb-xs">{{ $t('Secret Key:') }}</div>
|
|
173
|
+
<div class="row items-center q-gutter-sm">
|
|
174
|
+
<div class="col">
|
|
175
|
+
<q-input
|
|
176
|
+
:model-value="secretVisible ? (my.my2FA?.secret || '') : '●'.repeat(my.my2FA?.secret?.length || 16)"
|
|
177
|
+
readonly
|
|
178
|
+
dense
|
|
179
|
+
outlined
|
|
180
|
+
class="secret-input"
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
<q-btn
|
|
184
|
+
flat
|
|
185
|
+
round
|
|
186
|
+
dense
|
|
187
|
+
:icon="secretVisible ? 'visibility_off' : 'visibility'"
|
|
188
|
+
@click="secretVisible = !secretVisible"
|
|
189
|
+
:title="secretVisible ? $t('Hide') : $t('Show')"
|
|
190
|
+
/>
|
|
191
|
+
<q-btn
|
|
192
|
+
flat
|
|
193
|
+
round
|
|
194
|
+
dense
|
|
195
|
+
icon="content_copy"
|
|
196
|
+
@click="onCopy"
|
|
197
|
+
:title="$t('Copy to clipboard')"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</q-expansion-item>
|
|
202
|
+
</q-card>
|
|
68
203
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
204
|
+
<div class="q-mt-md">
|
|
205
|
+
<q-btn
|
|
206
|
+
color="primary"
|
|
207
|
+
@click="currentStep = 3"
|
|
208
|
+
:label="$t('I have scanned the code')"
|
|
209
|
+
icon-right="arrow_forward"
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</q-step>
|
|
78
214
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
215
|
+
<q-step :name="3" title="Verify Setup" icon="verified_user">
|
|
216
|
+
<div class="verify-section">
|
|
217
|
+
<p class="text-body1 q-mb-md">
|
|
218
|
+
{{ $t('Enter the 6-digit code from your authenticator app to complete setup:') }}
|
|
219
|
+
</p>
|
|
220
|
+
|
|
221
|
+
<div class="row justify-center q-mb-lg">
|
|
222
|
+
<div class="col-12 col-sm-8 col-md-6">
|
|
223
|
+
<q-input
|
|
224
|
+
v-model="obj.code"
|
|
225
|
+
:label="$t('Verification Code')"
|
|
226
|
+
hint="6-digit code from your authenticator app"
|
|
227
|
+
type="tel"
|
|
228
|
+
inputmode="numeric"
|
|
229
|
+
pattern="[0-9]*"
|
|
230
|
+
outlined
|
|
231
|
+
maxlength="6"
|
|
232
|
+
class="text-center code-input"
|
|
233
|
+
required
|
|
234
|
+
>
|
|
235
|
+
<template v-slot:prepend>
|
|
236
|
+
<q-icon name="security" />
|
|
237
|
+
</template>
|
|
238
|
+
</q-input>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
87
241
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
242
|
+
<div class="text-center">
|
|
243
|
+
<q-btn
|
|
244
|
+
@click="save"
|
|
245
|
+
color="positive"
|
|
246
|
+
size="lg"
|
|
247
|
+
:label="$t('Complete Setup')"
|
|
248
|
+
icon="check_circle"
|
|
249
|
+
:loading="loading"
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</q-step>
|
|
254
|
+
</q-stepper>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
91
257
|
</template>
|