fix(banners): save relative storage paths so admin preview survives refresh

Uploads were stored as host-specific URLs and a force-reload could wipe a just-saved image, leaving a broken dark-theme preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 18:01:26 +03:30
co-authored by Cursor
parent 3d63e2cf7f
commit 464995a43e
6 changed files with 142 additions and 26 deletions
+17 -4
View File
@@ -14,8 +14,13 @@ import {
function pickConstRow(res: Const | Const[] | null | undefined): Const | null {
if (!res) return null
if (Array.isArray(res)) {
const first = (res[0] as Const) ?? null
return isMeaningfulConstRow(first) ? first : null
const rows = res.filter(isMeaningfulConstRow)
if (!rows.length) return null
return rows.reduce((best, row) => {
const a = Number(best.id ?? best.const_id ?? 0)
const b = Number(row.id ?? row.const_id ?? 0)
return b > a ? row : best
})
}
return isMeaningfulConstRow(res) ? res : null
}
@@ -159,8 +164,16 @@ export async function saveAppBannersConst(
}
let nextId = id
if (id) {
await useAxios.put<Const>(`/admin/const/${id}`, { id, ...payload })
try {
const existingRes = await useAxios.get<Const | Const[]>(`/api/const/key/${APP_BANNERS_CONST_KEY}`)
const existing = pickConstRow(existingRes)
nextId = existing?.id ?? existing?.const_id ?? id
} catch {
// create below
}
if (nextId) {
await useAxios.put<Const>(`/admin/const/${nextId}`, { id: nextId, ...payload })
} else {
const created = await useAxios.post<Const>(`/admin/const`, { id: 0, ...payload })
nextId = created?.id ?? created?.const_id ?? null
+40 -5
View File
@@ -2,7 +2,7 @@ import { reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { loadAppBannersConst, saveAppBannersConst } from '@/api/appBanners'
import { emptyAppBanners, type AppBannersValue, type ThemeImagePair } from '@/model/appBanners'
import { toAbsoluteFileUrl } from '@/utils/file'
import { toAbsoluteFileUrl, toStoredStoragePath } from '@/utils/file'
import type { IBenefits } from '@/model/banner'
const state = reactive<AppBannersValue>(emptyAppBanners())
@@ -13,10 +13,36 @@ const saving = ref(false)
let loadPromise: Promise<void> | null = null
let saveTimer: ReturnType<typeof setTimeout> | null = null
let saveChain: Promise<boolean> = Promise.resolve(true)
let localRevision = 0
function absolutizePair(pair: ThemeImagePair): ThemeImagePair {
return {
light: toAbsoluteFileUrl(pair.light) ?? pair.light,
dark: toAbsoluteFileUrl(pair.dark) ?? pair.dark,
}
}
function applyLoadedData(data: AppBannersValue) {
const next = emptyAppBanners()
Object.assign(next, data)
next.homeAsset = absolutizePair(next.homeAsset)
next.piggyCreate = absolutizePair(next.piggyCreate)
next.productsHero1 = absolutizePair(next.productsHero1)
next.productsHero2 = absolutizePair(next.productsHero2)
next.productsHero3 = absolutizePair(next.productsHero3)
next.referral = absolutizePair(next.referral)
next.share = absolutizePair(next.share)
next.invite = absolutizePair(next.invite)
next.slider = (next.slider ?? []).map((slide) => ({
...slide,
icon: toAbsoluteFileUrl(slide.icon) ?? slide.icon,
}))
Object.assign(state, next)
}
function httpsUrl(raw: string | null | undefined): string | null {
if (typeof raw !== 'string' || !raw.trim()) return null
return toAbsoluteFileUrl(raw.trim()) ?? raw.trim()
return toStoredStoragePath(raw.trim())
}
function httpsPair(pair: ThemeImagePair): ThemeImagePair {
@@ -48,14 +74,22 @@ async function load(force = false) {
if (!force && loaded.value) return
if (!force && loadPromise) return loadPromise
const rev = localRevision
loadPromise = (async () => {
loading.value = true
try {
const res = await loadAppBannersConst()
if (rev !== localRevision) {
return
}
constId.value = res.id
Object.assign(state, emptyAppBanners(), res.data)
applyLoadedData(res.data)
loaded.value = true
} catch (error) {
if (rev !== localRevision) {
return
}
console.error('خطا در دریافت بنرهای اپ:', error)
Object.assign(state, emptyAppBanners())
} finally {
@@ -84,13 +118,14 @@ async function save(successText = 'بنرهای اپ ذخیره شد') {
}
}
/** آپلود/حذف را با تأخیر کوتاه در const می‌نویسد تا رفرش تصویر را برنگرداند. */
/** آپلود/حذف را سریع در const می‌نویسد تا رفرش تصویر را برنگرداند. */
function saveSoon(successText = 'بنر ذخیره شد') {
localRevision += 1
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = null
saveChain = saveChain.then(() => save(successText))
}, 450)
}, 80)
}
export function useAppBannersAdmin() {
+2 -2
View File
@@ -90,11 +90,11 @@ function parseSlider(raw: unknown): IBenefits[] {
return []
}
export function parseAppBanners(value: string | null | undefined): AppBannersValue {
export function parseAppBanners(value: string | Record<string, unknown> | null | undefined): AppBannersValue {
const fallback = emptyAppBanners()
if (!value) return fallback
try {
const parsed = JSON.parse(value)
const parsed = typeof value === 'string' ? JSON.parse(value) : value
if (!parsed || typeof parsed !== 'object') return fallback
// فرمت قدیمی: فقط آرایهٔ اسلایدر (header_banner)
@@ -26,7 +26,15 @@
</a-upload>
<div v-else class="banner-image-upload__filled">
<img :src="displayUrl" alt="" class="banner-image-upload__img" />
<img
:src="displayUrl"
alt=""
class="banner-image-upload__img"
@error="onPreviewError"
/>
<div v-if="previewFailed" class="banner-image-upload__error">
تصویر ذخیره شده ولی فایل روی سرور پیدا نشد
</div>
<div class="banner-image-upload__bar">
<a-upload
name="file"
@@ -59,7 +67,7 @@ import { LoadingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { uploadURL } from '@root/config'
import { getToken } from '@/utils'
import { normalizeUploadFileResponse, toAbsoluteFileUrl } from '@/utils/file'
import { fileUrlCandidates, normalizeUploadFileResponse, toAbsoluteFileUrl } from '@/utils/file'
import {
createImageBeforeUpload,
createImageCustomRequest
@@ -75,7 +83,19 @@ const emit = defineEmits<{
remove: []
}>()
const displayUrl = computed(() => toAbsoluteFileUrl(props.imageUrl) ?? props.imageUrl ?? '')
const previewFailed = ref(false)
const candidateIndex = ref(0)
const candidates = computed(() => fileUrlCandidates(props.imageUrl))
const displayUrl = computed(() => candidates.value[candidateIndex.value] ?? '')
watch(
() => props.imageUrl,
() => {
previewFailed.value = false
candidateIndex.value = 0
},
)
const loading = ref(false)
@@ -114,6 +134,14 @@ function onChange({ file }: { file: { status?: string } }) {
loading.value = true
}
}
function onPreviewError() {
if (candidateIndex.value < candidates.value.length - 1) {
candidateIndex.value += 1
return
}
previewFailed.value = true
}
</script>
<style scoped lang="less">
@@ -209,6 +237,19 @@ function onChange({ file }: { file: { status?: string } }) {
display: block;
}
.banner-image-upload__error {
position: absolute;
inset: 0 0 3.5rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
color: rgba(255, 255, 255, 0.88);
font-size: 0.8125rem;
text-align: center;
pointer-events: none;
}
.banner-image-upload__bar {
position: absolute;
inset: auto 0 0 0;
+1 -1
View File
@@ -161,7 +161,7 @@ watch(
)
onMounted(() => {
void load(true)
void load()
})
const typeList = ref([
+38 -11
View File
@@ -80,36 +80,63 @@ export function preferHttpsFileUrl(url: string): string {
/**
* Turns API paths like "storage/1405/02/27/file.jpg" into a full URL.
* Absolute URLs that already contain /storage/ are rebuilt onto the API base
* so preview/theme never keep a host without /server.
*/
export function toAbsoluteFileUrl(path?: string | null): string | undefined {
if (!path?.trim()) return undefined
const trimmed = path.trim()
const storageMatch = trimmed.match(/(?:^|\/)storage\/(.+)$/i)
const base = getFileBaseUrl()
if (storageMatch && base) {
return preferHttpsFileUrl(`${ensureServerBase(base)}/storage/${storageMatch[1]}`)
}
if (/^https?:\/\//i.test(trimmed)) {
return preferHttpsFileUrl(trimmed)
}
const storageMatch = trimmed.match(/(?:^|\/)storage\/(.+)$/i)
if (storageMatch) {
const base = getFileBaseUrl()
if (base) {
return preferHttpsFileUrl(`${base}/storage/${storageMatch[1]}`)
}
}
if (/^[a-z0-9.-]+(?:\/server)?\/storage\//i.test(trimmed)) {
return `https://${trimmed.replace(/^\/+/, '')}`
}
const base = getFileBaseUrl()
if (!base) return undefined
if (trimmed.startsWith('/')) {
return preferHttpsFileUrl(`${base}${trimmed}`)
return preferHttpsFileUrl(`${ensureServerBase(base)}${trimmed}`)
}
return preferHttpsFileUrl(`${base}/${trimmed.replace(/^\//, '')}`)
return preferHttpsFileUrl(`${ensureServerBase(base)}/${trimmed.replace(/^\//, '')}`)
}
function ensureServerBase(base: string): string {
const trimmed = base.replace(/\/$/, '')
if (/\/(server|api)$/i.test(trimmed) || /\/(server|api)\//i.test(trimmed)) {
return trimmed
}
return `${trimmed}/server`
}
/** مسیر نسبی storage برای ذخیره در const — مستقل از دامنهٔ پنل */
export function toStoredStoragePath(path?: string | null): string | null {
if (!path?.trim()) return null
const match = path.trim().match(/(?:^|\/)(storage\/.+)$/i)
return match ? match[1].replace(/^\/+/, '') : path.trim()
}
/** اگر فایل WebP باشد و URL با .png ذخیره شده باشد، پسوندهای جایگزین را هم امتحان کن. */
export function fileUrlCandidates(url?: string | null): string[] {
const absolute = toAbsoluteFileUrl(url) ?? url?.trim() ?? ''
if (!absolute) return []
const match = absolute.match(/^(.*)\.(png|jpe?g|webp|gif)(\?.*)?$/i)
if (!match) return [absolute]
const stem = match[1]
const current = match[2].toLowerCase()
const query = match[3] ?? ''
const alts = ['webp', 'png', 'jpg', 'jpeg', 'gif']
return [absolute, ...alts.filter((ext) => ext !== current).map((ext) => `${stem}.${ext}${query}`)]
}
function isBareFileName(value: string): boolean {