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 {
|
function pickConstRow(res: Const | Const[] | null | undefined): Const | null {
|
||||||
if (!res) return null
|
if (!res) return null
|
||||||
if (Array.isArray(res)) {
|
if (Array.isArray(res)) {
|
||||||
const first = (res[0] as Const) ?? null
|
const rows = res.filter(isMeaningfulConstRow)
|
||||||
return isMeaningfulConstRow(first) ? first : null
|
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
|
return isMeaningfulConstRow(res) ? res : null
|
||||||
}
|
}
|
||||||
@@ -159,8 +164,16 @@ export async function saveAppBannersConst(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nextId = id
|
let nextId = id
|
||||||
if (id) {
|
try {
|
||||||
await useAxios.put<Const>(`/admin/const/${id}`, { id, ...payload })
|
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 {
|
} else {
|
||||||
const created = await useAxios.post<Const>(`/admin/const`, { id: 0, ...payload })
|
const created = await useAxios.post<Const>(`/admin/const`, { id: 0, ...payload })
|
||||||
nextId = created?.id ?? created?.const_id ?? null
|
nextId = created?.id ?? created?.const_id ?? null
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { reactive, ref } from 'vue'
|
|||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { loadAppBannersConst, saveAppBannersConst } from '@/api/appBanners'
|
import { loadAppBannersConst, saveAppBannersConst } from '@/api/appBanners'
|
||||||
import { emptyAppBanners, type AppBannersValue, type ThemeImagePair } from '@/model/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'
|
import type { IBenefits } from '@/model/banner'
|
||||||
|
|
||||||
const state = reactive<AppBannersValue>(emptyAppBanners())
|
const state = reactive<AppBannersValue>(emptyAppBanners())
|
||||||
@@ -13,10 +13,36 @@ const saving = ref(false)
|
|||||||
let loadPromise: Promise<void> | null = null
|
let loadPromise: Promise<void> | null = null
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let saveChain: Promise<boolean> = Promise.resolve(true)
|
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 {
|
function httpsUrl(raw: string | null | undefined): string | null {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) return null
|
if (typeof raw !== 'string' || !raw.trim()) return null
|
||||||
return toAbsoluteFileUrl(raw.trim()) ?? raw.trim()
|
return toStoredStoragePath(raw.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpsPair(pair: ThemeImagePair): ThemeImagePair {
|
function httpsPair(pair: ThemeImagePair): ThemeImagePair {
|
||||||
@@ -48,14 +74,22 @@ async function load(force = false) {
|
|||||||
if (!force && loaded.value) return
|
if (!force && loaded.value) return
|
||||||
if (!force && loadPromise) return loadPromise
|
if (!force && loadPromise) return loadPromise
|
||||||
|
|
||||||
|
const rev = localRevision
|
||||||
|
|
||||||
loadPromise = (async () => {
|
loadPromise = (async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await loadAppBannersConst()
|
const res = await loadAppBannersConst()
|
||||||
|
if (rev !== localRevision) {
|
||||||
|
return
|
||||||
|
}
|
||||||
constId.value = res.id
|
constId.value = res.id
|
||||||
Object.assign(state, emptyAppBanners(), res.data)
|
applyLoadedData(res.data)
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (rev !== localRevision) {
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error('خطا در دریافت بنرهای اپ:', error)
|
console.error('خطا در دریافت بنرهای اپ:', error)
|
||||||
Object.assign(state, emptyAppBanners())
|
Object.assign(state, emptyAppBanners())
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,13 +118,14 @@ async function save(successText = 'بنرهای اپ ذخیره شد') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** آپلود/حذف را با تأخیر کوتاه در const مینویسد تا رفرش تصویر را برنگرداند. */
|
/** آپلود/حذف را سریع در const مینویسد تا رفرش تصویر را برنگرداند. */
|
||||||
function saveSoon(successText = 'بنر ذخیره شد') {
|
function saveSoon(successText = 'بنر ذخیره شد') {
|
||||||
|
localRevision += 1
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
saveTimer = null
|
saveTimer = null
|
||||||
saveChain = saveChain.then(() => save(successText))
|
saveChain = saveChain.then(() => save(successText))
|
||||||
}, 450)
|
}, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppBannersAdmin() {
|
export function useAppBannersAdmin() {
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ function parseSlider(raw: unknown): IBenefits[] {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAppBanners(value: string | null | undefined): AppBannersValue {
|
export function parseAppBanners(value: string | Record<string, unknown> | null | undefined): AppBannersValue {
|
||||||
const fallback = emptyAppBanners()
|
const fallback = emptyAppBanners()
|
||||||
if (!value) return fallback
|
if (!value) return fallback
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value)
|
const parsed = typeof value === 'string' ? JSON.parse(value) : value
|
||||||
if (!parsed || typeof parsed !== 'object') return fallback
|
if (!parsed || typeof parsed !== 'object') return fallback
|
||||||
|
|
||||||
// فرمت قدیمی: فقط آرایهٔ اسلایدر (header_banner)
|
// فرمت قدیمی: فقط آرایهٔ اسلایدر (header_banner)
|
||||||
|
|||||||
@@ -26,7 +26,15 @@
|
|||||||
</a-upload>
|
</a-upload>
|
||||||
|
|
||||||
<div v-else class="banner-image-upload__filled">
|
<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">
|
<div class="banner-image-upload__bar">
|
||||||
<a-upload
|
<a-upload
|
||||||
name="file"
|
name="file"
|
||||||
@@ -59,7 +67,7 @@ import { LoadingOutlined } from '@ant-design/icons-vue'
|
|||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { uploadURL } from '@root/config'
|
import { uploadURL } from '@root/config'
|
||||||
import { getToken } from '@/utils'
|
import { getToken } from '@/utils'
|
||||||
import { normalizeUploadFileResponse, toAbsoluteFileUrl } from '@/utils/file'
|
import { fileUrlCandidates, normalizeUploadFileResponse, toAbsoluteFileUrl } from '@/utils/file'
|
||||||
import {
|
import {
|
||||||
createImageBeforeUpload,
|
createImageBeforeUpload,
|
||||||
createImageCustomRequest
|
createImageCustomRequest
|
||||||
@@ -75,7 +83,19 @@ const emit = defineEmits<{
|
|||||||
remove: []
|
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)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -114,6 +134,14 @@ function onChange({ file }: { file: { status?: string } }) {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPreviewError() {
|
||||||
|
if (candidateIndex.value < candidates.value.length - 1) {
|
||||||
|
candidateIndex.value += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewFailed.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@@ -209,6 +237,19 @@ function onChange({ file }: { file: { status?: string } }) {
|
|||||||
display: block;
|
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 {
|
.banner-image-upload__bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: auto 0 0 0;
|
inset: auto 0 0 0;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load(true)
|
void load()
|
||||||
})
|
})
|
||||||
|
|
||||||
const typeList = ref([
|
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.
|
* 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 {
|
export function toAbsoluteFileUrl(path?: string | null): string | undefined {
|
||||||
if (!path?.trim()) return undefined
|
if (!path?.trim()) return undefined
|
||||||
|
|
||||||
const trimmed = path.trim()
|
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)) {
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
return preferHttpsFileUrl(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)) {
|
if (/^[a-z0-9.-]+(?:\/server)?\/storage\//i.test(trimmed)) {
|
||||||
return `https://${trimmed.replace(/^\/+/, '')}`
|
return `https://${trimmed.replace(/^\/+/, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = getFileBaseUrl()
|
|
||||||
if (!base) return undefined
|
if (!base) return undefined
|
||||||
|
|
||||||
if (trimmed.startsWith('/')) {
|
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 {
|
function isBareFileName(value: string): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user