@liujitcn/kratos-uni-app-core 0.0.29 → 0.0.32

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.
Files changed (43) hide show
  1. package/README.md +30 -7
  2. package/dist/module.mjs +164 -16
  3. package/dist/vite.mjs +4 -5
  4. package/package.json +3 -1
  5. package/src/api/base/{config.ts → v1/config.ts} +6 -2
  6. package/src/api/base/{file.ts → v1/file.ts} +6 -2
  7. package/src/api/base/{language.ts → v1/language.ts} +2 -2
  8. package/src/api/base/{login.ts → v1/login.ts} +3 -3
  9. package/src/api/base/v1/mfa.ts +127 -0
  10. package/src/api/base/{oauth.ts → v1/oauth.ts} +3 -3
  11. package/src/api/system/{auth.ts → app/v1/auth.ts} +8 -15
  12. package/src/api/system/{base_area.ts → app/v1/base_area.ts} +2 -2
  13. package/src/api/system/app/v1/base_dict.ts +21 -0
  14. package/src/api/{base/menu.ts → system/app/v1/base_menu.ts} +2 -2
  15. package/src/bootstrap.ts +1 -1
  16. package/src/components/KratosSettingsPage.vue +292 -0
  17. package/src/components/KratosTabBar.vue +71 -1
  18. package/src/components/MfaManageDialog.vue +469 -0
  19. package/src/components/MfaRecoveryCodesDialog.vue +192 -0
  20. package/src/components/MfaSetupPanel.vue +160 -0
  21. package/src/components/PasswordVerifyDialog.vue +277 -0
  22. package/src/index.ts +2 -0
  23. package/src/locales/README.md +2 -1
  24. package/src/locales/en-US.json +41 -4
  25. package/src/locales/index.ts +10 -1
  26. package/src/locales/ja-JP.json +41 -4
  27. package/src/locales/zh-CN.json +41 -4
  28. package/src/locales/zh-TW.json +41 -4
  29. package/src/navigation.ts +115 -53
  30. package/src/rpc/base/v1/login.ts +37 -4
  31. package/src/rpc/base/v1/mfa.ts +180 -0
  32. package/src/rpc/base/v1/oauth.ts +25 -0
  33. package/src/rpc/system/app/v1/auth.ts +7 -0
  34. package/src/rpc/system/common/v1/common.ts +35 -0
  35. package/src/stores/modules/setting.ts +1 -1
  36. package/src/stores/modules/user.ts +29 -10
  37. package/src/utils/clipboard.ts +33 -0
  38. package/src/utils/http.ts +10 -13
  39. package/src/utils/passwordCrypto.ts +1 -1
  40. package/src/utils/webauthn.ts +101 -0
  41. package/src/views/pages/login/login.vue +299 -13
  42. package/src/vite.ts +4 -7
  43. package/src/api/system/base_dict.ts +0 -24
@@ -0,0 +1,127 @@
1
+ import { http } from '../../../utils/http'
2
+ import type { LoginResponse } from '../../../rpc/base/v1/login'
3
+ import type {
4
+ BeginMfaDisableRequest,
5
+ BeginMfaDisableResponse,
6
+ BeginMfaEnrollmentRequest,
7
+ BeginMfaEnrollmentResponse,
8
+ BeginMfaSetupRequest,
9
+ BeginMfaSetupResponse,
10
+ ConfirmMfaEnrollmentRequest,
11
+ ConfirmMfaEnrollmentResponse,
12
+ ConfirmMfaSetupRequest,
13
+ ConfirmMfaSetupResponse,
14
+ DisableMfaRequest,
15
+ GetMfaStatusRequest,
16
+ MfaService,
17
+ MfaStatusResponse,
18
+ RecoveryCodesResponse,
19
+ RegenerateMfaRecoveryCodesRequest,
20
+ VerifyMfaRequest,
21
+ } from '../../../rpc/base/v1/mfa'
22
+ import type { Empty } from '../../../rpc/google/protobuf/empty'
23
+
24
+ const MFA_URL = '/v1/base/mfa'
25
+
26
+ /** 多因素认证公共服务。 */
27
+ export class MfaServiceImpl implements MfaService {
28
+ /** 校验登录阶段多因素认证。 */
29
+ VerifyMfa(request: VerifyMfaRequest): Promise<LoginResponse> {
30
+ return http<LoginResponse>({
31
+ url: `${MFA_URL}/verify`,
32
+ method: 'POST',
33
+ authMode: 'none',
34
+ data: request,
35
+ header: { Authorization: 'no-auth' },
36
+ })
37
+ }
38
+
39
+ /** 开始登录阶段的强制多因素认证绑定。 */
40
+ BeginMfaEnrollment(request: BeginMfaEnrollmentRequest): Promise<BeginMfaEnrollmentResponse> {
41
+ return http<BeginMfaEnrollmentResponse>({
42
+ url: `${MFA_URL}/enrollment`,
43
+ method: 'POST',
44
+ authMode: 'none',
45
+ data: request,
46
+ header: { Authorization: 'no-auth' },
47
+ })
48
+ }
49
+
50
+ /** 确认登录阶段的强制多因素认证绑定。 */
51
+ ConfirmMfaEnrollment(
52
+ request: ConfirmMfaEnrollmentRequest,
53
+ ): Promise<ConfirmMfaEnrollmentResponse> {
54
+ return http<ConfirmMfaEnrollmentResponse>({
55
+ url: `${MFA_URL}/enrollment/confirm`,
56
+ method: 'POST',
57
+ authMode: 'none',
58
+ data: request,
59
+ header: { Authorization: 'no-auth' },
60
+ })
61
+ }
62
+
63
+ /** 查询当前用户多因素认证状态。 */
64
+ GetMfaStatus(request: GetMfaStatusRequest): Promise<MfaStatusResponse> {
65
+ return http<MfaStatusResponse>({
66
+ url: MFA_URL,
67
+ method: 'GET',
68
+ authMode: 'required',
69
+ data: request,
70
+ })
71
+ }
72
+
73
+ /** 开始绑定当前用户的多因素认证方式。 */
74
+ BeginMfaSetup(request: BeginMfaSetupRequest): Promise<BeginMfaSetupResponse> {
75
+ return http<BeginMfaSetupResponse>({
76
+ url: `${MFA_URL}/setup`,
77
+ method: 'POST',
78
+ authMode: 'required',
79
+ data: request,
80
+ })
81
+ }
82
+
83
+ /** 确认绑定当前用户的多因素认证方式。 */
84
+ ConfirmMfaSetup(request: ConfirmMfaSetupRequest): Promise<ConfirmMfaSetupResponse> {
85
+ return http<ConfirmMfaSetupResponse>({
86
+ url: `${MFA_URL}/setup/confirm`,
87
+ method: 'POST',
88
+ authMode: 'required',
89
+ data: request,
90
+ })
91
+ }
92
+
93
+ /** 开始禁用 WebAuthn 多因素认证的 Passkey 验证。 */
94
+ BeginMfaDisable(request: BeginMfaDisableRequest): Promise<BeginMfaDisableResponse> {
95
+ return http<BeginMfaDisableResponse>({
96
+ url: `${MFA_URL}/disable/challenge`,
97
+ method: 'POST',
98
+ authMode: 'required',
99
+ data: request,
100
+ })
101
+ }
102
+
103
+ /** 禁用当前用户的多因素认证。 */
104
+ DisableMfa(request: DisableMfaRequest): Promise<Empty> {
105
+ return http<Empty>({
106
+ url: `${MFA_URL}/disable`,
107
+ method: 'POST',
108
+ authMode: 'required',
109
+ data: request,
110
+ })
111
+ }
112
+
113
+ /** 重新生成当前用户的多因素认证恢复码。 */
114
+ RegenerateMfaRecoveryCodes(
115
+ request: RegenerateMfaRecoveryCodesRequest,
116
+ ): Promise<RecoveryCodesResponse> {
117
+ return http<RecoveryCodesResponse>({
118
+ url: `${MFA_URL}/recovery-codes`,
119
+ method: 'POST',
120
+ authMode: 'required',
121
+ data: request,
122
+ })
123
+ }
124
+ }
125
+
126
+ /** defMfaService 多因素认证公共服务实例。 */
127
+ export const defMfaService = new MfaServiceImpl()
@@ -1,4 +1,4 @@
1
- import { http } from '../../utils/http'
1
+ import { http } from '../../../utils/http'
2
2
  import type {
3
3
  CreateOauthAuthorizationRequest,
4
4
  CreateOauthAuthorizationResponse,
@@ -19,8 +19,8 @@ import type {
19
19
  ListOauthProviderResponse,
20
20
  OauthService,
21
21
  UnbindOauthAccountRequest,
22
- } from '../../rpc/base/v1/oauth'
23
- import type { Empty } from '../../rpc/google/protobuf/empty'
22
+ } from '../../../rpc/base/v1/oauth'
23
+ import type { Empty } from '../../../rpc/google/protobuf/empty'
24
24
 
25
25
  const OAUTH_PROVIDER_URL = '/v1/base/oauth/provider'
26
26
  const OAUTH_AUTHORIZATION_URL = '/v1/base/oauth/authorization'
@@ -1,26 +1,20 @@
1
- import { http } from '../../utils/http'
1
+ import { http } from '../../../../utils/http'
2
2
  import {
3
3
  type AuthService,
4
4
  type BindUserPhoneRequest,
5
5
  type BindUserPhoneResponse,
6
+ type GetUserProfileRequest,
7
+ type UpdateUserProfileRequest,
6
8
  type UserProfileForm,
7
- } from '../../rpc/system/app/v1/auth'
8
- import type { Empty } from '../../rpc/google/protobuf/empty'
9
+ } from '../../../../rpc/system/app/v1/auth'
10
+ import type { Empty } from '../../../../rpc/google/protobuf/empty'
9
11
 
10
12
  const AUTH_URL = '/v1/app/auth'
11
13
 
12
- /** 获取用户资料请求兼容空请求结构。 */
13
- type GetUserProfileRequestCompat = Empty
14
-
15
- /** 更新用户资料请求兼容新旧两种表单包裹方式。 */
16
- type UpdateUserProfileRequestCompat = Partial<UserProfileForm> & {
17
- user_profile?: UserProfileForm
18
- }
19
-
20
14
  /** 用户认证资料服务 */
21
15
  export class AuthServiceImpl implements AuthService {
22
16
  /** 获取已登录用户资料 */
23
- GetUserProfile(request: GetUserProfileRequestCompat): Promise<UserProfileForm> {
17
+ GetUserProfile(request: GetUserProfileRequest): Promise<UserProfileForm> {
24
18
  return http<UserProfileForm>({
25
19
  url: `${AUTH_URL}/profile`,
26
20
  method: 'GET',
@@ -30,13 +24,12 @@ export class AuthServiceImpl implements AuthService {
30
24
  }
31
25
 
32
26
  /** 修改个人中心用户信息 */
33
- UpdateUserProfile(request: UpdateUserProfileRequestCompat): Promise<Empty> {
34
- const userProfile = request.user_profile ?? (request as UserProfileForm)
27
+ UpdateUserProfile(request: UpdateUserProfileRequest): Promise<Empty> {
35
28
  return http<Empty>({
36
29
  url: `${AUTH_URL}/profile`,
37
30
  method: 'PUT',
38
31
  authMode: 'required',
39
- data: userProfile,
32
+ data: request.user_profile,
40
33
  })
41
34
  }
42
35
 
@@ -1,9 +1,9 @@
1
- import { http } from '../../utils/http'
1
+ import { http } from '../../../../utils/http'
2
2
  import type {
3
3
  BaseAreaService,
4
4
  TreeBaseAreaRequest,
5
5
  TreeBaseAreaResponse,
6
- } from '../../rpc/system/app/v1/base_area'
6
+ } from '../../../../rpc/system/app/v1/base_area'
7
7
 
8
8
  const BASE_AREA_URL = '/v1/app/base/area'
9
9
 
@@ -0,0 +1,21 @@
1
+ import { http } from '../../../../utils/http'
2
+ import type {
3
+ BaseDictForm,
4
+ BaseDictService,
5
+ GetBaseDictRequest,
6
+ } from '../../../../rpc/system/app/v1/base_dict'
7
+
8
+ const BASE_DICT_URL = '/v1/app/base/dict'
9
+
10
+ /** 字典服务 */
11
+ export class BaseDictServiceImpl implements BaseDictService {
12
+ /** 查询单个字典 */
13
+ GetBaseDict(request: GetBaseDictRequest): Promise<BaseDictForm> {
14
+ return http<BaseDictForm>({
15
+ url: `${BASE_DICT_URL}/${request.code}`,
16
+ method: 'GET',
17
+ })
18
+ }
19
+ }
20
+
21
+ export const defBaseDictService = new BaseDictServiceImpl()
@@ -1,5 +1,5 @@
1
- import { http } from '../../utils/http'
2
- import type { ListBaseMenuResponse } from '../../rpc/system/app/v1/base_menu'
1
+ import { http } from '../../../../utils/http'
2
+ import type { ListBaseMenuResponse } from '../../../../rpc/system/app/v1/base_menu'
3
3
 
4
4
  /** 远端移动菜单服务。 */
5
5
  export class BaseMenuServiceImpl {
package/src/bootstrap.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from './locales'
10
10
  import { initializeAppNavigation } from './navigation'
11
11
  import { useSettingStore } from './stores'
12
- import { defLanguageService } from './api/base/language'
12
+ import { defLanguageService } from './api/base/v1/language'
13
13
 
14
14
  /** uni-app 启动参数。 */
15
15
  export interface KratosAppBootstrapOptions {
@@ -0,0 +1,292 @@
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/v1/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
+ min-width: 0;
244
+ justify-content: space-between;
245
+ padding-right: 42rpx;
246
+
247
+ > text:first-child {
248
+ flex-shrink: 0;
249
+ }
250
+ }
251
+
252
+ .locale-value {
253
+ flex: 1;
254
+ min-width: 0;
255
+ overflow: hidden;
256
+ margin-left: 24rpx;
257
+ color: #888;
258
+ text-align: right;
259
+ text-overflow: ellipsis;
260
+ white-space: nowrap;
261
+ }
262
+
263
+ .mfa-item {
264
+ display: flex;
265
+ align-items: center;
266
+ justify-content: space-between;
267
+ padding-right: 42rpx;
268
+ }
269
+
270
+ .mfa-value {
271
+ max-width: 55%;
272
+ overflow: hidden;
273
+ color: #888;
274
+ font-size: 26rpx;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
277
+ }
278
+
279
+ /* 操作按钮 */
280
+ .action {
281
+ text-align: center;
282
+ line-height: 90rpx;
283
+ margin-top: 40rpx;
284
+ font-size: 32rpx;
285
+ color: #333;
286
+ .button {
287
+ background-color: #fff;
288
+ margin-bottom: 20rpx;
289
+ border-radius: 10rpx;
290
+ }
291
+ }
292
+ </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,11 @@ 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
+ <view
28
+ v-if="item.viewKey === 'MESSAGE_INBOX' && messageBadge"
29
+ class="kratos-tab-bar__badge"
30
+ aria-hidden="true"
31
+ />
26
32
  <image
27
33
  v-if="resolveModuleIcon(item.icon)"
28
34
  class="kratos-tab-bar__icon"
@@ -33,6 +39,12 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
33
39
  "
34
40
  mode="aspectFit"
35
41
  />
42
+ <view
43
+ v-else-if="item.viewKey === 'MESSAGE_INBOX'"
44
+ class="kratos-tab-bar__message-icon"
45
+ :class="{ 'kratos-tab-bar__message-icon--active': activeMenu?.id === item.id }"
46
+ aria-hidden="true"
47
+ />
36
48
  <text :class="{ 'kratos-tab-bar__text--active': activeMenu?.id === item.id }">
37
49
  {{ item.title }}
38
50
  </text>
@@ -55,6 +67,7 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
55
67
  background: #fff;
56
68
  }
57
69
  .kratos-tab-bar__item {
70
+ position: relative;
58
71
  display: flex;
59
72
  flex: 1;
60
73
  align-items: center;
@@ -70,7 +83,64 @@ const visible = computed(() => Boolean(activeMenu.value && tabBar.value.length))
70
83
  height: 28px;
71
84
  margin-bottom: 4px;
72
85
  }
86
+ .kratos-tab-bar__message-icon {
87
+ position: relative;
88
+ width: 22px;
89
+ height: 18px;
90
+ margin-bottom: 6px;
91
+ border: 2px solid #9aa4b2;
92
+ border-radius: 6px;
93
+ box-sizing: border-box;
94
+ }
95
+ .kratos-tab-bar__message-icon::before {
96
+ position: absolute;
97
+ top: 5px;
98
+ left: 4px;
99
+ width: 3px;
100
+ height: 3px;
101
+ border-radius: 50%;
102
+ background: #9aa4b2;
103
+ box-shadow:
104
+ 5px 0 #9aa4b2,
105
+ 10px 0 #9aa4b2;
106
+ content: '';
107
+ }
108
+ .kratos-tab-bar__message-icon::after {
109
+ position: absolute;
110
+ bottom: -5px;
111
+ left: 3px;
112
+ width: 7px;
113
+ height: 7px;
114
+ border-right: 2px solid #9aa4b2;
115
+ border-bottom: 2px solid #9aa4b2;
116
+ background: #fff;
117
+ content: '';
118
+ transform: rotate(45deg);
119
+ }
120
+ .kratos-tab-bar__message-icon--active {
121
+ border-color: #27ba9b;
122
+ }
123
+ .kratos-tab-bar__message-icon--active::before {
124
+ background: #27ba9b;
125
+ box-shadow:
126
+ 5px 0 #27ba9b,
127
+ 10px 0 #27ba9b;
128
+ }
129
+ .kratos-tab-bar__message-icon--active::after {
130
+ border-color: #27ba9b;
131
+ }
73
132
  .kratos-tab-bar__text--active {
74
133
  color: #27ba9b;
75
134
  }
135
+ .kratos-tab-bar__badge {
136
+ position: absolute;
137
+ top: 0;
138
+ right: calc(50% - 19px);
139
+ width: 10px;
140
+ height: 10px;
141
+ box-sizing: border-box;
142
+ border: 2px solid #fff;
143
+ border-radius: 50%;
144
+ background: #e5484d;
145
+ }
76
146
  </style>