Files
hfb_sys/frontend/src/features/listings/views/ListingDetailView.vue
T

1039 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 { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchListing, type Listing } from '@/features/listings/api/listings'
import {
createOrder,
fetchOrderAgreements,
type OrderAgreements,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { roundMoney, formatMoney, formatCent } from '@/shared/utils/money'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingChips,
getListingDisplayPrice,
getListingRentPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getOnlineTimeText,
getLoginMethod,
getServerRegion,
readAssetString,
} from '@/shared/utils/listingDisplay'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const ordering = ref(false)
const agreementsLoading = ref(false)
const agreementVisible = ref(false)
const agreements = ref<OrderAgreements | null>(null)
const virtualAgreementRead = ref(false)
const renterAgreementRead = ref(false)
const virtualAgreementChecked = ref(false)
const renterAgreementChecked = ref(false)
const virtualAgreementRef = ref<HTMLElement | null>(null)
const renterAgreementRef = ref<HTMLElement | null>(null)
const canCreateOrderAfterAgreement = computed(
() =>
virtualAgreementRead.value &&
renterAgreementRead.value &&
virtualAgreementChecked.value &&
renterAgreementChecked.value
)
const listing = ref<Listing | null>(null)
onMounted(async () => {
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id))
} finally {
loading.value = false
}
})
const orderTotal = computed(() => {
if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value))
})
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
return {
rent: 0,
consumable: 0,
total: 0,
}
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
total: roundMoney(getListingDisplayPrice(listing.value)),
}
})
const coverURL = computed(() => {
if (!listing.value) return ''
return listing.value.cover_url || listing.value.screenshot_urls?.[0] || ''
})
const detailMetrics = computed(() => {
if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value)
return [
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{ label: '价格', value: ${orderTotal.value}`, tone: 'price' },
{ label: '押金', value: ${formatCent(listing.value.deposit_amount_cent)}`, tone: '' },
]
})
const detailScreenshots = computed(() => {
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
url,
}))
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
const slots = [
{ key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
]
return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}))
})
}
const detailSkinGroups = computed(() => {
if (!listing.value) return []
const groups = listing.value.asset_summary?.skin_groups
if (typeof groups !== 'object' || groups === null) return []
const titles: Record<string, string> = {
melee: '近战皮肤',
operator: '干员皮肤',
operatorGold: '干员金皮',
operatorRed: '干员红皮',
weapon: '武器皮肤',
}
return Object.entries(groups as Record<string, unknown>)
.map(([key, value]) => ({
key,
title: titles[key] || key,
options: Array.isArray(value)
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.filter(group => group.options.length)
})
const accountRows = computed(() => {
if (!listing.value) return []
const regions = assetRegions(listing.value)
return [
{ label: '所属区服', value: getServerRegion(listing.value) || '--' },
{ label: '上号方式', value: getLoginMethod(listing.value) || '--' },
{ label: '游戏段位', value: listing.value.rank_level || '--' },
{ label: 'M单价', value: formatRatio(listing.value) },
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
{ label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无' },
]
})
async function handleCreateOrder() {
if (!listing.value) return
if (!session.token) {
await router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe()
} catch {
// 登录态失效时由全局请求拦截处理。
}
}
if (session.realnameStatus !== 'verified') {
try {
await ElMessageBox.confirm('租号下单前需要完成实名认证。', '请先实名认证', {
confirmButtonText: '去认证',
cancelButtonText: '取消',
type: 'warning',
})
await router.push({ path: '/realname', query: { redirect: route.fullPath } })
} catch {
// 用户取消认证时停留在当前页面。
}
return
}
await openAgreementBeforeOrder()
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true
try {
agreements.value = await fetchOrderAgreements()
virtualAgreementRead.value = false
renterAgreementRead.value = false
virtualAgreementChecked.value = false
renterAgreementChecked.value = false
agreementVisible.value = true
await nextTick()
updateAgreementReadState('virtual')
updateAgreementReadState('renter')
} catch (error) {
ElMessage.error(readError(error, '协议加载失败'))
} finally {
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
ElMessage.warning('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return
ordering.value = true
try {
const order = await createOrder(listing.value.id)
ElMessage.success('订单已创建,正在打开支付')
await router.push({ path: `/orders/${order.id}`, query: { pay: '1' } })
} catch (error) {
ElMessage.error(readError(error, '下单失败'))
} finally {
ordering.value = false
}
}
async function copyListingCode() {
if (!listing.value) return
try {
await navigator.clipboard.writeText(formatListingCode(listing.value))
ElMessage.success('商品编号已复制')
} catch {
ElMessage.error('复制失败')
}
}
function handleAgreementScroll(type: 'virtual' | 'renter') {
updateAgreementReadState(type)
}
function updateAgreementReadState(type: 'virtual' | 'renter') {
const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
if (!el) return
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
if (type === 'virtual') {
virtualAgreementRead.value = read
} else {
renterAgreementRead.value = read
}
}
// 手动锁定/解锁背景滚动
watch(agreementVisible, visible => {
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
const pcMain = document.querySelector('.pc-main') as HTMLElement
if (!pcMain) return
if (visible) {
// 保存当前滚动位置
const scrollTop = pcMain.scrollTop || 0
// 锁定主容器滚动
pcMain.style.overflow = 'hidden'
pcMain.style.position = 'fixed'
pcMain.style.top = `-${scrollTop}px`
pcMain.style.left = '0'
pcMain.style.right = '0'
pcMain.style.width = '100%'
// 保存滚动位置供恢复使用
pcMain.dataset.scrollTop = String(scrollTop)
} else {
// 恢复主容器滚动
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10)
pcMain.style.overflow = ''
pcMain.style.position = ''
pcMain.style.top = ''
pcMain.style.left = ''
pcMain.style.right = ''
pcMain.style.width = ''
// 恢复滚动位置
pcMain.scrollTop = scrollTop
delete pcMain.dataset.scrollTop
}
})
function listingPrice(item: Listing) {
return formatMoney(getListingDisplayPrice(item))
}
</script>
<template>
<section class="pc-detail page" v-loading="loading">
<div class="anti-fraud-strip compact">
<span>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span>
</div>
<div v-if="listing" class="pc-detail-layout">
<section class="pc-detail-main">
<div class="detail-hero-card">
<AuthImage
v-if="coverURL"
:source="coverURL"
:alt="getListingTitle(listing)"
image-class="detail-hero-img"
loading="eager"
/>
<span v-else>HFB ACCOUNT</span>
<div class="detail-hero-overlay">
<div class="detail-tags">
<span>{{ getServerRegion(listing) }}</span>
<span v-if="getLoginMethod(listing)">{{ getLoginMethod(listing) }}</span>
<span v-if="listing.rank_level">{{ listing.rank_level }}</span>
</div>
<button class="detail-code-chip" type="button" @click="copyListingCode">
编号 {{ formatListingCode(listing) }}
</button>
<h1>{{ getListingTitle(listing) }}</h1>
<p>{{ getListingSubtitle(listing) }}</p>
</div>
</div>
<div class="detail-body">
<div class="detail-summary-row">
<div
v-for="metric in detailMetrics"
:key="metric.label"
class="detail-metric"
:class="`is-${metric.tone}`"
>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
</div>
</div>
<section class="detail-section">
<div class="detail-section-head">
<h2>账号资料</h2>
<span>{{ listing.game_name || '三角洲行动' }}</span>
</div>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}{{ chip.value }}
</span>
</div>
<dl class="detail-info-grid">
<div v-for="row in accountRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section v-if="getListingResources(listing).length" class="detail-section">
<div class="detail-section-head">
<h2>额外消耗品</h2>
<span>{{ getListingResources(listing).length }} </span>
</div>
<div class="detail-resource-grid">
<div
v-for="resource in getListingResources(listing)"
:key="resource.key"
class="detail-resource-card"
>
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || 0' }}</small>
<small v-else>无额外收费</small>
</em>
</div>
</div>
</section>
<section v-if="detailSkinGroups.length" class="detail-section">
<div class="detail-section-head">
<h2>皮肤清单</h2>
<span>按类型展示</span>
</div>
<div class="skin-groups">
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-group">
<h3>{{ group.title }}</h3>
<div class="detail-chip-row">
<span v-for="skin in group.options" :key="skin">{{ skin }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section">
<div class="detail-section-head">
<h2>号主备注</h2>
</div>
<p class="detail-description">{{ listing.description || '号主暂未填写详细说明。' }}</p>
</section>
<section v-if="detailScreenshots.length" class="detail-section">
<div class="detail-section-head">
<h2>账号截图</h2>
<span>{{ detailScreenshots.length }} </span>
</div>
<div class="detail-screenshot-grid">
<figure v-for="shot in detailScreenshots" :key="shot.url" class="detail-screenshot">
<AuthImage :source="shot.url" :alt="shot.label" />
<figcaption>{{ shot.label }}</figcaption>
</figure>
</div>
</section>
</div>
</section>
<aside class="order-panel pc-order-card">
<h2>立即下单</h2>
<p class="order-safe-text">平台托管订单与押金按平台交接流程完成账号使用</p>
<el-form label-position="top">
<div class="order-total-box">
<div class="order-total-head">
<span>租赁价格</span>
<strong>¥{{ listingPrice(listing) }}</strong>
</div>
<div class="order-price-breakdown">
<div>
<span>基础租金</span>
<strong>¥{{ formatMoney(orderPriceBreakdown.rent) }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>¥{{ formatMoney(orderPriceBreakdown.consumable) }}</strong>
</div>
</div>
<em>押金另付 ¥{{ formatCent(listing.deposit_amount_cent) }}</em>
</div>
<dl class="order-check-list">
<div>
<dt>账号区服</dt>
<dd>{{ getServerRegion(listing) || '--' }}</dd>
</div>
<div>
<dt>上号方式</dt>
<dd>{{ getLoginMethod(listing) || '--' }}</dd>
</div>
<div>
<dt>预计可租</dt>
<dd>{{ formatEstimatedRentalDuration(listing) }}</dd>
</div>
</dl>
<el-button
type="warning"
size="large"
:loading="ordering || agreementsLoading"
:disabled="listing.in_transaction"
class="full-control"
@click="handleCreateOrder"
>
{{ listing.in_transaction ? '交易中' : '立即下单' }}
</el-button>
</el-form>
</aside>
</div>
<el-dialog
v-model="agreementVisible"
title="下单协议确认"
width="860px"
class="order-agreement-dialog"
:close-on-click-modal="false"
>
<div v-if="agreements" class="agreement-dialog-body">
<p class="agreement-tip">请完整阅读并勾选以下两份协议后继续下单</p>
<div class="agreement-grid">
<section class="agreement-panel">
<h3>{{ agreements.virtual_asset_purchase.title }}</h3>
<div
ref="virtualAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('virtual')"
>
{{ agreements.virtual_asset_purchase.content }}
</div>
<el-checkbox v-model="virtualAgreementChecked" :disabled="!virtualAgreementRead">
我已阅读并同意{{ agreements.virtual_asset_purchase.title }}
</el-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
<section class="agreement-panel">
<h3>{{ agreements.renter_agreement.title }}</h3>
<div
ref="renterAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('renter')"
>
{{ agreements.renter_agreement.content }}
</div>
<el-checkbox v-model="renterAgreementChecked" :disabled="!renterAgreementRead">
我已阅读并同意{{ agreements.renter_agreement.title }}
</el-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
</div>
</div>
<template #footer>
<el-button @click="agreementVisible = false">取消</el-button>
<el-button
type="primary"
:loading="ordering"
:disabled="!canCreateOrderAfterAgreement"
@click="handleConfirmAgreementAndCreateOrder"
>
同意协议并下单
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.pc-detail-layout {
grid-template-columns: minmax(0, 1fr) 420px;
align-items: start;
}
.pc-detail-main {
overflow: hidden;
}
.detail-hero-card {
position: relative;
display: grid;
height: 340px;
overflow: hidden;
background: #111827;
color: #ffffff;
}
.detail-hero-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-hero-card > span {
display: grid;
height: 340px;
place-items: center;
font-size: 32px;
font-weight: 900;
}
.detail-hero-card::after {
position: absolute;
inset: 0;
content: '';
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)),
linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%);
}
.detail-hero-overlay {
position: absolute;
inset: auto 0 0;
z-index: 1;
max-width: 860px;
padding: 28px;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.detail-tags span {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-size: 13px;
font-weight: 900;
}
.detail-code-chip {
width: fit-content;
margin-top: 10px;
padding: 5px 10px;
border: 1px solid rgba(147, 197, 253, 0.6);
border-radius: 999px;
background: rgba(239, 246, 255, 0.92);
color: #2563eb;
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
.detail-hero-overlay h1 {
margin: 0;
color: #ffffff;
font-size: 30px;
line-height: 1.25;
}
.detail-hero-overlay p {
margin: 8px 0 0;
color: rgba(255, 255, 255, 0.82);
font-size: 16px;
font-weight: 800;
}
.detail-body {
display: grid;
gap: 18px;
padding: 24px;
}
.detail-summary-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.detail-metric,
.detail-section {
border: 1px solid #edf0f4;
border-radius: 16px;
background: #ffffff;
}
.detail-metric {
padding: 18px;
background: #f8fafc;
}
.detail-metric span {
display: block;
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.detail-metric strong {
display: block;
margin-top: 8px;
color: #17233d;
font-size: 28px;
line-height: 1.1;
}
.detail-metric.is-coin strong {
color: #1477ff;
}
.detail-metric.is-price strong {
color: #ef4444;
}
.detail-section {
padding: 22px;
}
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.detail-section-head h2 {
margin: 0;
color: #17233d;
font-size: 20px;
}
.detail-section-head span {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.detail-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-chip-row span {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border: 1px solid rgba(255, 106, 0, 0.28);
border-radius: 8px;
background: #fff7ed;
color: #ea580c;
font-size: 13px;
font-weight: 900;
}
.detail-info-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 18px 0 0;
}
.detail-info-grid div {
min-width: 0;
border-radius: 12px;
background: #f8fafc;
padding: 14px;
}
.detail-info-grid dt {
color: #8b9cb5;
font-size: 12px;
font-weight: 900;
}
.detail-info-grid dd {
margin: 8px 0 0;
overflow-wrap: anywhere;
color: #17233d;
font-size: 15px;
font-weight: 900;
}
.detail-resource-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.detail-resource-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
border-radius: 12px;
background: #f8fafc;
padding: 14px;
}
.detail-resource-card span {
min-width: 0;
color: #475569;
font-size: 14px;
font-weight: 900;
}
.detail-resource-card strong {
color: #17233d;
font-size: 18px;
}
.detail-resource-card em {
display: flex;
align-items: center;
justify-content: space-between;
grid-column: 1 / -1;
font-size: 12px;
font-style: normal;
font-weight: 900;
}
.detail-resource-card em b {
color: #ff6a00;
}
.detail-resource-card em small {
color: #17233d;
font-size: 13px;
font-weight: 900;
}
.skin-groups {
display: grid;
gap: 16px;
}
.skin-group h3 {
margin: 0 0 10px;
color: #475569;
font-size: 15px;
}
.detail-description {
margin: 0;
color: #52616f;
font-size: 15px;
line-height: 1.8;
}
.detail-screenshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.detail-screenshot {
margin: 0;
}
.detail-screenshot img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: 12px;
border: 1px solid #edf0f4;
}
.detail-screenshot figcaption {
margin-top: 8px;
color: #64748b;
font-size: 13px;
font-weight: 800;
text-align: center;
}
.pc-order-card {
max-width: none;
border-radius: 20px;
}
.order-total-box {
margin-bottom: 14px;
}
.order-total-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(217, 119, 6, 0.14);
}
.order-total-head span {
color: #92400e;
font-size: 14px;
font-weight: 900;
}
.order-total-head strong {
margin: 0;
color: #ef4444;
font-size: 32px;
line-height: 1;
}
.order-price-breakdown {
display: grid;
gap: 8px;
padding: 12px 0;
}
.order-price-breakdown div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.order-price-breakdown span {
color: #8a5a12;
font-size: 13px;
font-weight: 900;
}
.order-price-breakdown strong {
margin: 0;
color: #17233d;
font-size: 16px;
line-height: 1.2;
}
.order-total-box > em {
padding-top: 10px;
border-top: 1px solid rgba(217, 119, 6, 0.14);
color: #92400e;
font-size: 13px;
font-weight: 900;
}
.order-check-list {
display: grid;
gap: 10px;
margin: 0 0 18px;
}
.order-check-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 40px;
padding: 0 12px;
border-radius: 10px;
background: #f8fafc;
}
.order-check-list dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.order-check-list dd {
margin: 0;
color: #17233d;
font-size: 14px;
font-weight: 900;
text-align: right;
}
.agreement-dialog-body {
display: grid;
gap: 14px;
}
.agreement-tip {
margin: 0;
color: #64748b;
font-size: 14px;
font-weight: 700;
}
.agreement-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.agreement-panel {
display: grid;
gap: 10px;
min-width: 0;
}
.agreement-panel h3 {
margin: 0;
color: #17233d;
font-size: 16px;
font-weight: 900;
}
.agreement-content {
height: 260px;
overflow: auto;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #f8fafc;
padding: 14px;
color: #1f2937;
font-size: 13px;
font-weight: 700;
line-height: 1.75;
white-space: pre-wrap;
}
.agreement-read-hint {
color: #f97316;
font-size: 12px;
font-weight: 800;
}
@media (max-width: 1180px) {
.pc-detail-layout,
.detail-screenshot-grid {
grid-template-columns: 1fr;
}
.detail-summary-row,
.detail-info-grid,
.detail-resource-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-hero-card,
.detail-hero-card img,
.detail-hero-card > span {
height: 300px;
}
.detail-hero-overlay {
padding: 24px;
}
.detail-hero-overlay h1 {
font-size: 26px;
}
}
@media (max-width: 720px) {
.detail-summary-row,
.detail-info-grid,
.detail-resource-grid {
grid-template-columns: 1fr;
}
.agreement-grid {
grid-template-columns: 1fr;
}
}
</style>