Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/SkinSiteCaptchaModal.vue
T
AxTps 00f0f111d8
Sync source to CNB / Sync Git ref (push) Skipped
feat: 新增皮肤站原生登录与验证码弹窗
登录入口原先会切到主页并打开内嵌网页。改为弹出启动器自己的登录弹窗:

- SkinSiteLoginModal:账号密码校验规则与皮肤站一致,登录前走验证码,
  成功后保存 JWT 并刷新会话。
- SkinSiteCaptchaModal:拉取旋转图片验证码,用滑块调整角度后提交。
- AccountsCard 的登录按钮改为打开该弹窗,登录成功后重新拉取玩家列表。
- 修正 verifyCaptcha 的参数:服务端校验的是旋转角度 degree,不是文字答案。
2026-10-17 18:42:34 +08:00

156 lines
3.8 KiB
Vue

<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { requestCaptcha, verifyCaptcha } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.captcha.title', defaultMessage: 'Let us check you are human' },
subtitle: {
id: 'app.skin-site.captcha.subtitle',
defaultMessage: 'Drag the slider to rotate the image upright.',
},
submit: { id: 'app.skin-site.captcha.submit', defaultMessage: 'Submit' },
loading: { id: 'app.skin-site.captcha.loading', defaultMessage: 'Loading captcha…' },
})
const emit = defineEmits<{ (e: 'verified', clientId: string): void; (e: 'cancel'): void }>()
const modal = ref<InstanceType<typeof NewModal>>()
const loading = ref(false)
const submitting = ref(false)
const image = ref('')
const challengeClientId = ref('')
const degree = ref(0)
/** The image is rotated by the server; the slider counter-rotates the preview. */
const previewRotation = computed(() => `${degree.value}deg`)
function resetState() {
degree.value = 0
image.value = ''
challengeClientId.value = ''
}
async function loadCaptcha() {
loading.value = true
try {
const result = await requestCaptcha()
if (!result.success) throw new Error(result.errorMessage)
image.value = result.payload.image ?? ''
challengeClientId.value = result.payload.clientId
degree.value = 0
} catch (error) {
handleError(error)
modal.value?.hide()
emit('cancel')
} finally {
loading.value = false
}
}
async function show() {
resetState()
modal.value?.show()
await loadCaptcha()
}
async function submit() {
if (submitting.value || !challengeClientId.value) return
submitting.value = true
try {
const result = await verifyCaptcha({
clientId: challengeClientId.value,
degree: degree.value,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
await loadCaptcha()
return
}
modal.value?.hide()
emit('verified', result.payload)
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="resetState">
<div class="flex flex-col items-center gap-6">
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
<div
class="size-44 overflow-hidden rounded-full border-4 border-solid border-bg-raised bg-button-bg"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover transition-transform duration-75"
:style="{ transform: `rotate(${previewRotation})` }"
draggable="false"
/>
</div>
<div class="w-full max-w-sm">
<input
v-model.number="degree"
type="range"
min="0"
max="360"
step="1"
class="w-full accent-brand"
:disabled="loading || submitting"
/>
<div class="mt-1 flex justify-between text-xs text-secondary">
<span>0°</span>
<span>90°</span>
<span>180°</span>
<span>270°</span>
<span>360°</span>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast"
:disabled="loading || submitting || !image"
@click="submit"
>
{{ formatMessage(loading ? messages.loading : messages.submit) }}
</button>
</div>
</template>
</NewModal>
</template>