Files
hfb_sys/frontend/src/features/admin/views/AdminListingDetailView.vue
T
2026-06-17 22:58:18 +08:00

1001 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
import { fetchAdminFileBlob } from '@/shared/api/files'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import {
adminMarkListingAbnormal,
adminOfflineListing,
adminTransferListingOwner,
fetchAdminListing,
type Listing,
} from '@/features/listings'
import {
listingReviewStatusLabel,
listingStatusLabel,
realnameStatusLabel,
userStatusLabel,
} from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingResources,
getOnlineTimeText,
getResourceQuantity,
getSkinNames,
formatListingCode,
readAssetNumber,
readAssetString,
} from '@/shared/utils/listingDisplay'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const userSearching = ref(false)
const listing = ref<Listing | null>(null)
const actionType = ref<'offline' | 'abnormal' | ''>('')
const reason = ref('')
const transferVisible = ref(false)
const transferReason = ref('')
const userKeyword = ref('')
const userOptions = ref<AdminUserItem[]>([])
const selectedUser = ref<AdminUserItem | null>(null)
const actionTitle = computed(() =>
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
)
const canOperate = computed(
() =>
!!listing.value &&
listing.value.status !== 'rented' &&
!['offline', 'abnormal'].includes(listing.value.status)
)
const canTransfer = computed(() => !!listing.value && listing.value.status !== 'rented')
const selectedUserAvailable = computed(
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
)
const assetRows = computed(() => {
if (!listing.value) return []
return [
{ label: '商品编号', value: formatListingCode(listing.value) },
{ label: '账号 ID', value: listing.value.account_id },
{ label: '游戏', value: listing.value.game_name || '-' },
{ label: '区服', value: listing.value.server_region || '-' },
{ label: '平台', value: listing.value.login_platform || '-' },
{ label: '段位', value: listing.value.rank_level || '-' },
{ label: '烽火等级', value: assetNumberText(listing.value, 'fire_level') },
{ label: '保险格数', value: assetText(listing.value, 'season_insurance') },
{
label: '体力/负重',
value: `${assetText(listing.value, 'stamina_level')} / ${assetText(listing.value, 'load_level')}`,
},
{ label: '绝密 KD', value: assetNumberText(listing.value, 'secret_kd') },
{ label: '常用地区', value: regionText(listing.value) },
{ label: '在线时间', value: getOnlineTimeText(listing.value) || '-' },
]
})
const ownerRows = computed(() => {
if (!listing.value) return []
return [
{
label: '号主',
value: listing.value.owner_phone || listing.value.owner_nickname || listing.value.owner_id,
},
{ label: '号主 ID', value: listing.value.owner_id },
{ label: '价格', value: listingPrice(listing.value) },
{ label: '押金', value: moneyCent(listing.value.deposit_amount_cent) },
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(listing.value)) },
{ label: '每日损耗', value: getDailyLoss(listing.value) || '-' },
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
{
label: '额外消耗',
value: moneyCent(Math.round(getListingConsumablePrice(listing.value) * 100)),
},
]
})
const resourceCards = computed(() => {
if (!listing.value) return []
const core = [
{ key: 'awmAmmo', label: 'AWM 子弹', quantity: getResourceQuantity(listing.value, 'awmAmmo') },
{ key: 'helmet6', label: '六级头', quantity: getResourceQuantity(listing.value, 'helmet6') },
{ key: 'armor6', label: '六级甲', quantity: getResourceQuantity(listing.value, 'armor6') },
].filter(item => item.quantity > 0)
const extra = getListingResources(listing.value)
.filter(item => !core.some(coreItem => coreItem.key === item.key))
.slice(0, 6)
.map(item => ({ key: item.key, label: item.label, quantity: item.quantity }))
return [...core, ...extra]
})
const skinNames = computed(() => (listing.value ? getSkinNames(listing.value).slice(0, 16) : []))
const hiddenSkinCount = computed(() => {
if (!listing.value) return 0
return Math.max(0, getSkinNames(listing.value).length - skinNames.value.length)
})
const statusTone = computed(() => {
if (!listing.value) return 'info'
if (listing.value.status === 'published') return 'success'
if (['offline', 'abnormal'].includes(listing.value.status)) return 'danger'
if (listing.value.status === 'rented') return 'warning'
return 'info'
})
const reviewTone = computed(() => {
if (!listing.value) return 'info'
if (listing.value.review_status === 'approved') return 'success'
if (listing.value.review_status === 'rejected') return 'danger'
if (listing.value.review_status === 'pending') return 'warning'
return 'info'
})
onMounted(loadListing)
async function loadListing() {
loading.value = true
try {
listing.value = await fetchAdminListing(String(route.params.id))
} finally {
loading.value = false
}
}
async function copyListingCode() {
if (!listing.value) return
try {
await navigator.clipboard.writeText(formatListingCode(listing.value))
ElMessage.success('商品编号已复制')
} catch {
ElMessage.error('复制失败')
}
}
function openAction(type: 'offline' | 'abnormal') {
actionType.value = type
reason.value = ''
}
function openTransfer() {
transferVisible.value = true
transferReason.value = ''
userKeyword.value = ''
selectedUser.value = null
userOptions.value = []
}
async function searchUsers() {
userSearching.value = true
try {
const result = await fetchAdminUsers(1, 8, {
keyword: userKeyword.value.trim(),
status: 'active',
})
userOptions.value = result.items.filter(user => user.realname_status === 'verified')
} catch (error) {
ElMessage.error(readError(error, '查询用户失败'))
} finally {
userSearching.value = false
}
}
function selectUser(user: AdminUserItem) {
selectedUser.value = user
}
async function submitAction() {
if (!listing.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'offline') {
listing.value = await adminOfflineListing(listing.value.id, reason.value)
ElMessage.success('商品已强制下架')
} else {
listing.value = await adminMarkListingAbnormal(listing.value.id, reason.value)
ElMessage.success('商品已标记异常')
}
actionType.value = ''
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
async function submitTransferOwner() {
if (!listing.value || !selectedUser.value) return
if (!selectedUserAvailable.value) {
ElMessage.warning('只能转移给正常且已实名的用户')
return
}
if (!transferReason.value.trim()) {
ElMessage.warning('请填写转移原因')
return
}
submitting.value = true
try {
listing.value = await adminTransferListingOwner(
listing.value.id,
selectedUser.value.id,
transferReason.value.trim()
)
ElMessage.success('所有权已转移')
transferVisible.value = false
} catch (error) {
ElMessage.error(readError(error, '转移失败'))
} finally {
submitting.value = false
}
}
function moneyCent(value: number) {
return formatCentWithSymbol(value)
}
function listingPrice(row: Listing) {
return moneyCent(row.price_cent)
}
function assetText(row: Listing, key: string) {
const value = readAssetString(row, key)
if (value) return value
const numberValue = readAssetNumber(row, key)
return numberValue > 0 ? String(numberValue) : '-'
}
function assetNumberText(row: Listing, key: string) {
const value = readAssetNumber(row, key)
if (value <= 0) return '-'
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
function regionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function openScreenshot(url: string) {
try {
const key = extractObjectKey(url)
if (!key) {
window.open(url, '_blank')
return
}
const blob = await fetchAdminFileBlob(key)
window.open(URL.createObjectURL(blob), '_blank')
} catch {
window.open(url, '_blank')
}
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="listing" class="listing-detail-header">
<div class="listing-title-block">
<div class="listing-code-line">
<span>商品编号</span>
<strong>{{ formatListingCode(listing) }}</strong>
</div>
<h1>{{ listing.title || '商品详情' }}</h1>
<div class="listing-subtitle">
<span>{{ listing.server_region || '-' }}</span>
<span>{{ listing.rank_level || '-' }}</span>
<span>{{ listing.login_platform || '-' }}</span>
<span>账号 {{ listing.account_id }}</span>
</div>
</div>
<div class="toolbar-actions">
<el-button :icon="CopyDocument" @click="copyListingCode">复制编号</el-button>
<RouterLink :to="adminPath('listings')">
<el-button>返回列表</el-button>
</RouterLink>
<el-button
class="transfer-owner-trigger"
type="success"
:disabled="!canTransfer"
@click="openTransfer"
>转移所有权</el-button
>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
>强制下架</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
</div>
</div>
<div v-if="listing" class="listing-summary-bar">
<div>
<span>商品状态</span>
<el-tag :type="statusTone" effect="light">{{ listingStatusLabel(listing.status) }}</el-tag>
</div>
<div>
<span>审核状态</span>
<el-tag :type="reviewTone" effect="light">{{
listingReviewStatusLabel(listing.review_status)
}}</el-tag>
</div>
<div>
<span>租金</span>
<strong>{{ listingPrice(listing) }}</strong>
</div>
<div>
<span>押金</span>
<strong>{{ moneyCent(listing.deposit_amount_cent) }}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{{ formatHafCoinM(getCoinWan(listing)) }}</strong>
</div>
<div>
<span>预计可租</span>
<strong>{{ formatEstimatedRentalDuration(listing) }}</strong>
</div>
</div>
<div v-if="listing" class="listing-workspace">
<section class="detail-panel primary-panel">
<div class="panel-heading">
<h2>账号属性</h2>
<span>{{ listing.game_name || 'delta_force' }}</span>
</div>
<dl class="compact-detail-grid">
<div v-for="row in assetRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="detail-panel side-panel">
<div class="panel-heading">
<h2>号主与价格</h2>
<span>{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</span>
</div>
<dl class="compact-detail-grid single">
<div v-for="row in ownerRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="detail-panel primary-panel">
<div class="panel-heading">
<h2>库存资产</h2>
<span
>{{ resourceCards.length }} 项资源 ·
{{ skinNames.length + hiddenSkinCount }} 个皮肤</span
>
</div>
<div v-if="resourceCards.length" class="resource-grid">
<div v-for="item in resourceCards" :key="item.key" class="resource-item">
<span>{{ item.label }}</span>
<strong>{{ item.quantity }}</strong>
</div>
</div>
<p v-else class="empty-line">暂无库存资源</p>
<div class="skin-chip-list">
<el-tag v-for="skin in skinNames" :key="skin" size="small" effect="plain">{{
skin
}}</el-tag>
<el-tag v-if="hiddenSkinCount" size="small" effect="plain">+{{ hiddenSkinCount }}</el-tag>
<span v-if="!skinNames.length" class="empty-line">暂无皮肤数据</span>
</div>
</section>
<section class="detail-panel side-panel">
<div class="panel-heading">
<h2>说明与时间</h2>
<span>{{ formatDateTime(listing.updated_at) }}</span>
</div>
<dl class="compact-detail-grid single">
<div>
<dt>商品说明</dt>
<dd>{{ listing.description || '暂无商品说明' }}</dd>
</div>
<div>
<dt>审核/后台原因</dt>
<dd>{{ listing.review_reason || '-' }}</dd>
</div>
<div>
<dt>上架时间</dt>
<dd>{{ formatDateTime(listing.published_at) }}</dd>
</div>
<div>
<dt>更新时间</dt>
<dd>{{ formatDateTime(listing.updated_at) }}</dd>
</div>
</dl>
</section>
<section class="detail-panel screenshots-panel">
<div class="panel-heading">
<h2>资产截图</h2>
<span>{{ listing.screenshot_urls?.length || 0 }} 张</span>
</div>
<div v-if="listing.screenshot_urls?.length" class="screenshot-grid">
<button
v-for="(url, index) in listing.screenshot_urls"
:key="url"
class="screenshot-thumb"
type="button"
@click="openScreenshot(url)"
>
<AuthImage
:source="url"
admin
:alt="`资产截图 ${index + 1}`"
image-class="screenshot-image"
fallback-class="screenshot-image"
fit="cover"
/>
<span>查看 {{ index + 1 }}</span>
</button>
</div>
<p v-else class="empty-line">暂无截图</p>
</section>
</div>
<el-dialog
:model-value="!!actionType"
:title="actionTitle"
width="560px"
@update:model-value="actionType = ''"
>
<div v-if="listing" class="dialog-body">
<p>
<strong>{{ listing.title }}</strong>
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写后台操作原因,会写入审计日志并通知号主"
/>
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
<el-dialog
v-model="transferVisible"
title="转移发布所有权"
width="680px"
class="transfer-owner-dialog"
>
<div v-if="listing" class="dialog-body transfer-dialog">
<div class="transfer-route">
<div class="transfer-owner-card">
<span>当前号主</span>
<strong>{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</strong>
<small>ID:{{ listing.owner_id }}</small>
</div>
<div class="transfer-arrow">→</div>
<div class="transfer-owner-card target" :class="{ empty: !selectedUser }">
<span>目标号主</span>
<strong v-if="selectedUser">{{ selectedUser.nickname || selectedUser.phone }}</strong>
<strong v-else>请选择用户</strong>
<small v-if="selectedUser"
>ID:{{ selectedUser.id }} · {{ selectedUser.phone || '-' }}</small
>
<small v-else>仅支持正常且已实名用户</small>
</div>
</div>
<div class="transfer-section">
<label>选择目标用户</label>
<el-input
v-model="userKeyword"
clearable
placeholder="输入手机号 / 昵称 / 用户ID 查询"
@keyup.enter="searchUsers"
@clear="searchUsers"
>
<template #append>
<el-button :icon="Search" :loading="userSearching" @click="searchUsers"
>查询</el-button
>
</template>
</el-input>
</div>
<div class="transfer-user-list">
<button
v-for="user in userOptions"
:key="user.id"
class="transfer-user"
:class="{ 'is-selected': selectedUser?.id === user.id }"
type="button"
@click="selectUser(user)"
>
<span class="transfer-user-main">
<strong>{{ user.nickname || user.phone || `用户${user.id}` }}</strong>
<small>{{ user.phone || '-' }} · ID {{ user.id }}</small>
</span>
<span class="transfer-user-meta">
<el-tag size="small" type="success">{{ userStatusLabel(user.status) }}</el-tag>
<el-tag size="small">{{ realnameStatusLabel(user.realname_status) }}</el-tag>
</span>
</button>
<el-empty
v-if="!userOptions.length"
description="输入关键词后查询已实名用户"
:image-size="72"
/>
</div>
<div class="transfer-section">
<label>转移原因</label>
<el-input
v-model="transferReason"
type="textarea"
:rows="3"
placeholder="填写转移原因,会写入审计日志"
/>
</div>
</div>
<template #footer>
<el-button @click="transferVisible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="!selectedUser || !selectedUserAvailable"
@click="submitTransferOwner"
>
确认转移
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.listing-detail-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 20px;
margin-bottom: 18px;
}
.listing-title-block {
min-width: 0;
}
.listing-code-line {
display: inline-flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.listing-code-line strong {
color: #2563eb;
letter-spacing: 0;
}
.listing-title-block h1 {
margin: 8px 0 10px;
color: #0f172a;
font-size: 30px;
line-height: 1.15;
}
.listing-subtitle {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.listing-subtitle span {
padding: 4px 9px;
border: 1px solid #dbe3ef;
border-radius: 8px;
background: #fff;
color: #64748b;
font-size: 13px;
}
.listing-summary-bar {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 1px;
overflow: hidden;
margin-bottom: 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #e5e7eb;
}
.listing-summary-bar > div {
display: grid;
min-height: 74px;
align-content: center;
gap: 8px;
padding: 12px 14px;
background: #fff;
}
.listing-summary-bar span {
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.listing-summary-bar strong {
color: #0f172a;
font-size: 21px;
line-height: 1.1;
}
.listing-workspace {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(360px, 0.7fr);
gap: 16px;
align-items: start;
}
.detail-panel {
min-width: 0;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
padding: 16px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.screenshots-panel {
grid-column: 1 / -1;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #eef2f7;
}
.panel-heading h2 {
margin: 0;
color: #0f172a;
font-size: 16px;
font-weight: 800;
}
.panel-heading span {
overflow: hidden;
color: #94a3b8;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin: 0;
border: 1px solid #eef2f7;
border-radius: 8px;
background: #eef2f7;
}
.compact-detail-grid.single {
grid-template-columns: 1fr;
}
.compact-detail-grid div {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
align-items: start;
min-height: 38px;
padding: 10px 12px;
background: #fff;
}
.compact-detail-grid dt {
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.compact-detail-grid dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.resource-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.resource-item span {
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.resource-item strong {
color: #0f172a;
font-size: 18px;
}
.skin-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
}
.screenshot-thumb {
display: grid;
gap: 8px;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #64748b;
cursor: pointer;
text-align: left;
}
.screenshot-thumb:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.screenshot-thumb img {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 6px;
background: #f1f5f9;
object-fit: cover;
}
.screenshot-image {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 6px;
}
.screenshot-thumb span {
color: #334155;
font-size: 13px;
font-weight: 700;
}
.empty-line {
margin: 0;
color: #94a3b8;
font-size: 13px;
}
.transfer-owner-trigger {
min-width: 120px;
font-weight: 700;
}
.transfer-dialog {
display: grid;
gap: 18px;
}
.transfer-route {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr);
align-items: stretch;
gap: 12px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.transfer-owner-card {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #64748b;
}
.transfer-owner-card.target {
border-color: #86efac;
background: #f0fdf4;
}
.transfer-owner-card.target.empty {
border-color: #bfdbfe;
background: #eff6ff;
}
.transfer-owner-card span {
font-size: 13px;
}
.transfer-owner-card strong {
overflow: hidden;
color: #0f172a;
font-size: 18px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-owner-card small {
overflow: hidden;
color: #94a3b8;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-arrow {
display: grid;
place-items: center;
color: #2563eb;
font-size: 22px;
font-weight: 800;
}
.transfer-section {
display: grid;
gap: 8px;
}
.transfer-section label {
color: #334155;
font-size: 14px;
font-weight: 700;
}
.transfer-user-list {
min-height: 138px;
max-height: 280px;
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.transfer-user {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 13px 14px;
border: 0;
border-bottom: 1px solid #eef2f7;
background: #fff;
color: #334155;
text-align: left;
cursor: pointer;
}
.transfer-user:last-child {
border-bottom: 0;
}
.transfer-user:hover,
.transfer-user.is-selected {
background: #eff6ff;
}
.transfer-user.is-selected {
box-shadow: inset 3px 0 0 #2563eb;
}
.transfer-user-main {
min-width: 0;
}
.transfer-user strong {
display: block;
overflow: hidden;
color: #0f172a;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-user small {
display: block;
margin-top: 4px;
overflow: hidden;
color: #94a3b8;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-user-meta {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
@media (max-width: 760px) {
.listing-detail-header,
.listing-workspace {
grid-template-columns: 1fr;
}
.listing-summary-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-detail-grid {
grid-template-columns: 1fr;
}
.transfer-route {
grid-template-columns: 1fr;
}
.transfer-arrow {
transform: rotate(90deg);
}
.transfer-user {
align-items: flex-start;
flex-direction: column;
}
}
</style>