fix: 问题追踪附件改为图片展示,登录失败透出服务端原因
Sync source to CNB / Sync Git ref (push) Skipped

- 附件图片通过带凭据的请求拉取后以 blob URL 渲染,卸载时回收
- 皮肤站玩家登录失败时优先展示服务端返回的 errorMessage
This commit is contained in:
AxTps committed 2026-10-23 21:27:12 +08:00
1 parent 227af685d4
commit ec89ec5cec
3 files changed
+81 -9

No files matched your search

@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets' import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { import {
type SkinSiteBugComment, type SkinSiteBugComment,
skinSiteBugComments, skinSiteBugComments,
skinSiteBugImageFetch,
type SkinSiteBugIssue, type SkinSiteBugIssue,
skinSiteBugIssue, skinSiteBugIssue,
type SkinSiteBugIssueType, type SkinSiteBugIssueType,
@@ -169,6 +170,26 @@ function formatDateTime(ts: number): string {
const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0) const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0)
const imageUrls = ref<Record<string, string>>({})
function revokeImageUrls() {
for (const url of Object.values(imageUrls.value)) URL.revokeObjectURL(url)
imageUrls.value = {}
}
async function loadIssueImages(issue: SkinSiteBugIssue) {
revokeImageUrls()
const entries = await Promise.all(
(issue.images ?? []).map(async (image) => {
const blob = await skinSiteBugImageFetch(image.hash)
return blob ? [image.hash, URL.createObjectURL(blob)] : null
}),
)
const map: Record<string, string> = {}
for (const entry of entries) if (entry) map[entry[0]] = entry[1]
imageUrls.value = map
}
async function loadIssue() { async function loadIssue() {
loading.value = true loading.value = true
failed.value = false failed.value = false
@@ -176,6 +197,7 @@ async function loadIssue() {
const result = await skinSiteBugIssue(props.issueId) const result = await skinSiteBugIssue(props.issueId)
if (!result.success) throw new Error(result.errorMessage) if (!result.success) throw new Error(result.errorMessage)
issue.value = result.payload issue.value = result.payload
void loadIssueImages(result.payload)
} catch (error) { } catch (error) {
failed.value = true failed.value = true
handleError(error) handleError(error)
@@ -241,6 +263,8 @@ onMounted(() => {
void loadIssue() void loadIssue()
void loadComments(true) void loadComments(true)
}) })
onUnmounted(revokeImageUrls)
</script> </script>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@@ -328,13 +352,27 @@ onMounted(() => {
{{ formatMessage(messages.descriptionEmpty) }} {{ formatMessage(messages.descriptionEmpty) }}
</p> </p>
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2"> <div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
<span <a
v-for="image in issue.images" v-for="image in issue.images"
:key="image.hash" :key="image.hash"
class="rounded-lg bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary" :href="imageUrls[image.hash]"
target="_blank"
rel="noopener noreferrer"
class="block overflow-hidden rounded-lg border border-solid border-surface-5"
> >
{{ image.hash }} <img
</span> v-if="imageUrls[image.hash]"
:src="imageUrls[image.hash]"
alt="attachment"
class="max-h-48 max-w-full object-contain"
/>
<span
v-else
class="block bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary"
>
{{ image.hash }}
</span>
</a>
</div> </div>
</div> </div>
@@ -692,6 +692,24 @@ export interface SkinSiteBugImage {
[key: string]: unknown [key: string]: unknown
} }
/** Download a bug attachment image. The endpoint requires auth, so the image
* must be fetched as a blob and rendered through an object URL. */
export async function skinSiteBugImageFetch(hash: string): Promise<Blob | null> {
const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/bug/images/${encodeURIComponent(hash)}`
const headers: Record<string, string> = { Accept: 'image/*' }
const token = getSkinSiteToken()
if (token) headers['Authorization'] = `Bearer ${token}`
try {
const response = await tauriFetch(url, { method: 'GET', headers })
if (!response.ok) return null
const type = response.headers.get('content-type') ?? ''
if (type.includes('application/json') || type.includes('text/')) return null
return await response.blob()
} catch {
return null
}
}
export interface SkinSiteBugIssue { export interface SkinSiteBugIssue {
id: number id: number
type: SkinSiteBugIssueType type: SkinSiteBugIssueType
@@ -629,10 +629,26 @@ pub async fn login_skin_site_player(
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(ErrorKind::InputError(format!( let status = response.status().as_u16();
"皮肤站玩家登录失败(HTTP {}),请检查登录状态后重试", // 皮肤站的错误响应体形如 {"error": "...", "errorMessage": "..."},
response.status().as_u16() // 优先透出服务端给出的具体原因(例如正版玩家需用正版账号登录)。
)) let detail = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|value| {
value
.get("errorMessage")
.and_then(|msg| msg.as_str())
.map(str::to_string)
})
.filter(|msg| !msg.trim().is_empty());
return Err(ErrorKind::InputError(match detail {
Some(msg) => format!("皮肤站玩家登录失败:{msg}(HTTP {status})"),
None => format!(
"皮肤站玩家登录失败(HTTP {status}),请检查登录状态后重试"
),
})
.as_error()); .as_error());
} }
let login = response.json::<Envelope>().await?.payload; let login = response.json::<Envelope>().await?.payload;