- 附件图片通过带凭据的请求拉取后以 blob URL 渲染,卸载时回收 - 皮肤站玩家登录失败时优先展示服务端返回的 errorMessage
This commit is contained in:
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;
|
||||||
|
|||||||
Reference in new issue
Block a user