- 附件图片通过带凭据的请求拉取后以 blob URL 渲染,卸载时回收 - 皮肤站玩家登录失败时优先展示服务端返回的 errorMessage
This commit is contained in:
1 parent
227af685d4
commit
ec89ec5cec
3 files changed
+79
-7
No files matched your search
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteBugComment,
|
||||
skinSiteBugComments,
|
||||
skinSiteBugImageFetch,
|
||||
type SkinSiteBugIssue,
|
||||
skinSiteBugIssue,
|
||||
type SkinSiteBugIssueType,
|
||||
@@ -169,6 +170,26 @@ function formatDateTime(ts: number): string {
|
||||
|
||||
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() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
@@ -176,6 +197,7 @@ async function loadIssue() {
|
||||
const result = await skinSiteBugIssue(props.issueId)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
issue.value = result.payload
|
||||
void loadIssueImages(result.payload)
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
@@ -241,6 +263,8 @@ onMounted(() => {
|
||||
void loadIssue()
|
||||
void loadComments(true)
|
||||
})
|
||||
|
||||
onUnmounted(revokeImageUrls)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -328,13 +352,27 @@ onMounted(() => {
|
||||
{{ formatMessage(messages.descriptionEmpty) }}
|
||||
</p>
|
||||
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
<a
|
||||
v-for="image in issue.images"
|
||||
: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"
|
||||
>
|
||||
<img
|
||||
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>
|
||||
|
||||
|
||||
@@ -692,6 +692,24 @@ export interface SkinSiteBugImage {
|
||||
[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 {
|
||||
id: number
|
||||
type: SkinSiteBugIssueType
|
||||
|
||||
@@ -629,10 +629,26 @@ pub async fn login_skin_site_player(
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ErrorKind::InputError(format!(
|
||||
"皮肤站玩家登录失败(HTTP {}),请检查登录状态后重试",
|
||||
response.status().as_u16()
|
||||
))
|
||||
let status = response.status().as_u16();
|
||||
// 皮肤站的错误响应体形如 {"error": "...", "errorMessage": "..."},
|
||||
// 优先透出服务端给出的具体原因(例如正版玩家需用正版账号登录)。
|
||||
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());
|
||||
}
|
||||
let login = response.json::<Envelope>().await?.payload;
|
||||
|
||||
Reference in new issue
Block a user