@liujitcn/kratos-uni-app-core 0.0.28 → 0.0.31

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.
@@ -0,0 +1,280 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { useUserStore } from '../stores'
4
+ import { navigateToLogin } from '../utils/navigation'
5
+ import { getLanguageOptions, useI18n, type SupportedLocale } from '../locales'
6
+ import { defMfaService } from '../api/base/mfa'
7
+ import MfaManageDialog from './MfaManageDialog.vue'
8
+
9
+ defineOptions({ name: 'KratosSettingsPage' })
10
+
11
+ defineSlots<{
12
+ /** 在所有公共设置卡片之前渲染的业务扩展内容。 */
13
+ extensions?: () => unknown
14
+ }>()
15
+
16
+ const userStore = useUserStore()
17
+ const { locale, setLocale, t } = useI18n()
18
+ const logoutLoading = ref(false)
19
+ const mfaDialogVisible = ref(false)
20
+ const mfaDialogMode = ref<'setup' | 'disable'>('setup')
21
+ const mfaEnabled = ref(false)
22
+ const mfaMethod = ref('totp')
23
+ const mfaStatusLoading = ref(false)
24
+ const localeOptions = computed(() => getLanguageOptions())
25
+ const localeLabels = computed(() => localeOptions.value.map((item) => item.native_name))
26
+ const localeIndex = computed(() =>
27
+ Math.max(
28
+ 0,
29
+ localeOptions.value.findIndex((item) => item.language_code === locale.value),
30
+ ),
31
+ )
32
+
33
+ watch(locale, () => void uni.setNavigationBarTitle({ title: t('core.settings.title') }), {
34
+ immediate: true,
35
+ })
36
+
37
+ /** 处理语言选择并刷新动态本地化数据。 */
38
+ const onLocaleChange = (event: { detail: { value: string | number } }) => {
39
+ const nextLocale = localeOptions.value[Number(event.detail.value)]?.language_code as
40
+ | SupportedLocale
41
+ | undefined
42
+ if (nextLocale) void setLocale(nextLocale)
43
+ }
44
+
45
+ /** 加载当前用户的多因素认证状态。 */
46
+ const loadMfaStatus = async (force = false) => {
47
+ if (!userStore.isAuthenticated() || (mfaStatusLoading.value && !force)) return
48
+ mfaStatusLoading.value = true
49
+ try {
50
+ const result = await defMfaService.GetMfaStatus({})
51
+ mfaEnabled.value = result.enabled
52
+ mfaMethod.value = result.method || 'totp'
53
+ } finally {
54
+ mfaStatusLoading.value = false
55
+ }
56
+ }
57
+
58
+ /** 当前多因素认证状态展示文案。 */
59
+ const mfaStatusText = computed(() => {
60
+ if (!mfaEnabled.value) return t('core.settings.mfa_disabled')
61
+ const method =
62
+ mfaMethod.value === 'webauthn'
63
+ ? t('core.settings.mfa_method_webauthn')
64
+ : t('core.settings.mfa_method_totp')
65
+ return t('core.settings.mfa_enabled_method', { method })
66
+ })
67
+
68
+ /** 根据当前状态打开多因素认证操作组件。 */
69
+ const onMfaTap = async () => {
70
+ if (!userStore.ensureAuthenticated()) {
71
+ navigateToLogin()
72
+ return
73
+ }
74
+ const currentMfaEnabled = mfaEnabled.value
75
+ try {
76
+ await loadMfaStatus(true)
77
+ } catch {
78
+ // 状态刷新失败时保留页面已有状态,避免已启用用户无法进入禁用流程。
79
+ mfaEnabled.value = currentMfaEnabled
80
+ }
81
+ if (mfaEnabled.value) {
82
+ mfaDialogMode.value = 'disable'
83
+ mfaDialogVisible.value = true
84
+ return
85
+ }
86
+ mfaDialogMode.value = 'setup'
87
+ mfaDialogVisible.value = true
88
+ }
89
+
90
+ /** 绑定成功后清理旧会话并重新登录。 */
91
+ const onMfaSuccess = async (method: string) => {
92
+ mfaEnabled.value = true
93
+ mfaMethod.value = method || 'totp'
94
+ await uni.showToast({ icon: 'none', title: t('core.settings.mfa_bind_success') })
95
+ // 绑定 MFA 后后端会撤销当前会话,不能再调用登出接口或继续使用旧 token。
96
+ await userStore.clearUserData()
97
+ uni.reLaunch({ url: '/pages/login/login' })
98
+ }
99
+
100
+ /** 禁用成功后清理旧会话并重新登录。 */
101
+ const onMfaDisabled = async () => {
102
+ mfaEnabled.value = false
103
+ await uni.showToast({ icon: 'none', title: t('core.settings.mfa_disable_success') })
104
+ await userStore.clearUserData()
105
+ uni.reLaunch({ url: '/pages/login/login' })
106
+ }
107
+
108
+ // 公共设置模块挂载时加载 MFA 状态,兼容页面包装和业务插槽场景。
109
+ onMounted(() => {
110
+ if (!userStore.ensureAuthenticated()) {
111
+ // #ifndef MP-WEIXIN
112
+ navigateToLogin()
113
+ // #endif
114
+ return
115
+ }
116
+ void loadMfaStatus()
117
+ })
118
+
119
+ /** 确认后退出登录并返回上一页。 */
120
+ const onLogout = () => {
121
+ if (logoutLoading.value) {
122
+ return
123
+ }
124
+ // 模态弹窗
125
+ uni.showModal({
126
+ content: t('core.settings.logout_confirm'),
127
+ confirmColor: '#27BA9B',
128
+ success: async (res) => {
129
+ if (!res.confirm) {
130
+ return
131
+ }
132
+
133
+ logoutLoading.value = true
134
+ try {
135
+ // 先完成退出和本地登录态清理,再返回个人中心,避免 onShow 读取到旧登录态。
136
+ await userStore.logout()
137
+ uni.navigateBack()
138
+ } catch (error) {
139
+ await uni.showToast({
140
+ icon: 'none',
141
+ title: t('core.settings.logout_failed'),
142
+ })
143
+ } finally {
144
+ logoutLoading.value = false
145
+ }
146
+ },
147
+ })
148
+ }
149
+ </script>
150
+
151
+ <template>
152
+ <view class="viewport">
153
+ <slot name="extensions" />
154
+ <view class="list">
155
+ <view v-if="userStore.isAuthenticated()" class="item arrow mfa-item" @tap="onMfaTap">
156
+ <text>{{ t('core.settings.mfa_title') }}</text>
157
+ <text class="mfa-value">{{ mfaStatusText }}</text>
158
+ </view>
159
+ <picker :range="localeLabels" :value="localeIndex" @change="onLocaleChange">
160
+ <view class="item arrow locale-item">
161
+ <text>{{ t('common.field.language') }}</text>
162
+ <text class="locale-value">{{ localeLabels[localeIndex] }}</text>
163
+ </view>
164
+ </picker>
165
+ </view>
166
+ <!-- #ifdef MP-WEIXIN -->
167
+ <!-- 列表2 -->
168
+ <view class="list">
169
+ <button hover-class="none" class="item arrow" open-type="openSetting">
170
+ {{ t('core.settings.authorization') }}
171
+ </button>
172
+ <button hover-class="none" class="item arrow" open-type="feedback">
173
+ {{ t('core.settings.feedback') }}
174
+ </button>
175
+ <button hover-class="none" class="item arrow" open-type="contact">
176
+ {{ t('core.settings.contact') }}
177
+ </button>
178
+ </view>
179
+ <!-- #endif -->
180
+ <!-- 操作按钮 -->
181
+ <view class="action" v-if="userStore.isAuthenticated()">
182
+ <view @tap="onLogout" class="button">{{ t('common.action.logout') }}</view>
183
+ </view>
184
+ </view>
185
+ <MfaManageDialog
186
+ v-model="mfaDialogVisible"
187
+ :mode="mfaDialogMode"
188
+ :method="mfaMethod"
189
+ @success="onMfaSuccess"
190
+ @disabled="onMfaDisabled"
191
+ />
192
+ </template>
193
+
194
+ <style scoped lang="scss">
195
+ .viewport {
196
+ min-height: 100vh;
197
+ padding: 20rpx;
198
+ box-sizing: border-box;
199
+ background-color: #f4f4f4;
200
+ }
201
+
202
+ /* 列表 */
203
+ .list {
204
+ padding: 0 20rpx;
205
+ background-color: #fff;
206
+ margin-bottom: 20rpx;
207
+ border-radius: 10rpx;
208
+ .item {
209
+ line-height: 90rpx;
210
+ padding-left: 10rpx;
211
+ font-size: 30rpx;
212
+ color: #333;
213
+ border-top: 1rpx solid #ddd;
214
+ position: relative;
215
+ text-align: left;
216
+ border-radius: 0;
217
+ background-color: #fff;
218
+ &::after {
219
+ width: auto;
220
+ height: auto;
221
+ left: auto;
222
+ border: none;
223
+ }
224
+ &::after {
225
+ right: 5rpx;
226
+ }
227
+ }
228
+ > .item:first-child {
229
+ border: none;
230
+ }
231
+ .arrow::after {
232
+ content: '›';
233
+ position: absolute;
234
+ top: 50%;
235
+ color: #ccc;
236
+ font-size: 36rpx;
237
+ transform: translateY(-50%);
238
+ }
239
+ }
240
+
241
+ .locale-item {
242
+ display: flex;
243
+ justify-content: space-between;
244
+ padding-right: 42rpx;
245
+ }
246
+
247
+ .locale-value {
248
+ color: #888;
249
+ }
250
+
251
+ .mfa-item {
252
+ display: flex;
253
+ align-items: center;
254
+ justify-content: space-between;
255
+ padding-right: 42rpx;
256
+ }
257
+
258
+ .mfa-value {
259
+ max-width: 55%;
260
+ overflow: hidden;
261
+ color: #888;
262
+ font-size: 26rpx;
263
+ text-overflow: ellipsis;
264
+ white-space: nowrap;
265
+ }
266
+
267
+ /* 操作按钮 */
268
+ .action {
269
+ text-align: center;
270
+ line-height: 90rpx;
271
+ margin-top: 40rpx;
272
+ font-size: 32rpx;
273
+ color: #333;
274
+ .button {
275
+ background-color: #fff;
276
+ margin-bottom: 20rpx;
277
+ border-radius: 10rpx;
278
+ }
279
+ }
280
+ </style>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { resolveModuleIcon, resolveStaticView } from '../module'
4
- import { APP_MENU_ROOT_ID, useAppNavigation } from '../navigation'
4
+ import { APP_MENU_ROOT_ID, useAppMenuBadge, useAppNavigation } from '../navigation'
5
5
  import { resolveRootMenuId } from '../navigation-tree.mjs'
6
6
 
7
7
  const props = defineProps<{ route: string }>()
@@ -13,6 +13,7 @@ const activeMenu = computed(() => {
13
13
  return tabBar.value.find((menu) => menu.id === tabMenuId)
14
14
  })
15
15
  const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
16
+ const messageBadge = useAppMenuBadge('MESSAGE_INBOX')
16
17
  </script>
17
18
 
18
19
  <template>
@@ -23,6 +24,9 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
23
24
  class="kratos-tab-bar__item"
24
25
  @tap="navigate(item.path)"
25
26
  >
27
+ <text v-if="item.viewKey === 'MESSAGE_INBOX' && messageBadge" class="kratos-tab-bar__badge">{{
28
+ messageBadge
29
+ }}</text>
26
30
  <image
27
31
  v-if="resolveModuleIcon(item.icon)"
28
32
  class="kratos-tab-bar__icon"
@@ -33,6 +37,12 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
33
37
  "
34
38
  mode="aspectFit"
35
39
  />
40
+ <view
41
+ v-else-if="item.viewKey === 'MESSAGE_INBOX'"
42
+ class="kratos-tab-bar__message-icon"
43
+ :class="{ 'kratos-tab-bar__message-icon--active': activeMenu?.id === item.id }"
44
+ aria-hidden="true"
45
+ />
36
46
  <text :class="{ 'kratos-tab-bar__text--active': activeMenu?.id === item.id }">
37
47
  {{ item.title }}
38
48
  </text>
@@ -55,6 +65,7 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
55
65
  background: #fff;
56
66
  }
57
67
  .kratos-tab-bar__item {
68
+ position: relative;
58
69
  display: flex;
59
70
  flex: 1;
60
71
  align-items: center;
@@ -70,7 +81,45 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
70
81
  height: 28px;
71
82
  margin-bottom: 4px;
72
83
  }
84
+ .kratos-tab-bar__message-icon {
85
+ position: relative;
86
+ width: 22px;
87
+ height: 16px;
88
+ margin-bottom: 8px;
89
+ border: 2px solid #9aa4b2;
90
+ border-radius: 3px;
91
+ box-sizing: border-box;
92
+ }
93
+ .kratos-tab-bar__message-icon::after {
94
+ position: absolute;
95
+ top: 1px;
96
+ left: 4px;
97
+ width: 9px;
98
+ height: 9px;
99
+ border-right: 2px solid #9aa4b2;
100
+ border-bottom: 2px solid #9aa4b2;
101
+ content: '';
102
+ transform: rotate(45deg);
103
+ }
104
+ .kratos-tab-bar__message-icon--active,
105
+ .kratos-tab-bar__message-icon--active::after {
106
+ border-color: #27ba9b;
107
+ }
73
108
  .kratos-tab-bar__text--active {
74
109
  color: #27ba9b;
75
110
  }
111
+ .kratos-tab-bar__badge {
112
+ position: absolute;
113
+ top: 2px;
114
+ right: calc(50% - 22px);
115
+ min-width: 14px;
116
+ height: 14px;
117
+ padding: 0 3px;
118
+ border-radius: 8px;
119
+ background: #e5484d;
120
+ color: #fff;
121
+ font-size: 9px;
122
+ line-height: 14px;
123
+ text-align: center;
124
+ }
76
125
  </style>