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:
+17
-4
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -161,7 +161,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void load(true)
|
||||
void load()
|
||||
})
|
||||
|
||||
const typeList = ref([
|
||||
|
||||
+38
-11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user