1001 lines
26 KiB
Vue
1001 lines
26 KiB
Vue
<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>
|