完善测试建表并统一前端格式
This commit is contained in:
@@ -42,7 +42,8 @@ export interface UpdateRulePayload {
|
||||
// ── 渠道 ──
|
||||
|
||||
export async function fetchPushChannels() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: PushChannel[] }>>('/admin/push-channels')
|
||||
const { data } =
|
||||
await apiClient.get<ApiResponse<{ items: PushChannel[] }>>('/admin/push-channels')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
@@ -52,17 +53,24 @@ export async function createPushChannel(payload: CreateChannelPayload) {
|
||||
}
|
||||
|
||||
export async function updatePushChannel(id: number, payload: UpdateChannelPayload) {
|
||||
const { data } = await apiClient.put<ApiResponse<PushChannel>>(`/admin/push-channels/${id}`, payload)
|
||||
const { data } = await apiClient.put<ApiResponse<PushChannel>>(
|
||||
`/admin/push-channels/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deletePushChannel(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/push-channels/${id}`)
|
||||
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
|
||||
`/admin/push-channels/${id}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function testPushChannel(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ sent: boolean }>>(`/admin/push-channels/${id}/test`)
|
||||
const { data } = await apiClient.post<ApiResponse<{ sent: boolean }>>(
|
||||
`/admin/push-channels/${id}/test`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElInput, ElMessage } from 'element-plus'
|
||||
import { Bell, Document, Operation, Picture, QuestionFilled, Warning } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Bell,
|
||||
Document,
|
||||
Operation,
|
||||
Picture,
|
||||
QuestionFilled,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Announcement } from '@/features/announcement'
|
||||
import type {
|
||||
CreateAnnouncementRequest,
|
||||
@@ -134,7 +141,9 @@ async function handleImageChange(event: Event) {
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const uploaded = await uploadAdminFile(file, 'announcement')
|
||||
insertContentAtCursor(``)
|
||||
insertContentAtCursor(
|
||||
``
|
||||
)
|
||||
ElMessage.success('图片已插入')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '图片上传失败'))
|
||||
@@ -205,7 +214,9 @@ function applyImageSettings() {
|
||||
return
|
||||
}
|
||||
|
||||
const widthMark = imageSettingsForm.value.useCustomWidth ? `|w=${imageSettingsForm.value.width}` : ''
|
||||
const widthMark = imageSettingsForm.value.useCustomWidth
|
||||
? `|w=${imageSettingsForm.value.width}`
|
||||
: ''
|
||||
const nextMarkdown = ``
|
||||
const content = formData.value.content
|
||||
formData.value.content = `${content.slice(0, range.start)}${nextMarkdown}${content.slice(range.end)}`
|
||||
@@ -358,7 +369,11 @@ function escapeMarkdownImageText(text: string) {
|
||||
</el-form-item>
|
||||
<el-form-item label="显示宽度">
|
||||
<div class="image-width-control">
|
||||
<el-switch v-model="imageSettingsForm.useCustomWidth" active-text="自定义" inactive-text="原宽" />
|
||||
<el-switch
|
||||
v-model="imageSettingsForm.useCustomWidth"
|
||||
active-text="自定义"
|
||||
inactive-text="原宽"
|
||||
/>
|
||||
<el-slider
|
||||
v-model="imageSettingsForm.width"
|
||||
:min="120"
|
||||
|
||||
@@ -208,7 +208,12 @@ function accountTypeLabel(type: string) {
|
||||
:admin="true"
|
||||
:preview-src-list="withdrawal.certificate_urls"
|
||||
fit="cover"
|
||||
:image-style="{ width: '100px', height: '100px', borderRadius: '4px', cursor: 'pointer' }"
|
||||
:image-style="{
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -142,7 +142,9 @@ function shortDate(date: string) {
|
||||
<h2 class="section-title">
|
||||
<el-icon><Shop /></el-icon>
|
||||
账号上下架
|
||||
<small class="section-subtitle">按北京时间自然日 · 近 {{ dashboard.listing_daily.days }} 日</small>
|
||||
<small class="section-subtitle"
|
||||
>按北京时间自然日 · 近 {{ dashboard.listing_daily.days }} 日</small
|
||||
>
|
||||
</h2>
|
||||
|
||||
<div class="listing-daily-grid">
|
||||
@@ -167,7 +169,10 @@ function shortDate(date: string) {
|
||||
<div
|
||||
class="mini-bar publish"
|
||||
:style="{
|
||||
height: barHeight(row.published_count, trendMax(r => r.published_count)),
|
||||
height: barHeight(
|
||||
row.published_count,
|
||||
trendMax(r => r.published_count)
|
||||
),
|
||||
}"
|
||||
/>
|
||||
<em>{{ shortDate(row.date) }}</em>
|
||||
@@ -230,7 +235,10 @@ function shortDate(date: string) {
|
||||
<div
|
||||
class="mini-bar trade"
|
||||
:style="{
|
||||
height: barHeight(row.trade_leave_count, trendMax(r => r.trade_leave_count)),
|
||||
height: barHeight(
|
||||
row.trade_leave_count,
|
||||
trendMax(r => r.trade_leave_count)
|
||||
),
|
||||
}"
|
||||
/>
|
||||
<em>{{ shortDate(row.date) }}</em>
|
||||
|
||||
@@ -9,11 +9,7 @@ import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminU
|
||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||
import { adminPath } from '@/shared/utils/adminPath'
|
||||
import {
|
||||
formatCentWithSymbol,
|
||||
formatMoneyWithSymbol,
|
||||
roundMoney,
|
||||
} from '@/shared/utils/money'
|
||||
import { formatCentWithSymbol, formatMoneyWithSymbol, roundMoney } from '@/shared/utils/money'
|
||||
import {
|
||||
adminMarkListingAbnormal,
|
||||
adminOfflineListing,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Plus, Refresh, View, Edit, Delete, Download, Upload, StarFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Plus,
|
||||
Refresh,
|
||||
View,
|
||||
Edit,
|
||||
Delete,
|
||||
Download,
|
||||
Upload,
|
||||
StarFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
import {
|
||||
fetchPaymentConfigs,
|
||||
@@ -432,7 +441,12 @@ function deleteDisabledReason(row: PaymentConfig) {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_used_at" label="最后成功支付" min-width="180" show-overflow-tooltip>
|
||||
<el-table-column
|
||||
prop="last_used_at"
|
||||
label="最后成功支付"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row.last_used_at ? formatDateTime(row.last_used_at, '-') : '-' }}
|
||||
</template>
|
||||
|
||||
@@ -216,9 +216,15 @@ async function saveRule() {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :icon="Edit" @click="openEditChannel(row)">编辑</el-button>
|
||||
<el-button size="small" type="primary" :icon="Edit" @click="openEditChannel(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button size="small" type="primary" @click="handleTestChannel(row)">测试</el-button>
|
||||
<el-button size="small" :type="row.enabled ? 'warning' : 'success'" @click="toggleChannel(row)">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.enabled ? 'warning' : 'success'"
|
||||
@click="toggleChannel(row)"
|
||||
>
|
||||
{{ row.enabled ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" :icon="Delete" @click="handleDeleteChannel(row)" />
|
||||
@@ -226,7 +232,10 @@ async function saveRule() {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!channelsLoading && channels.length === 0" description="暂无推送渠道,点击上方按钮新增" />
|
||||
<el-empty
|
||||
v-if="!channelsLoading && channels.length === 0"
|
||||
description="暂无推送渠道,点击上方按钮新增"
|
||||
/>
|
||||
|
||||
<!-- 规则管理 -->
|
||||
<div class="section-header" style="margin-top: 32px">
|
||||
@@ -251,7 +260,9 @@ async function saveRule() {
|
||||
<el-table-column label="消息模板" min-width="250" prop="message_template" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :icon="Edit" @click="openEditRule(row)">编辑</el-button>
|
||||
<el-button size="small" type="primary" :icon="Edit" @click="openEditRule(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -276,11 +287,12 @@ async function saveRule() {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="field in channelConfigFields(channelForm.type)" :key="field.key" :label="field.label">
|
||||
<el-input
|
||||
v-model="channelForm.config[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
<el-form-item
|
||||
v-for="field in channelConfigFields(channelForm.type)"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
>
|
||||
<el-input v-model="channelForm.config[field.key]" :placeholder="field.placeholder" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -300,7 +312,12 @@ async function saveRule() {
|
||||
<span class="form-hint">库存低于此值时触发预警</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="消息模板">
|
||||
<el-input v-model="ruleForm.message_template" type="textarea" :rows="3" placeholder="支持 {{.Count}} 占位符" />
|
||||
<el-input
|
||||
v-model="ruleForm.message_template"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="支持 {{.Count}} 占位符"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
|
||||
@@ -37,7 +37,12 @@ import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
const adminSession = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const qrcodes = ref<ChatQrCode[]>([])
|
||||
const stats = ref<QrCodeStats>({ unused_count: 0, used_count: 0, disabled_count: 0, total_count: 0 })
|
||||
const stats = ref<QrCodeStats>({
|
||||
unused_count: 0,
|
||||
used_count: 0,
|
||||
disabled_count: 0,
|
||||
total_count: 0,
|
||||
})
|
||||
const canManageQrCodes = computed(() => adminSession.hasPermission('qrcode:manage'))
|
||||
|
||||
const currentPage = ref(1)
|
||||
@@ -136,7 +141,9 @@ const editStatusOptions = computed(() =>
|
||||
const maxBatchUploadCount = 20
|
||||
const uploading = computed(() => uploadPendingCount.value > 0)
|
||||
const uploadBusy = computed(() => uploading.value || uploadSaving.value)
|
||||
const uploadedImageCountText = computed(() => `${uploadedImages.value.length}/${maxBatchUploadCount}`)
|
||||
const uploadedImageCountText = computed(
|
||||
() => `${uploadedImages.value.length}/${maxBatchUploadCount}`
|
||||
)
|
||||
const renameLoadingIds = ref(new Set<number>())
|
||||
const selectedRows = ref<ChatQrCode[]>([])
|
||||
const selectedCount = computed(() => selectedRows.value.length)
|
||||
@@ -228,7 +235,12 @@ function parseGroupNameFromOcrText(text: string) {
|
||||
return !excludedPatterns.some(pattern => pattern.test(line))
|
||||
})
|
||||
|
||||
return candidates.find(line => line.includes('群')) || candidates.find(line => /[-—]/.test(line)) || candidates[0] || ''
|
||||
return (
|
||||
candidates.find(line => line.includes('群')) ||
|
||||
candidates.find(line => /[-—]/.test(line)) ||
|
||||
candidates[0] ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const ocrConcurrencyLimit = 4
|
||||
@@ -333,10 +345,6 @@ async function handleFileUpload(options: { file: File }) {
|
||||
}
|
||||
}
|
||||
|
||||
function imagePreviewSources() {
|
||||
return uploadedImages.value.map(item => item.url).filter(Boolean)
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
const [removed] = uploadedImages.value.splice(index, 1)
|
||||
if (removed?.previewUrl) {
|
||||
@@ -377,9 +385,7 @@ async function submitUpload() {
|
||||
}
|
||||
uploadSaving.value = true
|
||||
try {
|
||||
const expiresAt = uploadForm.expires_at
|
||||
? new Date(uploadForm.expires_at).toISOString()
|
||||
: null
|
||||
const expiresAt = uploadForm.expires_at ? new Date(uploadForm.expires_at).toISOString() : null
|
||||
const items: CreateQrCodePayload[] = uploadedImages.value.map(img => ({
|
||||
image_url: img.url,
|
||||
group_name: img.groupName,
|
||||
@@ -832,12 +838,7 @@ onMounted(reloadAll)
|
||||
:image-style="{ width: '52px', height: '52px', borderRadius: '6px' }"
|
||||
:preview-src-list="[img.url]"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="uploaded-local-preview"
|
||||
:src="img.previewUrl"
|
||||
alt=""
|
||||
/>
|
||||
<img v-else class="uploaded-local-preview" :src="img.previewUrl" alt="" />
|
||||
<button
|
||||
class="uploaded-remove"
|
||||
type="button"
|
||||
@@ -872,7 +873,11 @@ onMounted(reloadAll)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="uploadForm.note" placeholder="如:7月企微群A(统一备注,适用于同一批次)" maxlength="50" />
|
||||
<el-input
|
||||
v-model="uploadForm.note"
|
||||
placeholder="如:7月企微群A(统一备注,适用于同一批次)"
|
||||
maxlength="50"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间">
|
||||
<el-date-picker
|
||||
@@ -932,10 +937,20 @@ onMounted(reloadAll)
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="群名">
|
||||
<el-input v-model="editForm.group_name" placeholder="企业微信群名" maxlength="64" clearable />
|
||||
<el-input
|
||||
v-model="editForm.group_name"
|
||||
placeholder="企业微信群名"
|
||||
maxlength="64"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="企微改名">
|
||||
<el-switch v-model="editForm.wecom_renamed" inline-prompt active-text="已改" inactive-text="未改" />
|
||||
<el-switch
|
||||
v-model="editForm.wecom_renamed"
|
||||
inline-prompt
|
||||
active-text="已改"
|
||||
inactive-text="未改"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.note" placeholder="如:7月企微群A" maxlength="50" />
|
||||
|
||||
@@ -130,11 +130,7 @@ onMounted(loadOrders)
|
||||
</div>
|
||||
|
||||
<div class="review-cards">
|
||||
<div
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="review-card"
|
||||
>
|
||||
<div v-for="order in orders" :key="order.id" class="review-card">
|
||||
<div class="card-header">
|
||||
<div class="order-info">
|
||||
<span class="order-no" @click="openDetail(order)">{{ order.order_no }}</span>
|
||||
@@ -167,26 +163,23 @@ onMounted(loadOrders)
|
||||
<div class="info-row">
|
||||
<span class="info-label">订单总额</span>
|
||||
<span class="info-value"
|
||||
>{{ formatCentWithSymbol((order.rent_amount_cent || 0) + order.deposit_amount_cent) }}(租金{{
|
||||
formatCentWithSymbol(order.rent_amount_cent || 0)
|
||||
}}
|
||||
/ 押金{{ formatCentWithSymbol(order.deposit_amount_cent) }})</span
|
||||
>{{
|
||||
formatCentWithSymbol((order.rent_amount_cent || 0) + order.deposit_amount_cent)
|
||||
}}(租金{{ formatCentWithSymbol(order.rent_amount_cent || 0) }} / 押金{{
|
||||
formatCentWithSymbol(order.deposit_amount_cent)
|
||||
}})</span
|
||||
>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">退款金额</span>
|
||||
<span class="info-value refund-amount">{{ formatCentWithSymbol(order.refund_amount_cent || 0) }}</span>
|
||||
<span class="info-value refund-amount">{{
|
||||
formatCentWithSymbol(order.refund_amount_cent || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
:icon="View"
|
||||
size="small"
|
||||
@click="openDetail(order)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button :icon="View" size="small" @click="openDetail(order)"> 查看详情 </el-button>
|
||||
<el-button
|
||||
:icon="Check"
|
||||
type="primary"
|
||||
@@ -211,28 +204,16 @@ onMounted(loadOrders)
|
||||
</div>
|
||||
|
||||
<!-- 驳回弹窗 -->
|
||||
<el-dialog
|
||||
v-model="rejectVisible"
|
||||
title="驳回退款"
|
||||
width="420px"
|
||||
>
|
||||
<el-dialog v-model="rejectVisible" title="驳回退款" width="420px">
|
||||
<div v-if="rejectOrder" class="reject-dialog">
|
||||
<p class="reject-tip">请选择驳回方式:</p>
|
||||
<div class="reject-options">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleRejectRestore"
|
||||
>
|
||||
<el-button type="primary" :loading="submitting" @click="handleRejectRestore">
|
||||
驳回退款(恢复订单)
|
||||
</el-button>
|
||||
<p class="option-desc">仅驳回退款请求,订单恢复至待交接状态,交接流程可继续</p>
|
||||
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="submitting"
|
||||
@click="handleRejectClose"
|
||||
>
|
||||
<el-button type="danger" :loading="submitting" @click="handleRejectClose">
|
||||
驳回退款并关闭订单
|
||||
</el-button>
|
||||
<p class="option-desc">驳回退款且关闭订单,商品下架归档,订单不可再操作</p>
|
||||
|
||||
@@ -106,7 +106,8 @@ const listingGroupWelcomeConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'chat.listing_group_welcome') || null
|
||||
)
|
||||
const renterRetentionConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'chat.renter_retention_days_after_order_end') || null
|
||||
() =>
|
||||
configs.value.find(item => item.key === 'chat.renter_retention_days_after_order_end') || null
|
||||
)
|
||||
const paddleOcrTokenConfig = computed(() =>
|
||||
systemConfigByKey(
|
||||
|
||||
@@ -40,8 +40,7 @@ export async function fetchUnreadNotificationCount() {
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead() {
|
||||
const { data } = await apiClient.put<ApiResponse<{ read_count: number }>>(
|
||||
'/notifications/read-all'
|
||||
)
|
||||
const { data } =
|
||||
await apiClient.put<ApiResponse<{ read_count: number }>>('/notifications/read-all')
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -167,7 +167,9 @@ async function handleRegister() {
|
||||
}
|
||||
const modeEyebrow = computed(() => (topTab.value === 'register' ? 'Register' : 'Login'))
|
||||
const modeTitle = computed(() => (topTab.value === 'register' ? '创建账号' : '欢迎回来'))
|
||||
const modeSubtitle = computed(() => (topTab.value === 'register' ? '注册后即可发布或租赁账号' : '登录后即可发布或租赁账号'))
|
||||
const modeSubtitle = computed(() =>
|
||||
topTab.value === 'register' ? '注册后即可发布或租赁账号' : '登录后即可发布或租赁账号'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -198,14 +200,10 @@ const modeSubtitle = computed(() => (topTab.value === 'register' ? '注册后即
|
||||
</div>
|
||||
|
||||
<div class="login-tabs">
|
||||
<button
|
||||
:class="{ active: topTab === 'login' }"
|
||||
@click="switchTab('login')"
|
||||
>登录</button>
|
||||
<button
|
||||
:class="{ active: topTab === 'register' }"
|
||||
@click="switchTab('register')"
|
||||
>注册</button>
|
||||
<button :class="{ active: topTab === 'login' }" @click="switchTab('login')">登录</button>
|
||||
<button :class="{ active: topTab === 'register' }" @click="switchTab('register')">
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 登录模式下的方式切换 -->
|
||||
@@ -213,23 +211,48 @@ const modeSubtitle = computed(() => (topTab.value === 'register' ? '注册后即
|
||||
<button
|
||||
:class="{ active: loginMode === 'password' }"
|
||||
@click="switchLoginMode('password')"
|
||||
>密码登录</button>
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<span class="sub-divider">|</span>
|
||||
<button
|
||||
:class="{ active: loginMode === 'sms' }"
|
||||
@click="switchLoginMode('sms')"
|
||||
>短信登录</button>
|
||||
<button :class="{ active: loginMode === 'sms' }" @click="switchLoginMode('sms')">
|
||||
短信登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 短信登录 -->
|
||||
<el-form v-if="topTab === 'login' && loginMode === 'sms'" class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form
|
||||
v-if="topTab === 'login' && loginMode === 'sms'"
|
||||
class="user-form"
|
||||
label-position="top"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" size="large" :prefix-icon="Iphone" />
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
:prefix-icon="Iphone"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="user-captcha-row">
|
||||
<el-input v-model="form.captchaCode" maxlength="4" placeholder="请输入图形验证码" size="large" :prefix-icon="Key" @keyup.enter="handleSendCode" />
|
||||
<button class="user-captcha-image" type="button" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="refreshCaptcha">
|
||||
<el-input
|
||||
v-model="form.captchaCode"
|
||||
maxlength="4"
|
||||
placeholder="请输入图形验证码"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
@keyup.enter="handleSendCode"
|
||||
/>
|
||||
<button
|
||||
class="user-captcha-image"
|
||||
type="button"
|
||||
:disabled="captchaLoading"
|
||||
aria-label="刷新图形验证码"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img v-if="captcha" :src="captcha.image" alt="图形验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
@@ -237,37 +260,107 @@ const modeSubtitle = computed(() => (topTab.value === 'register' ? '注册后即
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="user-code-row">
|
||||
<el-input v-model="form.code" maxlength="6" placeholder="6 位验证码" size="large" :prefix-icon="ChatLineRound" @keyup.enter="handleLogin" />
|
||||
<el-button size="large" :disabled="countDown > 0 || sending" :loading="sending" @click="handleSendCode">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
maxlength="6"
|
||||
placeholder="6 位验证码"
|
||||
size="large"
|
||||
:prefix-icon="ChatLineRound"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<el-button
|
||||
size="large"
|
||||
:disabled="countDown > 0 || sending"
|
||||
:loading="sending"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button class="user-login-btn" type="primary" size="large" :loading="loading" @click="handleLogin">登录</el-button>
|
||||
<el-button
|
||||
class="user-login-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>登录</el-button
|
||||
>
|
||||
<p class="login-notice">未收到验证码时,请稍后重试或联系客服处理</p>
|
||||
</el-form>
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<el-form v-if="topTab === 'login' && loginMode === 'password'" class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form
|
||||
v-if="topTab === 'login' && loginMode === 'password'"
|
||||
class="user-form"
|
||||
label-position="top"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" size="large" :prefix-icon="Iphone" />
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
:prefix-icon="Iphone"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" maxlength="20" placeholder="请输入密码" size="large" :prefix-icon="Lock" show-password @keyup.enter="handlePasswordLogin" />
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
@keyup.enter="handlePasswordLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button class="user-login-btn" type="primary" size="large" :loading="loading" @click="handlePasswordLogin">登录</el-button>
|
||||
<el-button
|
||||
class="user-login-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handlePasswordLogin"
|
||||
>登录</el-button
|
||||
>
|
||||
<p class="login-notice">未设置密码?请使用短信登录后前往个人中心设置密码</p>
|
||||
</el-form>
|
||||
|
||||
<!-- 注册 -->
|
||||
<el-form v-if="topTab === 'register'" class="user-form" label-position="top" @submit.prevent>
|
||||
<el-form
|
||||
v-if="topTab === 'register'"
|
||||
class="user-form"
|
||||
label-position="top"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" maxlength="11" placeholder="请输入手机号" size="large" :prefix-icon="Iphone" />
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
:prefix-icon="Iphone"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="user-captcha-row">
|
||||
<el-input v-model="form.captchaCode" maxlength="4" placeholder="请输入图形验证码" size="large" :prefix-icon="Key" @keyup.enter="handleSendCode" />
|
||||
<button class="user-captcha-image" type="button" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="refreshCaptcha">
|
||||
<el-input
|
||||
v-model="form.captchaCode"
|
||||
maxlength="4"
|
||||
placeholder="请输入图形验证码"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
@keyup.enter="handleSendCode"
|
||||
/>
|
||||
<button
|
||||
class="user-captcha-image"
|
||||
type="button"
|
||||
:disabled="captchaLoading"
|
||||
aria-label="刷新图形验证码"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img v-if="captcha" :src="captcha.image" alt="图形验证码" />
|
||||
<span v-else>刷新</span>
|
||||
</button>
|
||||
@@ -275,19 +368,62 @@ const modeSubtitle = computed(() => (topTab.value === 'register' ? '注册后即
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="user-code-row">
|
||||
<el-input v-model="form.code" maxlength="6" placeholder="6 位验证码" size="large" :prefix-icon="ChatLineRound" @keyup.enter="handleRegister" />
|
||||
<el-button size="large" :disabled="countDown > 0 || sending" :loading="sending" @click="handleSendCode">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
maxlength="6"
|
||||
placeholder="6 位验证码"
|
||||
size="large"
|
||||
:prefix-icon="ChatLineRound"
|
||||
@keyup.enter="handleRegister"
|
||||
/>
|
||||
<el-button
|
||||
size="large"
|
||||
:disabled="countDown > 0 || sending"
|
||||
:loading="sending"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="设置密码" :error="form.password && !passwordValid ? '密码长度 8-20 位' : ''">
|
||||
<el-input v-model="form.password" type="password" maxlength="20" placeholder="请输入密码(8-20 位)" size="large" :prefix-icon="Lock" show-password :class="{ 'is-error': form.password && !passwordValid }" />
|
||||
<el-form-item
|
||||
label="设置密码"
|
||||
:error="form.password && !passwordValid ? '密码长度 8-20 位' : ''"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="请输入密码(8-20 位)"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
:class="{ 'is-error': form.password && !passwordValid }"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" :error="form.confirmPassword && !confirmValid ? '两次密码不一致' : ''">
|
||||
<el-input v-model="form.confirmPassword" type="password" maxlength="20" placeholder="请再次输入密码" size="large" :prefix-icon="Lock" show-password :class="{ 'is-error': form.confirmPassword && !confirmValid }" />
|
||||
<el-form-item
|
||||
label="确认密码"
|
||||
:error="form.confirmPassword && !confirmValid ? '两次密码不一致' : ''"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
:class="{ 'is-error': form.confirmPassword && !confirmValid }"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button class="user-login-btn" type="primary" size="large" :loading="loading" @click="handleRegister">注册</el-button>
|
||||
<el-button
|
||||
class="user-login-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
>注册</el-button
|
||||
>
|
||||
<p class="login-notice">注册即表示同意《用户协议》和《隐私政策》</p>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -174,21 +174,25 @@ async function handlePasswordLogin() {
|
||||
<!-- 密码登录 -->
|
||||
<template v-if="loginMode === 'password'">
|
||||
<label class="auth-input-row">
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
maxlength="20"
|
||||
placeholder="密码"
|
||||
/>
|
||||
<input v-model="form.password" type="password" maxlength="20" placeholder="密码" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="assist-row">
|
||||
<span v-if="loginMode === 'sms'">验证码登录</span>
|
||||
<span v-else>密码登录</span>
|
||||
<button v-if="loginMode === 'sms'" class="link-btn" type="button" @click="switchToPassword">密码登录</button>
|
||||
<button
|
||||
v-if="loginMode === 'sms'"
|
||||
class="link-btn"
|
||||
type="button"
|
||||
@click="switchToPassword"
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button v-else class="link-btn" type="button" @click="switchToSms">短信验证码登录</button>
|
||||
<RouterLink v-if="loginMode === 'sms'" to="/m/register">没有账号?<b>立即注册</b></RouterLink>
|
||||
<RouterLink v-if="loginMode === 'sms'" to="/m/register"
|
||||
>没有账号?<b>立即注册</b></RouterLink
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="agreement-row">
|
||||
|
||||
@@ -129,7 +129,13 @@ function typeLabel(type: string) {
|
||||
>
|
||||
查看详情
|
||||
</van-button>
|
||||
<van-button v-if="!item.read_at" size="small" round type="primary" @click="markRead(item)">
|
||||
<van-button
|
||||
v-if="!item.read_at"
|
||||
size="small"
|
||||
round
|
||||
type="primary"
|
||||
@click="markRead(item)"
|
||||
>
|
||||
已读
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +309,9 @@ async function savePassword() {
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingPassword" @click="savePassword">保存密码</el-button>
|
||||
<el-button type="primary" :loading="savingPassword" @click="savePassword"
|
||||
>保存密码</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
|
||||
@@ -206,11 +206,17 @@ function handleTabChange(tab: string) {
|
||||
<span class="tab-label">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
聊天
|
||||
<em v-if="unreadTotal > 0" class="tab-badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</em>
|
||||
<em v-if="unreadTotal > 0" class="tab-badge">{{
|
||||
unreadTotal > 99 ? '99+' : unreadTotal
|
||||
}}</em>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
||||
<div
|
||||
v-if="loading && conversations.length === 0"
|
||||
class="message-loading"
|
||||
v-loading="loading"
|
||||
/>
|
||||
|
||||
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
||||
<el-empty description="暂无会话">
|
||||
@@ -265,7 +271,9 @@ function handleTabChange(tab: string) {
|
||||
<span class="tab-label">
|
||||
<el-icon><Bell /></el-icon>
|
||||
通知
|
||||
<em v-if="notificationUnreadCount > 0" class="tab-badge">{{ notificationUnreadCount > 99 ? '99+' : notificationUnreadCount }}</em>
|
||||
<em v-if="notificationUnreadCount > 0" class="tab-badge">{{
|
||||
notificationUnreadCount > 99 ? '99+' : notificationUnreadCount
|
||||
}}</em>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -280,10 +288,15 @@ function handleTabChange(tab: string) {
|
||||
:loading="notiSubmitting"
|
||||
:disabled="notificationUnreadCount === 0"
|
||||
@click="markAllNotiRead"
|
||||
>全部已读</el-button>
|
||||
>全部已读</el-button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="notiLoading && notifications.length === 0" class="message-loading" v-loading="notiLoading" />
|
||||
<div
|
||||
v-if="notiLoading && notifications.length === 0"
|
||||
class="message-loading"
|
||||
v-loading="notiLoading"
|
||||
/>
|
||||
|
||||
<div v-else-if="!notiLoading && notifications.length === 0" class="empty-panel">
|
||||
<el-empty description="暂无站内信" />
|
||||
@@ -307,13 +320,15 @@ function handleTabChange(tab: string) {
|
||||
v-if="item.biz_type === 'order' && item.biz_id"
|
||||
size="small"
|
||||
@click="router.push(`/orders/${item.biz_id}`)"
|
||||
>查看订单</el-button>
|
||||
>查看订单</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="!item.read_at"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="markNotiRead(item)"
|
||||
>已读</el-button>
|
||||
>已读</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed } from 'vue'
|
||||
interface Props {
|
||||
label: string
|
||||
placeholder: string
|
||||
modelValue: string[]
|
||||
modelValue?: string[]
|
||||
options: string[]
|
||||
popoverProps: Record<string, any>
|
||||
wide?: boolean
|
||||
@@ -72,7 +72,11 @@ function toggleOption(value: string) {
|
||||
:class="{ active: isSelected(item) }"
|
||||
@click="toggleOption(item)"
|
||||
>
|
||||
<span class="filter-option-check" :class="{ checked: isSelected(item) }" aria-hidden="true" />
|
||||
<span
|
||||
class="filter-option-check"
|
||||
:class="{ checked: isSelected(item) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,14 @@ export function readQueryNumber(query: LocationQuery | LocationQueryRaw, key: st
|
||||
export function readQueryCSV(query: LocationQuery | LocationQueryRaw, key: string): string[] {
|
||||
const raw = readQueryString(query, key)
|
||||
if (!raw) return []
|
||||
return [...new Set(raw.split(',').map(item => item.trim()).filter(Boolean))]
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/** 将多选数组序列化为 CSV;空数组返回 undefined 以便从 query 中省略。 */
|
||||
|
||||
@@ -134,11 +134,7 @@ export function useHomeFilters(
|
||||
}
|
||||
|
||||
function setStringFilter(key: StringFilterKey, value: string | string[]) {
|
||||
filters[key] = Array.isArray(value)
|
||||
? uniqueOptions(value)
|
||||
: value.trim()
|
||||
? [value.trim()]
|
||||
: []
|
||||
filters[key] = Array.isArray(value) ? uniqueOptions(value) : value.trim() ? [value.trim()] : []
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +49,7 @@ import {
|
||||
hasAcceleratedSaleRatio,
|
||||
hasGiftResources,
|
||||
} from '@/shared/utils/listingDisplay'
|
||||
import {
|
||||
buildMobileHomeListSignature,
|
||||
useMobileHomeCacheStore,
|
||||
} from '@/stores/mobileHomeCache'
|
||||
import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -85,7 +82,6 @@ const mobilePageSize = 10
|
||||
let listingRequestSeq = 0
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
|
||||
const sortOptions = [
|
||||
{ key: 'recommended', label: '综合推荐' },
|
||||
{ key: 'coinDesc', label: '哈夫币' },
|
||||
@@ -734,254 +730,259 @@ syncMobileHomeQuery()
|
||||
<template>
|
||||
<main class="mobile-shell">
|
||||
<div ref="scrollEl" class="mobile-scroll" @scroll.passive="handleListScroll">
|
||||
<!-- ========== Hero 区域:顶部搜索与公告 ========== -->
|
||||
<section class="mobile-hero">
|
||||
<div class="mobile-topbar">
|
||||
<div class="mobile-brand">
|
||||
<span class="mobile-logo">锤</span>
|
||||
<div>
|
||||
<strong>大锤商行</strong>
|
||||
<small>哈夫币租号</small>
|
||||
<!-- ========== Hero 区域:顶部搜索与公告 ========== -->
|
||||
<section class="mobile-hero">
|
||||
<div class="mobile-topbar">
|
||||
<div class="mobile-brand">
|
||||
<span class="mobile-logo">锤</span>
|
||||
<div>
|
||||
<strong>大锤商行</strong>
|
||||
<small>哈夫币租号</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="mobile-service"
|
||||
type="button"
|
||||
:disabled="supportLoading"
|
||||
@click="handleSupportClick"
|
||||
>
|
||||
{{ supportLoading ? '接入中' : '客服' }}
|
||||
</button>
|
||||
</div>
|
||||
<RouterLink class="add-home-strip" to="/m/add-home-guide" aria-label="查看添加到手机桌面教程">
|
||||
<span class="add-home-mark">
|
||||
<van-icon name="home-o" :size="18" />
|
||||
</span>
|
||||
<span class="add-home-copy">
|
||||
<strong>添加到手机桌面</strong>
|
||||
<small>下次像 App 一样快速打开</small>
|
||||
</span>
|
||||
<span class="add-home-cta">去添加</span>
|
||||
</RouterLink>
|
||||
<van-search
|
||||
v-model="searchValue"
|
||||
shape="round"
|
||||
placeholder="搜编号 / 区服 / 段位"
|
||||
class="home-search"
|
||||
/>
|
||||
|
||||
<!-- 防骗提示卡片(不用 van-notice-bar) -->
|
||||
<RouterLink class="fraud-tip" to="/m/announcements">
|
||||
<van-icon name="warning-o" :size="16" color="#b8860b" />
|
||||
<span class="fraud-dot"></span>
|
||||
<van-swipe
|
||||
class="announcement-swipe"
|
||||
vertical
|
||||
:autoplay="3200"
|
||||
:show-indicators="false"
|
||||
touchable
|
||||
>
|
||||
<van-swipe-item v-for="item in announcements" :key="item">
|
||||
<span>{{ item }}</span>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
<van-icon class="fraud-more" name="arrow" :size="14" />
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<!-- ========== Content 区域 ========== -->
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<section class="mobile-content">
|
||||
<!-- Banner 轮播 -->
|
||||
<van-swipe class="banner-swipe" :autoplay="3600" lazy-render>
|
||||
<van-swipe-item v-for="slide in bannerSlides" :key="slide.title || slide.image_url">
|
||||
<div
|
||||
class="mobile-banner"
|
||||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
||||
>
|
||||
<img
|
||||
v-if="slide.image_url"
|
||||
class="banner-image"
|
||||
:src="slide.image_url"
|
||||
:alt="slide.title || slide.eyebrow || '首页轮播图'"
|
||||
/>
|
||||
<div class="banner-copy">
|
||||
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
|
||||
<h1 v-if="slide.title">{{ slide.title }}</h1>
|
||||
<span v-if="slide.pill" class="banner-pill">{{ slide.pill }}</span>
|
||||
</div>
|
||||
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
|
||||
<div class="zone-strip" aria-label="账号专区">
|
||||
<button
|
||||
v-for="zone in visibleZoneOptions"
|
||||
:key="zone.key"
|
||||
class="mobile-service"
|
||||
type="button"
|
||||
class="zone-pill"
|
||||
:class="{ active: activeZone === zone.key }"
|
||||
@click="selectZone(zone.key)"
|
||||
:disabled="supportLoading"
|
||||
@click="handleSupportClick"
|
||||
>
|
||||
<strong>{{ zone.label }}</strong>
|
||||
<span>{{ zone.count }}</span>
|
||||
<small>{{ zone.hint }}</small>
|
||||
{{ supportLoading ? '接入中' : '客服' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<button type="button" class="sort-entry" @click="toggleSortPanel">
|
||||
<span>{{ activeSortLabel }}</span>
|
||||
<van-icon :name="sortOpen ? 'arrow-up' : 'arrow-down'" :size="14" />
|
||||
</button>
|
||||
<button type="button" class="filter-entry" @click="openFilters">
|
||||
<van-icon name="filter-o" :size="16" />
|
||||
<span>筛选</span>
|
||||
<em v-if="activeFilterCount">{{ activeFilterCount }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sortOpen" class="sort-panel">
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
:class="{ active: activeSort === option.key }"
|
||||
@click="selectSort(option.key)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<van-icon v-if="activeSort === option.key" name="success" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-count">
|
||||
<strong>{{ totalListings }}</strong>
|
||||
<span>个可租账号</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载/错误状态 -->
|
||||
<van-loading v-if="loading" class="state-loading" size="24px" vertical>
|
||||
正在加载优质账号...
|
||||
</van-loading>
|
||||
<van-notice-bar
|
||||
v-else-if="loadFailed"
|
||||
left-icon="info-o"
|
||||
color="#6b7a90"
|
||||
background="transparent"
|
||||
text="接口暂不可用,请稍后刷新。"
|
||||
<RouterLink
|
||||
class="add-home-strip"
|
||||
to="/m/add-home-guide"
|
||||
aria-label="查看添加到手机桌面教程"
|
||||
>
|
||||
<span class="add-home-mark">
|
||||
<van-icon name="home-o" :size="18" />
|
||||
</span>
|
||||
<span class="add-home-copy">
|
||||
<strong>添加到手机桌面</strong>
|
||||
<small>下次像 App 一样快速打开</small>
|
||||
</span>
|
||||
<span class="add-home-cta">去添加</span>
|
||||
</RouterLink>
|
||||
<van-search
|
||||
v-model="searchValue"
|
||||
shape="round"
|
||||
placeholder="搜编号 / 区服 / 段位"
|
||||
class="home-search"
|
||||
/>
|
||||
<van-empty
|
||||
v-else-if="displayListings.length === 0"
|
||||
class="mobile-empty"
|
||||
image="search"
|
||||
description="没有符合条件的账号"
|
||||
>
|
||||
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
|
||||
</van-empty>
|
||||
|
||||
<!-- 列表卡片:全宽上下布局 -->
|
||||
<div class="mobile-list">
|
||||
<RouterLink
|
||||
v-for="item in displayListings"
|
||||
:key="item.id"
|
||||
class="mobile-card"
|
||||
:to="`/m/listings/${item.id}`"
|
||||
<!-- 防骗提示卡片(不用 van-notice-bar) -->
|
||||
<RouterLink class="fraud-tip" to="/m/announcements">
|
||||
<van-icon name="warning-o" :size="16" color="#b8860b" />
|
||||
<span class="fraud-dot"></span>
|
||||
<van-swipe
|
||||
class="announcement-swipe"
|
||||
vertical
|
||||
:autoplay="3200"
|
||||
:show-indicators="false"
|
||||
touchable
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img
|
||||
v-if="item.cover_url"
|
||||
:src="item.cover_url"
|
||||
:alt="getListingTitle(item)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else>图</span>
|
||||
<div
|
||||
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
|
||||
class="card-cover-labels"
|
||||
>
|
||||
<em v-if="hasGiftResources(item)">有赠送</em>
|
||||
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-main">
|
||||
<div class="card-title-row">
|
||||
<h2>{{ getListingTitle(item) }}</h2>
|
||||
</div>
|
||||
<div class="card-meta-row">
|
||||
<div class="mobile-listing-no">{{ formatListingCode(item) }}</div>
|
||||
<div
|
||||
v-if="getDailyLoss(item) || formatEstimatedRentalDuration(item) !== '--'"
|
||||
class="rental-meta-badge"
|
||||
>
|
||||
<span v-if="getDailyLoss(item)">消耗 {{ getDailyLoss(item) }}/天</span>
|
||||
<span
|
||||
v-if="getDailyLoss(item) && formatEstimatedRentalDuration(item) !== '--'"
|
||||
class="meta-separator"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
<span v-if="formatEstimatedRentalDuration(item) !== '--'">
|
||||
租期 {{ formatEstimatedRentalDuration(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-action-row">
|
||||
<p v-if="getMobileRatioText(item)" class="card-subtitle">
|
||||
{{ getMobileRatioText(item) }}
|
||||
</p>
|
||||
<div class="card-badges-row">
|
||||
<span
|
||||
class="server-badge"
|
||||
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
|
||||
>
|
||||
<van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="13" />
|
||||
{{ getServerRegion(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getLoginMethod(item)"
|
||||
class="server-badge"
|
||||
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
|
||||
>
|
||||
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="13" />
|
||||
{{ getLoginMethod(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="price-col">
|
||||
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
|
||||
<span class="rent-sub">押金 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-chip-row">
|
||||
<span
|
||||
v-for="chip in getListingChips(item)"
|
||||
:key="`${item.id}-${chip.label}`"
|
||||
:class="`tone-${chipTone(chip.label)}`"
|
||||
>
|
||||
{{ chip.label }}:{{ chip.value }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="!loading && displayListings.length" class="mobile-load-state">
|
||||
<span v-if="loadingMore">正在加载更多账号...</span>
|
||||
<span v-else-if="!hasMoreListings">已经到底了</span>
|
||||
</div>
|
||||
<van-swipe-item v-for="item in announcements" :key="item">
|
||||
<span>{{ item }}</span>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
<van-icon class="fraud-more" name="arrow" :size="14" />
|
||||
</RouterLink>
|
||||
</section>
|
||||
</van-pull-refresh>
|
||||
|
||||
<footer class="home-footer">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="home-footer-icp"
|
||||
>湘ICP备2025146668号</a>
|
||||
<span class="home-footer-divider">|</span>
|
||||
<span class="home-footer-copyright">版权所有 ©2026 大锤网络游戏有限公司</span>
|
||||
</footer>
|
||||
<!-- ========== Content 区域 ========== -->
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<section class="mobile-content">
|
||||
<!-- Banner 轮播 -->
|
||||
<van-swipe class="banner-swipe" :autoplay="3600" lazy-render>
|
||||
<van-swipe-item v-for="slide in bannerSlides" :key="slide.title || slide.image_url">
|
||||
<div
|
||||
class="mobile-banner"
|
||||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
||||
>
|
||||
<img
|
||||
v-if="slide.image_url"
|
||||
class="banner-image"
|
||||
:src="slide.image_url"
|
||||
:alt="slide.title || slide.eyebrow || '首页轮播图'"
|
||||
/>
|
||||
<div class="banner-copy">
|
||||
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
|
||||
<h1 v-if="slide.title">{{ slide.title }}</h1>
|
||||
<span v-if="slide.pill" class="banner-pill">{{ slide.pill }}</span>
|
||||
</div>
|
||||
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
|
||||
<div class="zone-strip" aria-label="账号专区">
|
||||
<button
|
||||
v-for="zone in visibleZoneOptions"
|
||||
:key="zone.key"
|
||||
type="button"
|
||||
class="zone-pill"
|
||||
:class="{ active: activeZone === zone.key }"
|
||||
@click="selectZone(zone.key)"
|
||||
>
|
||||
<strong>{{ zone.label }}</strong>
|
||||
<span>{{ zone.count }}</span>
|
||||
<small>{{ zone.hint }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<button type="button" class="sort-entry" @click="toggleSortPanel">
|
||||
<span>{{ activeSortLabel }}</span>
|
||||
<van-icon :name="sortOpen ? 'arrow-up' : 'arrow-down'" :size="14" />
|
||||
</button>
|
||||
<button type="button" class="filter-entry" @click="openFilters">
|
||||
<van-icon name="filter-o" :size="16" />
|
||||
<span>筛选</span>
|
||||
<em v-if="activeFilterCount">{{ activeFilterCount }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sortOpen" class="sort-panel">
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
:class="{ active: activeSort === option.key }"
|
||||
@click="selectSort(option.key)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<van-icon v-if="activeSort === option.key" name="success" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-count">
|
||||
<strong>{{ totalListings }}</strong>
|
||||
<span>个可租账号</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载/错误状态 -->
|
||||
<van-loading v-if="loading" class="state-loading" size="24px" vertical>
|
||||
正在加载优质账号...
|
||||
</van-loading>
|
||||
<van-notice-bar
|
||||
v-else-if="loadFailed"
|
||||
left-icon="info-o"
|
||||
color="#6b7a90"
|
||||
background="transparent"
|
||||
text="接口暂不可用,请稍后刷新。"
|
||||
/>
|
||||
<van-empty
|
||||
v-else-if="displayListings.length === 0"
|
||||
class="mobile-empty"
|
||||
image="search"
|
||||
description="没有符合条件的账号"
|
||||
>
|
||||
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
|
||||
</van-empty>
|
||||
|
||||
<!-- 列表卡片:全宽上下布局 -->
|
||||
<div class="mobile-list">
|
||||
<RouterLink
|
||||
v-for="item in displayListings"
|
||||
:key="item.id"
|
||||
class="mobile-card"
|
||||
:to="`/m/listings/${item.id}`"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img
|
||||
v-if="item.cover_url"
|
||||
:src="item.cover_url"
|
||||
:alt="getListingTitle(item)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else>图</span>
|
||||
<div
|
||||
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
|
||||
class="card-cover-labels"
|
||||
>
|
||||
<em v-if="hasGiftResources(item)">有赠送</em>
|
||||
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-main">
|
||||
<div class="card-title-row">
|
||||
<h2>{{ getListingTitle(item) }}</h2>
|
||||
</div>
|
||||
<div class="card-meta-row">
|
||||
<div class="mobile-listing-no">{{ formatListingCode(item) }}</div>
|
||||
<div
|
||||
v-if="getDailyLoss(item) || formatEstimatedRentalDuration(item) !== '--'"
|
||||
class="rental-meta-badge"
|
||||
>
|
||||
<span v-if="getDailyLoss(item)">消耗 {{ getDailyLoss(item) }}/天</span>
|
||||
<span
|
||||
v-if="getDailyLoss(item) && formatEstimatedRentalDuration(item) !== '--'"
|
||||
class="meta-separator"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
<span v-if="formatEstimatedRentalDuration(item) !== '--'">
|
||||
租期 {{ formatEstimatedRentalDuration(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-action-row">
|
||||
<p v-if="getMobileRatioText(item)" class="card-subtitle">
|
||||
{{ getMobileRatioText(item) }}
|
||||
</p>
|
||||
<div class="card-badges-row">
|
||||
<span
|
||||
class="server-badge"
|
||||
:class="`tone-${getAccessBadgeMeta(getServerRegion(item)).tone}`"
|
||||
>
|
||||
<van-icon :name="getAccessBadgeMeta(getServerRegion(item)).icon" :size="13" />
|
||||
{{ getServerRegion(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getLoginMethod(item)"
|
||||
class="server-badge"
|
||||
:class="`tone-${getAccessBadgeMeta(getLoginMethod(item)).tone}`"
|
||||
>
|
||||
<van-icon :name="getAccessBadgeMeta(getLoginMethod(item)).icon" :size="13" />
|
||||
{{ getLoginMethod(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="price-col">
|
||||
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
|
||||
<span class="rent-sub">押金 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-chip-row">
|
||||
<span
|
||||
v-for="chip in getListingChips(item)"
|
||||
:key="`${item.id}-${chip.label}`"
|
||||
:class="`tone-${chipTone(chip.label)}`"
|
||||
>
|
||||
{{ chip.label }}:{{ chip.value }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="!loading && displayListings.length" class="mobile-load-state">
|
||||
<span v-if="loadingMore">正在加载更多账号...</span>
|
||||
<span v-else-if="!hasMoreListings">已经到底了</span>
|
||||
</div>
|
||||
</section>
|
||||
</van-pull-refresh>
|
||||
|
||||
<footer class="home-footer">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="home-footer-icp"
|
||||
>湘ICP备2025146668号</a
|
||||
>
|
||||
<span class="home-footer-divider">|</span>
|
||||
<span class="home-footer-copyright">版权所有 ©2026 大锤网络游戏有限公司</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<MobileHomeFilterSheet
|
||||
|
||||
@@ -5,11 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||
|
||||
import {
|
||||
fetchOrders,
|
||||
startOrderPayment,
|
||||
type Order,
|
||||
} from '@/features/orders/api/orders'
|
||||
import { fetchOrders, startOrderPayment, type Order } from '@/features/orders/api/orders'
|
||||
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
|
||||
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||
|
||||
@@ -456,7 +456,11 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
</div>
|
||||
|
||||
<div class="handoff-workspace">
|
||||
<OrderHandoffTimeline v-if="order" :records="handoffRecords" :listing-code="listingCode" />
|
||||
<OrderHandoffTimeline
|
||||
v-if="order"
|
||||
:records="handoffRecords"
|
||||
:listing-code="listingCode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OrderCheckoutSummary
|
||||
@@ -608,7 +612,6 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
</el-button>
|
||||
<p class="sidebar-hint">不同意修正时需填写原因,将进入争议处理</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -625,7 +628,11 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
<el-icon><Service /></el-icon>
|
||||
</span>
|
||||
<p>
|
||||
{{ isCheckoutDisputeStage ? '提交后客服会核查结账金额和双方证据。' : '请尽量写清楚时间点、问题经过和证据,便于客服快速处理。' }}
|
||||
{{
|
||||
isCheckoutDisputeStage
|
||||
? '提交后客服会核查结账金额和双方证据。'
|
||||
: '请尽量写清楚时间点、问题经过和证据,便于客服快速处理。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<el-select
|
||||
|
||||
@@ -867,8 +867,6 @@
|
||||
background: #ff6a00 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 370px) {
|
||||
.level-options,
|
||||
.region-grid {
|
||||
|
||||
@@ -230,7 +230,9 @@ function isPendingReview(row: Listing) {
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<section class="listing-list">
|
||||
<van-loading v-if="loading" class="center-loading" size="24px" vertical>加载中...</van-loading>
|
||||
<van-loading v-if="loading" class="center-loading" size="24px" vertical
|
||||
>加载中...</van-loading
|
||||
>
|
||||
|
||||
<div v-else-if="displayListings.length === 0" class="empty-state-wrap">
|
||||
<van-empty description="暂无相关商品" image="search" />
|
||||
|
||||
@@ -562,9 +562,7 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
|
||||
<van-radio name="wechat" :disabled="!!editingAccount">微信</van-radio>
|
||||
<van-radio name="bank" :disabled="!!editingAccount">银行卡</van-radio>
|
||||
</van-radio-group>
|
||||
<p v-if="!editingAccount" class="type-tip">
|
||||
推荐优先添加支付宝,微信和银行卡可按需选择。
|
||||
</p>
|
||||
<p v-if="!editingAccount" class="type-tip">推荐优先添加支付宝,微信和银行卡可按需选择。</p>
|
||||
|
||||
<van-field
|
||||
v-model.trim="accountForm.account_name"
|
||||
|
||||
Reference in New Issue
Block a user