chore: 删除旧的 api/, views/, composables/ 目录

所有文件已迁移到 features/ 架构:
- 删除 111 个旧文件
- api/ → features/*/api/
- views/ → features/*/views/
- composables/ → features/*/composables/

清理完成,架构迁移进入最后阶段。
This commit is contained in:
yml2213
2026-06-04 09:13:15 +08:00
parent 3d5db9d0b6
commit 5e878585ad
111 changed files with 0 additions and 33317 deletions
-32
View File
@@ -1,32 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface AdminAuditLog {
id: number
actor_type: string
actor_id: number
actor_username: string
actor_nickname: string
action: string
biz_type: string
biz_id?: number
ip: string
user_agent: string
detail?: Record<string, unknown> | null
created_at: string
}
export interface AdminAuditQuery {
actor_id?: string
action?: string
biz_type?: string
page?: number
page_size?: number
}
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params })
return data.data
}
-74
View File
@@ -1,74 +0,0 @@
import axios from 'axios'
import { apiClient } from './client'
import type { ApiResponse } from './types'
import { getRefreshToken, setAuthTokens } from '@/utils/authStorage'
import type { UserStatus } from '@/types/status'
export interface AdminRole {
id: number
code: string
name: string
}
export interface AdminUser {
id: number
username: string
nickname: string
status: UserStatus
roles: AdminRole[]
permissions: string[]
last_login_at?: string
}
export interface AdminTokenPair {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
export interface AdminLoginData {
admin: AdminUser
tokens: AdminTokenPair
}
export interface AdminCaptcha {
captcha_id: string
image: string
expires_in: number
}
export async function fetchAdminCaptcha() {
const { data } = await apiClient.get<ApiResponse<AdminCaptcha>>('/admin/auth/captcha')
return data.data
}
export async function loginAdmin(username: string, password: string, captchaId: string, captchaCode: string) {
const { data } = await apiClient.post<ApiResponse<AdminLoginData>>('/admin/auth/login', {
username,
password,
captcha_id: captchaId,
captcha_code: captchaCode,
})
return data.data
}
export async function fetchAdminMe() {
const { data } = await apiClient.get<ApiResponse<AdminUser>>('/admin/me')
return data.data
}
export async function logoutAdmin() {
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
return data.data
}
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
export async function refreshAdminSession() {
const refreshToken = getRefreshToken('admin')
if (!refreshToken) throw new Error('no refresh token')
const { data } = await axios.post<ApiResponse<AdminTokenPair>>('/api/admin/auth/refresh', { refresh_token: refreshToken }, { timeout: 10000 })
setAuthTokens('admin', data.data)
return data.data
}
-61
View File
@@ -1,61 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import type { DisputeStatus, OrderStatus } from '@/types/status'
export interface DashboardMetrics {
total_users: number
verified_users: number
total_listings: number
published_listings: number
total_orders: number
renting_orders: number
today_orders: number
today_ledger_amount: number
}
export interface DashboardPending {
listing_reviews: number
disputes: number
pending_handoffs: number
pending_return_confirms: number
}
export interface DashboardRecentOrder {
id: number
order_no: string
title: string
renter_id: number
owner_id: number
status: OrderStatus
rent_amount: number
deposit_amount: number
created_at: string
}
export interface DashboardRecentDispute {
id: number
order_id: number
order_no: string
title: string
type: string
status: DisputeStatus
initiator_id: number
created_at: string
}
export interface AdminDashboard {
metrics: DashboardMetrics
pending: DashboardPending
recent_orders: DashboardRecentOrder[]
recent_disputes: DashboardRecentDispute[]
generated_at: string
}
export async function fetchAdminDashboard() {
const { data } = await apiClient.get<ApiResponse<AdminDashboard>>('/admin/dashboard')
return {
...data.data,
recent_orders: data.data.recent_orders ?? [],
recent_disputes: data.data.recent_disputes ?? [],
}
}
-76
View File
@@ -1,76 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface AdminRole {
id: number
code: string
name: string
description: string
}
export interface AdminMgrUser {
id: number
username: string
nickname: string
status: string
roles: AdminRole[]
last_login_at?: string
created_at: string
updated_at: string
}
export interface CreateAdminRequest {
username: string
password: string
nickname?: string
}
export interface UpdateAdminRequest {
nickname?: string
status?: string
}
export interface ChangePasswordRequest {
old_password: string
new_password: string
}
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>('/admin/admin-users', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchAdminMgrUser(id: number) {
const { data } = await apiClient.get<ApiResponse<AdminMgrUser>>(`/admin/admin-users/${id}`)
return data.data
}
export async function createAdminMgrUser(req: CreateAdminRequest) {
const { data } = await apiClient.post<ApiResponse<AdminMgrUser>>('/admin/admin-users', req)
return data.data
}
export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) {
const { data } = await apiClient.put<ApiResponse<AdminMgrUser>>(`/admin/admin-users/${id}`, req)
return data.data
}
export async function deleteAdminMgrUser(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/admin-users/${id}`)
return data.data
}
export async function assignAdminRoles(id: number, roleIds: number[]) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/admin-users/${id}/roles`, {
role_ids: roleIds,
})
return data.data
}
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/admin-users/${id}/password`, req)
return data.data
}
-70
View File
@@ -1,70 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
export interface Permission {
id: number
code: string
name: string
resource: string
action: string
}
export interface Role {
id: number
code: string
name: string
description: string
perm_count: number
permissions?: Permission[]
created_at: string
updated_at: string
}
export interface CreateRoleRequest {
code: string
name: string
description?: string
}
export interface UpdateRoleRequest {
name: string
description?: string
}
export async function fetchRoles() {
const { data } = await apiClient.get<ApiResponse<Role[]>>('/admin/roles')
return data.data
}
export async function fetchRole(id: number) {
const { data } = await apiClient.get<ApiResponse<Role>>(`/admin/roles/${id}`)
return data.data
}
export async function createRole(req: CreateRoleRequest) {
const { data } = await apiClient.post<ApiResponse<Role>>('/admin/roles', req)
return data.data
}
export async function updateRole(id: number, req: UpdateRoleRequest) {
const { data } = await apiClient.put<ApiResponse<Role>>(`/admin/roles/${id}`, req)
return data.data
}
export async function deleteRole(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/roles/${id}`)
return data.data
}
export async function assignRolePermissions(roleId: number, permissionIds: number[]) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/roles/${roleId}/permissions`, {
permission_ids: permissionIds,
})
return data.data
}
export async function fetchPermissions() {
const { data } = await apiClient.get<ApiResponse<Permission[]>>('/admin/permissions')
return data.data
}
-37
View File
@@ -1,37 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status'
export interface AdminUserItem {
id: number
phone: string
nickname: string
realname_status: RealnameStatusValue
risk_status: RiskStatus
credit_score: number
status: UserStatus
order_count: number
listing_count: number
dispute_count: number
last_login_at?: string
created_at: string
updated_at: string
}
export async function fetchAdminUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function freezeAdminUser(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/freeze`, { reason })
return data.data
}
export async function unfreezeAdminUser(id: number) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/unfreeze`)
return data.data
}
-36
View File
@@ -1,36 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
import type { BalanceType, LedgerDirection } from '@/types/status'
export interface AdminWalletLedger {
id: number
ledger_no: string
user_id: number
user_phone: string
user_nickname: string
order_id?: number
order_no: string
direction: LedgerDirection
amount: number
balance_after: number
balance_type: BalanceType
biz_type: string
biz_no: string
remark: string
created_at: string
}
export interface AdminWalletLedgerQuery {
user_id?: string
order_id?: string
biz_type?: string
page?: number
page_size?: number
}
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params })
return data.data
}
-56
View File
@@ -1,56 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/types/status'
export interface AuthUser {
id: number
phone: string
nickname: string
avatar_url: string
realname_status: RealnameStatusValue
risk_status: RiskStatus
credit_score: number
status: UserStatus
}
export interface TokenPair {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
export interface LoginData {
user: AuthUser
tokens: TokenPair
}
export async function sendSmsCode(phone: string) {
const { data } = await apiClient.post<ApiResponse<{ phone: string; expires_in: number }>>('/auth/sms/send', {
phone,
})
return data.data
}
export async function loginWithSms(phone: string, code: string) {
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/sms/login', { phone, code })
return data.data
}
export async function fetchMe() {
const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me')
return data.data
}
export async function updateMe(payload: Pick<AuthUser, 'nickname' | 'avatar_url'>) {
const { data } = await apiClient.put<ApiResponse<AuthUser>>('/me', payload)
return data.data
}
/** Manually refresh user token (for store to call on app init) */
export async function refreshUserToken(refreshToken: string) {
const { data } = await apiClient.post<ApiResponse<TokenPair>>('/auth/refresh', {
refresh_token: refreshToken,
})
return data.data
}
-187
View File
@@ -1,187 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface ChatParticipant {
id: number
conversation_id: number
participant_type: 'user' | 'admin'
participant_id: number
role: 'renter' | 'owner' | 'support' | 'customer'
remark: string
display_name: string
avatar_url: string
last_read_at?: string
joined_at: string
}
export interface ChatConversation {
id: number
order_id: number | null
type: string
title: string
status: string
role: 'renter' | 'owner' | 'support' | 'customer'
participants?: ChatParticipant[]
last_message_id?: number
last_message_preview: string
last_message_at?: string
unread_count: number
created_at: string
updated_at: string
}
export interface ChatMessage {
id: number
conversation_id: number
sender_type: 'user' | 'admin' | 'system'
sender_id: number
sender_role: 'renter' | 'owner' | 'support' | 'customer' | 'system'
sender_name: string
sender_avatar: string
is_self: boolean
content_type: 'text' | 'system'
content: string
attachment_urls: string[]
created_at: string
}
export async function fetchChats(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/chats', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchChat(id: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/chats/${id}`)
return data.data
}
export async function fetchOrderChat(orderId: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/orders/${orderId}/chat`)
return data.data
}
export async function ensureSupportChat() {
const { data } = await apiClient.post<ApiResponse<ChatConversation>>('/chats/support')
return data.data
}
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
params: { page, page_size: pageSize },
})
return data.data
}
export async function sendChatMessage(id: number, content: string, attachmentUrls: string[] = []) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
content,
attachment_urls: attachmentUrls,
})
return data.data
}
export async function markChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/chats/${id}/read`)
return data.data
}
export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
params: { page, page_size: pageSize, filter },
})
return data.data
}
export async function fetchAdminChat(id: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/admin/chats/${id}`)
return data.data
}
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/admin/chats/${id}/messages`, {
params: { page, page_size: pageSize },
})
return data.data
}
export async function sendAdminChatMessage(id: number, content: string, attachmentUrls: string[] = []) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, {
content,
attachment_urls: attachmentUrls,
})
return data.data
}
export async function markAdminChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/admin/chats/${id}/read`)
return data.data
}
export interface SupportAdmin {
id: number
nickname: string
chat_count: number
}
export async function fetchSupportAdmins() {
const { data } = await apiClient.get<ApiResponse<SupportAdmin[]>>('/admin/chats/support-admins')
return data.data
}
export async function transferChat(id: number, toAdminId: number) {
const { data } = await apiClient.post<ApiResponse<{ transferred: boolean }>>(`/admin/chats/${id}/transfer`, {
to_admin_id: toAdminId,
})
return data.data
}
export async function updateChatRemark(id: number, remark: string) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/${id}/remark`, { remark })
return data.data
}
export interface QuickReply {
id: number
admin_user_id: number
title: string
content: string
sort_order: number
is_global: boolean
}
export async function fetchQuickReplies() {
const { data } = await apiClient.get<ApiResponse<QuickReply[]>>('/admin/chats/quick-replies')
return data.data
}
export async function createQuickReply(title: string, content: string, sortOrder = 0, isGlobal = false) {
const { data } = await apiClient.post<ApiResponse<QuickReply>>('/admin/chats/quick-replies', {
title,
content,
sort_order: sortOrder,
is_global: isGlobal,
})
return data.data
}
export async function updateQuickReply(id: number, updates: { title?: string; content?: string; sort_order?: number }) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/quick-replies/${id}`, updates)
return data.data
}
export async function deleteQuickReply(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/chats/quick-replies/${id}`)
return data.data
}
export async function fetchAutoWelcomeMessage() {
const { data } = await apiClient.get<ApiResponse<{ message: string }>>('/admin/chats/auto-welcome')
return data.data.message
}
export async function updateAutoWelcomeMessage(message: string) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>('/admin/chats/auto-welcome', { message })
return data.data
}
-172
View File
@@ -1,172 +0,0 @@
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import { showToast } from 'vant'
function showError(message: string) {
const isMobile = window.location.pathname.startsWith('/m')
if (isMobile) {
showToast({ message, icon: 'cross' })
} else {
ElMessage.error(message)
}
}
declare module 'axios' {
export interface AxiosRequestConfig {
silent?: boolean
}
}
import {
clearAuthStorage,
getAccessToken,
getLoginPath,
getRefreshToken,
setAuthTokens,
type AuthScope,
} from '@/utils/authStorage'
import type { ApiResponse } from './types'
export const apiClient = axios.create({
baseURL: '/api',
timeout: 10000,
})
export async function unwrapData<T>(request: Promise<AxiosResponse<ApiResponse<T>>>) {
const { data } = await request
return data.data
}
type RetryRequest = {
resolve: (token: string) => void
reject: (error: unknown) => void
}
type RefreshState = {
refreshing: boolean
pendingRequests: RetryRequest[]
}
type RetriableRequestConfig = InternalAxiosRequestConfig & {
_retry?: boolean
silent?: boolean
}
const refreshStates: Record<AuthScope, RefreshState> = {
user: {
refreshing: false,
pendingRequests: [],
},
admin: {
refreshing: false,
pendingRequests: [],
},
}
function resolvePendingRequests(scope: AuthScope, token: string) {
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
pendingRequests.forEach(({ resolve }) => resolve(token))
}
function rejectPendingRequests(scope: AuthScope, error: unknown) {
const pendingRequests = refreshStates[scope].pendingRequests.splice(0)
pendingRequests.forEach(({ reject }) => reject(error))
}
export async function refreshAccessToken(scope: AuthScope): Promise<string> {
const refreshToken = getRefreshToken(scope)
if (!refreshToken) throw new Error('no refresh token')
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh'
const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 })
const tokens = {
access_token: data.data.access_token,
refresh_token: data.data.refresh_token,
}
setAuthTokens(scope, tokens)
return tokens.access_token
}
function getRequestScope(url = ''): AuthScope {
return url.startsWith('/admin') ? 'admin' : 'user'
}
function redirectToLogin(scope: AuthScope) {
clearAuthStorage(scope)
const currentPath = window.location.pathname + window.location.search
const loginPath = getLoginPath(scope, currentPath)
if (currentPath.startsWith(loginPath)) return
if (scope === 'admin') {
window.location.assign(loginPath)
return
}
window.location.assign(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`)
}
function isRefreshRequest(url = '') {
return url.endsWith('/auth/refresh') || url.endsWith('/admin/auth/refresh')
}
apiClient.interceptors.request.use((config) => {
const scope = getRequestScope(config.url || '')
const token = getAccessToken(scope)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as RetriableRequestConfig | undefined
if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) {
if (originalRequest && !originalRequest.silent) {
const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试'
showError(msg)
}
return Promise.reject(error)
}
const requestUrl = originalRequest.url || ''
const scope = getRequestScope(requestUrl)
if (isRefreshRequest(requestUrl)) {
redirectToLogin(scope)
if (originalRequest && !originalRequest.silent) {
showError('登录已失效,请重新登录')
}
return Promise.reject(error)
}
const state = refreshStates[scope]
if (state.refreshing) {
return new Promise<string>((resolve, reject) => {
state.pendingRequests.push({ resolve, reject })
}).then((newToken) => {
originalRequest._retry = true
originalRequest.headers.Authorization = `Bearer ${newToken}`
return apiClient(originalRequest)
})
}
state.refreshing = true
try {
const newToken = await refreshAccessToken(scope)
resolvePendingRequests(scope, newToken)
originalRequest._retry = true
originalRequest.headers.Authorization = `Bearer ${newToken}`
return apiClient(originalRequest)
} catch (refreshError) {
rejectPendingRequests(scope, refreshError)
redirectToLogin(scope)
if (originalRequest && !originalRequest.silent) {
showError('会话已过期,请重新登录')
}
return Promise.reject(refreshError)
} finally {
state.refreshing = false
}
},
)
-47
View File
@@ -1,47 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
import type { DisputeStatus } from '@/types/status'
export interface Dispute {
id: number
order_id: number
order_no: string
title: string
initiator_id: number
target_user_id: number
type: string
status: DisputeStatus
description: string
evidence_urls?: string[]
arbitration_result: string
arbitration_remark: string
handled_by?: number
handled_at?: string
created_at: string
updated_at: string
}
export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: string[] }) {
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute`, payload)
return data.data
}
export async function fetchDisputes(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchAdminDisputes(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/admin/disputes', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) {
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload)
return data.data
}
-51
View File
@@ -1,51 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import { optimizeImageForUpload } from '@/utils/imageUpload'
export interface UploadedFile {
object_key: string
url: string
thumbnail_url?: string
medium_url?: string
filename: string
content_type: string
size: number
}
export async function uploadFile(file: File, scene: string) {
const uploadTarget = await optimizeImageForUpload(file, scene)
const form = new FormData()
form.append('file', uploadTarget)
form.append('scene', scene)
const { data } = await apiClient.post<ApiResponse<UploadedFile>>('/files/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data.data
}
export async function uploadAdminFile(file: File, scene: string) {
const uploadTarget = await optimizeImageForUpload(file, scene)
const form = new FormData()
form.append('file', uploadTarget)
form.append('scene', scene)
const { data } = await apiClient.post<ApiResponse<UploadedFile>>('/admin/files/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data.data
}
export async function fetchAdminFileBlob(key: string) {
const { data } = await apiClient.get<Blob>('/admin/files/object', {
params: { key },
responseType: 'blob',
})
return data
}
export async function fetchFileBlobByURL(fileURL: string) {
const apiPath = fileURL.startsWith('/api/') ? fileURL.slice(4) : fileURL
const { data } = await apiClient.get<Blob>(apiPath, {
responseType: 'blob',
})
return data
}
-95
View File
@@ -1,95 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import {
mergeListingPublishOptions,
type ListingPublishOptions,
} from './listingOptions'
export interface HomeBannerSlide {
eyebrow: string
title: string
badge: string
pill: string
tone: string
image_url?: string
}
export interface MobileHomeConfig {
announcements: string[]
banners: HomeBannerSlide[]
publish_options: ListingPublishOptions
}
export const defaultHomeAnnouncements = [
'平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。',
'优先推荐同地区账号,减少异地登录保护触发。',
'下单前请核对哈夫币、保险、体力负重和截图信息。',
]
export const defaultHomeBanners: HomeBannerSlide[] = [
{
eyebrow: '三角洲行动账号专区',
title: '高哈夫币 · 安全交接 · 随租随玩',
badge: 'HOT',
pill: '新人领券最高减 20 元',
tone: 'blue',
image_url: '',
},
{
eyebrow: '平台担保交易',
title: '交接全程留痕,拒绝私下转账',
badge: 'SAFE',
pill: '租号前先验实名与资料',
tone: 'green',
image_url: '',
},
{
eyebrow: '高效筛选',
title: '按区服、段位、哈夫币快速找号',
badge: 'FAST',
pill: '支持扫码号与账密号',
tone: 'orange',
image_url: '',
},
]
export async function fetchMobileHomeConfig() {
const { data } = await apiClient.get<ApiResponse<MobileHomeConfig>>('/mobile-home-config')
return mergeHomeConfig(data.data)
}
export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeConfig {
const announcements =
config?.announcements
?.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean) || []
const banners =
config?.banners
?.map((item) => {
const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>)
return {
eyebrow: banner.eyebrow?.trim() || '',
title: banner.title?.trim() || '',
badge: banner.badge?.trim() || '',
pill: banner.pill?.trim() || '',
tone: normalizeBannerTone(banner.tone),
image_url: banner.image_url?.trim() || '',
}
})
.filter((item) => item.title || item.image_url) || []
return {
announcements: announcements.length ? announcements : defaultHomeAnnouncements,
banners: banners.length ? banners : defaultHomeBanners,
publish_options: mergeListingPublishOptions(config?.publish_options),
}
}
function isBannerLike(value: unknown): value is Partial<HomeBannerSlide> {
return typeof value === 'object' && value !== null
}
function normalizeBannerTone(tone?: string) {
if (tone === 'green' || tone === 'orange') return tone
return 'blue'
}
-398
View File
@@ -1,398 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
export type ChargeMode = '赠送' | '收费'
export type QuantityKey = string
export type ScreenshotKey = string
export type SkinCategoryKey = string
export interface PublishOptionGroup {
key: SkinCategoryKey | string
title: string
options: string[]
}
export interface PublishQuantityItem {
key: QuantityKey
label: string
price: string
placeholder?: string
}
export interface PublishScreenshotSlot {
key: ScreenshotKey
label: string
required: boolean
hint: string
}
export interface PublishPriceConfig {
deposit_placeholder: string
price_placeholder: string
ratio_description: string
}
export interface PublishDepositSkinGroupRule {
group_key: string
label: string
amount_per_item: number
}
export interface PublishDepositRecommendConfig {
base_amount: number
skin_group_rules: PublishDepositSkinGroupRule[]
}
export interface PublishInsuranceBaseRatio {
insurance: string
ratio: number
}
export interface PublishRatioConfigItem {
key: string
label: string
kind: string
group_key?: string
missing_penalty: number
}
export interface PublishCoinCorrection {
threshold_m: number
correction: number
}
export interface PublishSaleFixedMarkupRule {
min_m: number
max_m: number
markup_amount: number
}
export interface PublishSaleRatioAdjustmentRule {
min_m: number
max_m: number
ratio_subtract: number
}
export interface PublishRatioConfig {
insurance_base_ratios: PublishInsuranceBaseRatio[]
config_items: PublishRatioConfigItem[]
coin_corrections: PublishCoinCorrection[]
}
export interface PublishSalePriceConfig {
fixed_markup_rules: PublishSaleFixedMarkupRule[]
ratio_adjustment_rules: PublishSaleRatioAdjustmentRule[]
}
export interface ListingPublishOptions {
server_options: string[]
face_options: string[]
rank_options: string[]
insurance_options: string[]
level_options: string[]
login_method_options: string[]
region_options: string[]
skin_groups: PublishOptionGroup[]
quantity_items: PublishQuantityItem[]
screenshot_slots: PublishScreenshotSlot[]
ban_record_options: string[]
ban_evidence_options: string[]
fire_level_min: number
price_config: PublishPriceConfig
deposit_recommend_config: PublishDepositRecommendConfig
ratio_config: PublishRatioConfig
}
export const emptyListingPublishOptions: ListingPublishOptions = {
server_options: [],
face_options: [],
rank_options: [],
insurance_options: [],
level_options: [],
login_method_options: [],
region_options: [],
skin_groups: [],
quantity_items: [],
screenshot_slots: [],
ban_record_options: [],
ban_evidence_options: [],
fire_level_min: 38,
price_config: {
deposit_placeholder: '',
price_placeholder: '',
ratio_description: '',
},
deposit_recommend_config: {
base_amount: 50,
skin_group_rules: [
{ group_key: 'melee', label: '刀皮', amount_per_item: 5 },
{ group_key: 'operatorGold', label: '干员金皮', amount_per_item: 10 },
{ group_key: 'operatorRed', label: '干员红皮', amount_per_item: 30 },
],
},
ratio_config: {
insurance_base_ratios: [],
config_items: [],
coin_corrections: [],
},
}
export const emptyListingSalePriceConfig: PublishSalePriceConfig = {
fixed_markup_rules: [
{ min_m: 10, max_m: 30, markup_amount: 28 },
{ min_m: 30, max_m: 50, markup_amount: 31 },
{ min_m: 50, max_m: 70, markup_amount: 34 },
{ min_m: 70, max_m: 90, markup_amount: 38 },
],
ratio_adjustment_rules: [
{ min_m: 90, max_m: 150, ratio_subtract: 5 },
{ min_m: 150, max_m: 230, ratio_subtract: 4 },
{ min_m: 230, max_m: 310, ratio_subtract: 3.5 },
{ min_m: 310, max_m: 390, ratio_subtract: 3 },
{ min_m: 390, max_m: 470, ratio_subtract: 0 },
{ min_m: 470, max_m: 550, ratio_subtract: 0 },
{ min_m: 550, max_m: 0, ratio_subtract: 0 },
],
}
export async function fetchListingPublishOptions() {
const { data } = await apiClient.get<ApiResponse<ListingPublishOptions>>('/listing-publish-options')
return mergeListingPublishOptions(data.data)
}
export async function fetchListingSalePriceConfig() {
const { data } = await apiClient.get<ApiResponse<PublishSalePriceConfig>>('/listing-sale-price-config')
return mergeListingSalePriceConfig(data.data)
}
export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions {
return {
server_options: normalizeStringList(options?.server_options),
face_options: normalizeStringList(options?.face_options),
rank_options: normalizeStringList(options?.rank_options),
insurance_options: normalizeStringList(options?.insurance_options),
level_options: normalizeStringList(options?.level_options),
login_method_options: normalizeStringList(options?.login_method_options),
region_options: normalizeStringList(options?.region_options),
skin_groups: normalizeOptionGroups(options?.skin_groups),
quantity_items: normalizeQuantityItems(options?.quantity_items),
screenshot_slots: normalizeScreenshotSlots(options?.screenshot_slots),
ban_record_options: normalizeStringList(options?.ban_record_options),
ban_evidence_options: normalizeStringList(options?.ban_evidence_options),
fire_level_min: readPositiveInteger(options?.fire_level_min, 38),
price_config: normalizePriceConfig(options?.price_config),
deposit_recommend_config: normalizeDepositRecommendConfig(options?.deposit_recommend_config),
ratio_config: normalizeRatioConfig(options?.ratio_config),
}
}
export function mergeListingSalePriceConfig(options?: Partial<PublishSalePriceConfig>): PublishSalePriceConfig {
return normalizeSalePriceConfig(options)
}
function normalizeStringList(values?: unknown[]) {
return Array.isArray(values)
? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
: []
}
function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
if (!Array.isArray(values)) return []
return values
.map((item) => {
const group = isRecord(item) ? item : {}
return {
key: typeof group.key === 'string' ? group.key.trim() : '',
title: typeof group.title === 'string' ? group.title.trim() : '',
options: normalizeStringList(Array.isArray(group.options) ? group.options : []),
}
})
.filter((item) => item.key && item.title)
}
function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
if (!Array.isArray(values)) return []
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
price: typeof row.price === 'string' ? row.price.trim() : '',
placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined,
}
})
.filter((item) => item.key && item.label)
}
function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
if (!Array.isArray(values)) return []
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
required: row.required === true,
hint: typeof row.hint === 'string' ? row.hint.trim() : '',
}
})
.filter((item) => item.key && item.label)
}
function normalizePriceConfig(value?: unknown): PublishPriceConfig {
const row = isRecord(value) ? value : {}
return {
deposit_placeholder: typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '',
price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
}
}
function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecommendConfig {
const row = isRecord(value) ? value : {}
const config = {
base_amount: readNumber(row.base_amount),
skin_group_rules: normalizeDepositSkinGroupRules(
Array.isArray(row.skin_group_rules) ? row.skin_group_rules : [],
),
}
if (config.base_amount <= 0) {
config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount
}
if (config.skin_group_rules.length === 0) {
config.skin_group_rules = [...emptyListingPublishOptions.deposit_recommend_config.skin_group_rules]
}
return config
}
function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] {
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
amount_per_item: readNumber(row.amount_per_item),
}
})
.filter((item) => item.group_key && item.label && item.amount_per_item >= 0)
}
function normalizeRatioConfig(value?: unknown): PublishRatioConfig {
const row = isRecord(value) ? value : {}
return {
insurance_base_ratios: normalizeInsuranceBaseRatios(
Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : [],
),
config_items: normalizeRatioConfigItems(
Array.isArray(row.config_items) ? row.config_items : [],
),
coin_corrections: normalizeCoinCorrections(
Array.isArray(row.coin_corrections) ? row.coin_corrections : [],
),
}
}
function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
const row = isRecord(value) ? value : {}
const config = {
fixed_markup_rules: normalizeSaleFixedMarkupRules(
Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : [],
),
ratio_adjustment_rules: normalizeSaleRatioAdjustmentRules(
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : [],
),
}
if (config.fixed_markup_rules.length === 0 && config.ratio_adjustment_rules.length === 0) {
return JSON.parse(JSON.stringify(emptyListingSalePriceConfig)) as PublishSalePriceConfig
}
return config
}
function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] {
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '',
ratio: readNumber(row.ratio),
}
})
.filter((item) => item.insurance && item.ratio > 0)
}
function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] {
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
kind: typeof row.kind === 'string' ? row.kind.trim() : '',
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
missing_penalty: readNumber(row.missing_penalty),
}
})
.filter((item) => item.key && item.label && item.kind)
}
function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] {
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
threshold_m: readNumber(row.threshold_m),
correction: readNumber(row.correction),
}
})
.filter((item) => item.threshold_m >= 0 && item.correction > 0)
}
function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] {
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
min_m: readNumber(row.min_m),
max_m: readNumber(row.max_m),
markup_amount: readNumber(row.markup_amount),
}
})
.filter((item) => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0)
}
function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] {
return values
.map((item) => {
const row = isRecord(item) ? item : {}
return {
min_m: readNumber(row.min_m),
max_m: readNumber(row.max_m),
ratio_subtract: readNumber(row.ratio_subtract),
}
})
.filter(
(item) =>
item.min_m >= 0 &&
(item.max_m === 0 || item.max_m >= item.min_m) &&
item.ratio_subtract >= 0,
)
}
function readNumber(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function readPositiveInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(readNumber(value))
return parsed > 0 ? parsed : fallback
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
-194
View File
@@ -1,194 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import type { ListingReviewStatus, ListingStatus } from '@/types/status'
export interface Listing {
id: number
account_id: number
owner_id: number
owner_phone?: string
owner_nickname?: string
title: string
description: string
game_name: string
server_region: string
login_platform: string
rank_level: string
haf_coin_amount: number
asset_summary?: Record<string, unknown>
screenshot_urls: string[]
cover_url: string
price: number
deposit_amount: number
is_accelerated_sale?: boolean
in_transaction: boolean
status: ListingStatus
review_status: ListingReviewStatus
review_reason: string
published_at?: string
created_at: string
updated_at: string
}
export interface ListingPayload {
title: string
description: string
server_region: string
login_platform: string
rank_level: string
haf_coin_amount: number
asset_summary?: Record<string, unknown>
screenshot_urls: string[]
price: number
deposit_amount: number
}
export interface PublicListingQuery {
page?: number
page_size?: number
keyword?: string
sort?: string
zone?: string
server?: string
region?: string
login_method?: string
rank?: string
insurance?: string
stamina?: string
load?: string
skin_group?: string
skin_name?: string
min_coin?: number
max_coin?: number
min_price?: number
max_price?: number
min_deposit?: number
max_deposit?: number
min_total?: number
max_total?: number
min_fire_level?: number
max_fire_level?: number
min_secret_kd?: number
max_secret_kd?: number
[key: `resource_${string}_min`]: number | undefined
[key: `resource_${string}_max`]: number | undefined
}
export interface PublicListingPage {
items: Listing[]
total: number
page: number
page_size: number
zone_counts: Record<string, number>
}
export async function fetchListings(query: PublicListingQuery = {}) {
const page = await fetchListingsPage(query)
return page.items
}
export async function fetchListingsPage(query: PublicListingQuery = {}) {
const params = Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined && value !== null)
)
const { data } = await apiClient.get<ApiResponse<Partial<PublicListingPage>>>('/listings', { params })
return normalizePublicListingPage(data.data, query)
}
function normalizePublicListingPage(data: Partial<PublicListingPage>, query: PublicListingQuery): PublicListingPage {
const items = Array.isArray(data.items) ? data.items : []
return {
items,
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length,
page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1),
page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 20),
zone_counts: data.zone_counts && typeof data.zone_counts === 'object' ? data.zone_counts : {},
}
}
export async function fetchListing(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`)
return data.data
}
export async function fetchSellerListings() {
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/seller/listings')
return data.data.items
}
export async function createListing(payload: ListingPayload) {
const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload)
return data.data
}
export async function submitListingReview(id: number) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`)
return data.data
}
export async function offlineListing(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
return data.data
}
export async function fetchPendingReviewListings() {
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings/pending')
return data.data.items
}
export interface AdminListingQuery {
owner_id?: string
status?: ListingStatus | ''
review_status?: ListingReviewStatus | ''
limit?: number
page?: number
page_size?: number
}
export interface AdminListingPage {
items: Listing[]
total: number
page: number
page_size: number
}
export async function fetchAdminListings(query: AdminListingQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
const { data } = await apiClient.get<ApiResponse<AdminListingPage>>('/admin/listings', { params })
return normalizeAdminListingPage(data.data, query)
}
function normalizeAdminListingPage(data: Partial<AdminListingPage>, query: AdminListingQuery): AdminListingPage {
const items = Array.isArray(data.items) ? data.items : []
return {
items,
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length,
page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1),
page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 10),
}
}
export async function fetchAdminListing(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Listing>>(`/admin/listings/${id}`)
return data.data
}
export async function adminOfflineListing(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
return data.data
}
export async function adminMarkListingAbnormal(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/mark-abnormal`, { reason })
return data.data
}
export async function approveListing(id: number) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
return data.data
}
export async function rejectListing(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
return data.data
}
-27
View File
@@ -1,27 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface NotificationItem {
id: number
user_id: number
type: string
title: string
content: string
biz_type: string
biz_id?: number
read_at?: string
created_at: string
}
export async function fetchNotifications(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>('/notifications', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function markNotificationRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/notifications/${id}/read`)
return data.data
}
-244
View File
@@ -1,244 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/types/status'
export interface Order {
id: number
order_no: string
listing_id: number
account_id: number
owner_id: number
renter_id: number
owner_phone?: string
renter_phone?: string
title: string
server_region: string
login_platform: string
rented_at?: string
estimated_duration_hours: number
price_role?: 'renter' | 'owner' | 'admin' | string
display_amount: number
rent_amount?: number
owner_rent_amount?: number
deposit_amount: number
platform_fee?: number
account_snapshot?: Record<string, unknown>
status: OrderStatus
handoff_status: HandoffStatus
settlement_status: SettlementStatus
checkout?: Checkout
created_at: string
updated_at: string
}
export interface Checkout {
id: number
order_id: number
initiated_by: number
status: SettlementStatus
price_role?: 'renter' | 'owner' | 'admin' | string
display_amount: number
rent_amount?: number
owner_rent_amount?: number
platform_fee?: number
deposit_amount: number
consumable_amount: number
coin_consumed_m: number
other_amount: number
deposit_deduct_amount: number
renter_refund_amount?: number
owner_income_amount?: number
content: string
evidence_urls: string[]
owner_adjustment_reason: string
owner_adjusted_at?: string
renter_confirmed_at?: string
renter_rejected_at?: string
created_at: string
updated_at: string
}
export interface HandoffRecord {
id: number
order_id: number
from_user_id: number
to_user_id: number
type: string
content: string
confirmed_by_renter_at?: string
confirmed_by_owner_at?: string
created_at: string
}
export interface PaymentOrder {
id: number
payment_no: string
order_id: number
order_no: string
provider: string
third_order_id: string
provider_order_id: string
pay_way: string
jspay_flag: string
amount_cent: number
status: string
td_code?: string
jspay_url?: string
jspay_info?: string
paid: boolean
paid_at?: string
created_at: string
updated_at: string
}
export interface PayOrderResult {
paid: boolean
}
export interface SubmitCheckoutPayload {
content: string
consumable_amount: number
coin_consumed_m: number
other_amount: number
evidence_urls: string[]
}
export interface CounterCheckoutPayload {
consumable_amount: number
coin_consumed_m: number
other_amount: number
deposit_deduct_amount: number
reason: string
evidence_urls: string[]
}
export async function createOrder(listingId: number) {
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', {
listing_id: listingId,
})
return data.data
}
export async function payOrder(id: number) {
const { data } = await apiClient.post<ApiResponse<PayOrderResult>>(`/orders/${id}/pay`)
return data.data
}
export async function fetchOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/orders')
return data.data.items
}
export async function fetchAdminOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/admin/orders')
return data.data.items
}
export async function fetchOrder(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`)
return data.data
}
export async function fetchAdminOrder(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Order>>(`/admin/orders/${id}`)
return data.data
}
export async function cancelOrder(id: number) {
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/orders/${id}/cancel`)
return data.data
}
export async function submitHandoff(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/handoff`, { content })
return data.data
}
export async function fetchHandoffRecords(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/orders/${id}/handoff-records`)
return data.data.items
}
export async function fetchAdminHandoffRecords(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/admin/orders/${id}/handoff-records`)
return data.data.items
}
export async function adminCloseOrder(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(`/admin/orders/${id}/close`, { reason })
return data.data
}
export async function adminMarkOrderAbnormal(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(`/admin/orders/${id}/mark-abnormal`, { reason })
return data.data
}
export interface RefundStatus {
order_id: number
order_no: string
refund_status: string
refund_amount_cent: number
refunded_at?: string
total_amount: number
}
export async function adminRefundOrder(id: number) {
const { data } = await apiClient.post<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund`)
return data.data
}
export async function adminRefundStatus(id: number) {
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund-status`)
return data.data
}
export interface StartOrderPaymentRequest {
pay_way?: string
jspay_flag?: string
}
export async function startOrderPayment(orderId: number, req?: StartOrderPaymentRequest) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(`/orders/${orderId}/start-payment`, req || {})
return data.data
}
export async function queryOrderPayment(orderId: number) {
const { data } = await apiClient.get<ApiResponse<PaymentOrder>>(`/orders/${orderId}/query-payment`)
return data.data
}
export async function confirmReceive(id: number) {
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
return data.data
}
export async function submitReturn(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/return`, { content })
return data.data
}
export async function confirmReturn(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`)
return data.data
}
export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) {
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/checkout`, payload)
return data.data
}
export async function confirmCheckout(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/confirm`)
return data.data
}
export async function counterCheckout(id: number, payload: CounterCheckoutPayload) {
const { data } = await apiClient.post<ApiResponse<Checkout>>(`/orders/${id}/checkout/counter`, payload)
return data.data
}
export async function acceptCheckout(id: number) {
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/accept`)
return data.data
}
-26
View File
@@ -1,26 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
import type { RealnameStatusValue } from '@/types/status'
export interface RealnameStatus {
status: RealnameStatusValue
provider?: string
provider_order_no?: string
masked_name?: string
masked_id_no?: string
verified_at?: string
fail_reason?: string
}
export async function startRealname(name: string, idNo: string) {
const { data } = await apiClient.post<ApiResponse<RealnameStatus>>('/realname/start', {
name,
id_no: idNo,
})
return data.data
}
export async function getRealnameStatus() {
const { data } = await apiClient.get<ApiResponse<RealnameStatus>>('/realname/status')
return data.data
}
-22
View File
@@ -1,22 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
export interface SystemConfig {
id: number
key: string
value: string
description: string
updated_by?: number
created_at: string
updated_at: string
}
export async function fetchSystemConfigs() {
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
return data.data.items
}
export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) {
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(`/admin/system-configs/${key}`, payload)
return data.data
}
-12
View File
@@ -1,12 +0,0 @@
export interface ApiResponse<T> {
code: string
message: string
data: T
}
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
page_size: number
}
-54
View File
@@ -1,54 +0,0 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
import type { BalanceType, LedgerDirection, WalletStatus } from '@/types/status'
import type { PaymentOrder } from './orders'
export interface WalletAccount {
user_id: number
available_balance: number
frozen_balance: number
status: WalletStatus
}
export interface WalletLedger {
id: number
ledger_no: string
user_id: number
order_id?: number
direction: LedgerDirection
amount: number
balance_after: number
balance_type: BalanceType
biz_type: string
biz_no: string
remark: string
created_at: string
}
export async function fetchWalletBalance() {
const { data } = await apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance')
return data.data
}
export async function fetchWalletLedger(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WalletLedger>>>('/wallet/ledger', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function rechargeWallet(amount: number) {
const { data } = await apiClient.post<ApiResponse<WalletAccount>>('/wallet/recharge', { amount })
return data.data
}
export async function startWalletRechargePayment(amount: number) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/wallet/recharge/pay', { amount })
return data.data
}
export async function queryWalletRechargePayment(id: number) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(`/wallet/recharge/pay/${id}/query`)
return data.data
}
@@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest'
import { rangeLabel, isRangeSelected } from '@/composables/home/useFilterOptions'
describe('useFilterOptions', () => {
describe('rangeLabel', () => {
it('应该返回双向范围标签', () => {
expect(rangeLabel(100, 500, '哈夫币')).toBe('100-500')
})
it('应该返回最小值+标签', () => {
expect(rangeLabel(500, undefined, '哈夫币')).toBe('500+')
})
it('应该返回最大值标签', () => {
expect(rangeLabel(undefined, 500, '哈夫币')).toBe('≤500')
})
it('应该返回默认标签', () => {
expect(rangeLabel(undefined, undefined, '哈夫币')).toBe('哈夫币')
})
})
describe('isRangeSelected', () => {
it('应该正确判断范围是否选中', () => {
expect(isRangeSelected(100, 500, 100, 500)).toBe(true)
expect(isRangeSelected(100, 500, 200, 500)).toBe(false)
expect(isRangeSelected(undefined, undefined, undefined, undefined)).toBe(true)
})
})
})
@@ -1,57 +0,0 @@
import { describe, it, expect } from 'vitest'
import { useHomeFilters } from '@/composables/home/useHomeFilters'
import { ref } from 'vue'
import { emptyListingPublishOptions } from '@/api/listingOptions'
describe('useHomeFilters', () => {
it('应该初始化空的筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters } = useHomeFilters(publishOptions, listings)
expect(filters.keyword).toBe('')
expect(filters.minCoin).toBeUndefined()
expect(filters.maxCoin).toBeUndefined()
})
it('应该正确重置筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, resetFilters } = useHomeFilters(publishOptions, listings)
filters.keyword = '测试'
filters.minCoin = 100
filters.region = '北京'
resetFilters()
expect(filters.keyword).toBe('')
expect(filters.minCoin).toBeUndefined()
expect(filters.region).toBe('')
})
it('应该正确设置字符串筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(publishOptions, listings)
setStringFilter('region', '上海')
expect(filters.region).toBe('上海')
})
it('应该正确设置范围筛选器', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, setCoinRange } = useHomeFilters(publishOptions, listings)
setCoinRange(100, 500)
expect(filters.minCoin).toBe(100)
expect(filters.maxCoin).toBe(500)
})
})
@@ -1,51 +0,0 @@
export const coinRangeOptions = [
{ label: '全部区间', min: undefined, max: undefined },
{ label: '0-100', min: 0, max: 100 },
{ label: '100-300', min: 100, max: 300 },
{ label: '300-500', min: 300, max: 500 },
{ label: '500+', min: 500, max: undefined },
]
export const moneyRangeOptions = [
{ label: '全部区间', min: undefined, max: undefined },
{ label: '0-50', min: 0, max: 50 },
{ label: '50-100', min: 50, max: 100 },
{ label: '100-200', min: 100, max: 200 },
{ label: '200+', min: 200, max: undefined },
]
export const totalRangeOptions = [
{ label: '全部区间', min: undefined, max: undefined },
{ label: '0-500', min: 0, max: 500 },
{ label: '500-1000', min: 500, max: 1000 },
{ label: '1000-2000', min: 1000, max: 2000 },
{ label: '2000-5000', min: 2000, max: 5000 },
]
export const fireLevelRangeOptions = [
{ label: '全部等级', min: undefined, max: undefined },
{ label: '38-50', min: 38, max: 50 },
{ label: '50-60', min: 50, max: 60 },
{ label: '60-70', min: 60, max: 70 },
{ label: '70+', min: 70, max: undefined },
]
export function rangeLabel(
min: number | undefined,
max: number | undefined,
fallback: string
) {
if (min !== undefined && max !== undefined) return `${min}-${max}`
if (min !== undefined) return `${min}+`
if (max !== undefined) return `${max}`
return fallback
}
export function isRangeSelected(
currentMin: number | undefined,
currentMax: number | undefined,
min: number | undefined,
max: number | undefined
) {
return currentMin === min && currentMax === max
}
@@ -1,208 +0,0 @@
import { reactive, ref, computed, type Ref } from 'vue'
import type { ListingPublishOptions } from '@/api/listingOptions'
import type { Listing } from '@/api/listings'
import { assetRegions } from '@/utils/listingDisplay'
export type FilterPopoverKey =
| 'insurance'
| 'stamina'
| 'load'
| 'region'
| 'coin'
| 'price'
| 'deposit'
| 'total'
| 'skin'
| 'rank'
| 'fireLevel'
| 'loginMethod'
export type StringFilterKey =
| 'insurance'
| 'stamina'
| 'load'
| 'region'
| 'rank'
| 'loginMethod'
export interface HomeFilters {
keyword: string
server: string
region: string
loginMethod: string
rank: string
insurance: string
stamina: string
load: string
skinGroup: string
skinName: string
minCoin: number | undefined
maxCoin: number | undefined
minPrice: number | undefined
maxPrice: number | undefined
minDeposit: number | undefined
maxDeposit: number | undefined
minTotal: number | undefined
maxTotal: number | undefined
minFireLevel: number | undefined
maxFireLevel: number | undefined
}
export function useHomeFilters(
publishOptions: Ref<ListingPublishOptions>,
listings: Ref<Listing[]>
) {
const filters = reactive<HomeFilters>({
keyword: '',
server: '',
region: '',
loginMethod: '',
rank: '',
insurance: '',
stamina: '',
load: '',
skinGroup: '',
skinName: '',
minCoin: undefined,
maxCoin: undefined,
minPrice: undefined,
maxPrice: undefined,
minDeposit: undefined,
maxDeposit: undefined,
minTotal: undefined,
maxTotal: undefined,
minFireLevel: undefined,
maxFireLevel: undefined,
})
const activeFilterPopover = ref<FilterPopoverKey | ''>('')
const regionOptions = computed(() =>
uniqueOptions([
...publishOptions.value.region_options,
...listings.value.flatMap((item) => assetRegions(item)),
])
)
const loginMethodOptions = computed(() =>
uniqueOptions(
publishOptions.value.login_method_options
.map((item) => item.trim())
.filter(Boolean)
)
)
const skinFilterGroups = computed(() => {
const preferred = ['operatorRed', 'operatorGold']
return preferred
.map((key) => publishOptions.value.skin_groups.find((group) => group.key === key))
.filter((group): group is ListingPublishOptions['skin_groups'][number] => Boolean(group))
})
const skinChipLabel = computed(() => {
if (filters.skinName) return filters.skinName
if (filters.skinGroup) {
return skinFilterGroups.value.find((group) => group.key === filters.skinGroup)?.title || '皮肤'
}
return '皮肤'
})
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))]
}
function resetFilters() {
filters.keyword = ''
filters.server = ''
filters.region = ''
filters.loginMethod = ''
filters.rank = ''
filters.insurance = ''
filters.stamina = ''
filters.load = ''
filters.skinGroup = ''
filters.skinName = ''
filters.minCoin = undefined
filters.maxCoin = undefined
filters.minPrice = undefined
filters.maxPrice = undefined
filters.minDeposit = undefined
filters.maxDeposit = undefined
filters.minTotal = undefined
filters.maxTotal = undefined
filters.minFireLevel = undefined
filters.maxFireLevel = undefined
}
function setStringFilter(key: StringFilterKey, value: string) {
filters[key] = value
closeFilterPopover()
}
function setCoinRange(min: number | undefined, max: number | undefined) {
filters.minCoin = min
filters.maxCoin = max
}
function setPriceRange(min: number | undefined, max: number | undefined) {
filters.minPrice = min
filters.maxPrice = max
}
function setDepositRange(min: number | undefined, max: number | undefined) {
filters.minDeposit = min
filters.maxDeposit = max
}
function setTotalRange(min: number | undefined, max: number | undefined) {
filters.minTotal = min
filters.maxTotal = max
}
function setFireLevelRange(min: number | undefined, max: number | undefined) {
filters.minFireLevel = min
filters.maxFireLevel = max
}
function setSkinFilter(group: string, name = '') {
filters.skinGroup = group
filters.skinName = name
}
function resetSkinFilter() {
filters.skinGroup = ''
filters.skinName = ''
}
function setFilterPopover(key: FilterPopoverKey, visible: boolean) {
if (visible) {
activeFilterPopover.value = key
return
}
if (activeFilterPopover.value === key) activeFilterPopover.value = ''
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
return {
filters,
activeFilterPopover,
regionOptions,
loginMethodOptions,
skinFilterGroups,
skinChipLabel,
resetFilters,
setStringFilter,
setCoinRange,
setPriceRange,
setDepositRange,
setTotalRange,
setFireLevelRange,
setSkinFilter,
resetSkinFilter,
setFilterPopover,
closeFilterPopover,
}
}
@@ -1,122 +0,0 @@
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
import { fetchListingsPage, type Listing, type PublicListingQuery } from '@/api/listings'
import type { HomeFilters } from './useHomeFilters'
const homePageSize = 12
export function useListingQuery(
filters: HomeFilters,
sortBy: Ref<string>,
activeZone: Ref<string>
) {
const loading = ref(false)
const loadingMore = ref(false)
const listings = ref<Listing[]>([])
const totalListings = ref(0)
const zoneCounts = ref<Record<string, number>>({})
const currentPage = ref(1)
const hasMoreListings = ref(true)
let listingRequestSeq = 0
function buildListingQuery(page: number): PublicListingQuery {
return {
page,
page_size: homePageSize,
keyword: filters.keyword.trim(),
sort: sortBy.value,
zone: activeZone.value,
server: filters.server,
region: filters.region,
login_method: filters.loginMethod,
rank: filters.rank,
insurance: filters.insurance,
stamina: filters.stamina,
load: filters.load,
skin_group: filters.skinGroup,
skin_name: filters.skinName,
min_coin: filters.minCoin,
max_coin: filters.maxCoin,
min_price: filters.minPrice,
max_price: filters.maxPrice,
min_deposit: filters.minDeposit,
max_deposit: filters.maxDeposit,
min_total: filters.minTotal,
max_total: filters.maxTotal,
min_fire_level: filters.minFireLevel,
max_fire_level: filters.maxFireLevel,
}
}
function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1))
}
async function loadListingsPage(reset = false) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return
const requestSeq = ++listingRequestSeq
if (reset) {
currentPage.value = 1
hasMoreListings.value = true
}
loadingMore.value = true
try {
const page = await fetchListingsPage(buildListingQuery(currentPage.value))
if (requestSeq !== listingRequestSeq) return
listings.value = reset ? page.items : [...listings.value, ...page.items]
totalListings.value = page.total
zoneCounts.value = page.zone_counts
hasMoreListings.value = listings.value.length < page.total
currentPage.value = page.page + 1
requestAnimationFrame(handleWindowScroll)
} catch {
if (reset) {
listings.value = []
totalListings.value = 0
zoneCounts.value = {}
hasMoreListings.value = false
}
} finally {
if (requestSeq === listingRequestSeq) {
loadingMore.value = false
}
}
}
function handleWindowScroll() {
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 480) return
loadListingsPage(false)
}
function zoneCount(key: string) {
if (key === 'all') return zoneCounts.value.all ?? totalListings.value
return zoneCounts.value[key] ?? 0
}
onMounted(() => {
window.addEventListener('scroll', handleWindowScroll, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleWindowScroll)
})
// 使用防抖优化搜索
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => listingQuerySignature(), () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
loadListingsPage(true)
}, 300)
})
return {
loading,
loadingMore,
listings,
totalListings,
zoneCounts,
hasMoreListings,
loadListingsPage,
zoneCount,
}
}
@@ -1,405 +0,0 @@
import { computed, ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type {
Order,
HandoffRecord,
PaymentOrder,
} from '@/api/orders'
import {
fetchOrder,
fetchHandoffRecords,
cancelOrder,
startOrderPayment,
queryOrderPayment,
submitHandoff,
confirmReceive,
submitCheckout,
acceptCheckout,
counterCheckout,
confirmCheckout,
} from '@/api/orders'
import { createDispute } from '@/api/disputes'
import { uploadFile } from '@/api/files'
import { fetchOrderChat } from '@/api/chats'
import { useSessionStore } from '@/stores/session'
export interface CheckoutForm {
content: string
consumable_amount: number
coin_consumed_m: number
other_amount: number
evidenceText: string
}
export interface CounterForm {
consumable_amount: number
coin_consumed_m: number
other_amount: number
deposit_deduct_amount: number
reason: string
evidenceText: string
}
export function useOrderDetail() {
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
// Loading states
const loading = ref(false)
const cancelling = ref(false)
const startingPayment = ref(false)
const handoffing = ref(false)
const confirming = ref(false)
const returning = ref(false)
const completing = ref(false)
const countering = ref(false)
const acceptingCheckout = ref(false)
const rejectingCheckout = ref(false)
const disputing = ref(false)
const uploadingEvidence = ref(false)
const openingChat = ref(false)
// Data
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const handoffContent = ref('')
// Payment states
const activePayment = ref<PaymentOrder | null>(null)
const paymentQRCodeURL = ref('')
const qrGenerating = ref(false)
const checkingPayment = ref(false)
let paymentPollingTimer: number | undefined
let autoPayHandled = false
// Forms
const checkoutForm = ref<CheckoutForm>({
content: '',
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
evidenceText: '',
})
const resourceUsage = ref<Record<string, number>>({})
const counterForm = ref<CounterForm>({
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
deposit_deduct_amount: 0,
reason: '',
evidenceText: '',
})
const rejectReason = ref('')
const disputeType = ref('cannot_login')
const disputeDescription = ref('')
const disputeEvidenceText = ref('')
// Computed
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const orderAmountLabel = computed(() => (isOwner.value ? '我的租金' : '订单金额'))
const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(
order.value.status
)
})
const isCheckoutDisputeStage = computed(() => {
return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
})
// Methods
async function loadOrder() {
loading.value = true
try {
order.value = await fetchOrder(String(route.params.id))
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
return order.value
} finally {
loading.value = false
}
}
async function handleCancel() {
if (!order.value) return
cancelling.value = true
try {
await cancelOrder(order.value.id)
return true
} finally {
cancelling.value = false
}
}
async function handlePay() {
if (!order.value) return null
startingPayment.value = true
try {
const payment = await startOrderPayment(order.value.id)
return payment
} finally {
startingPayment.value = false
}
}
async function handleSubmitHandoff() {
if (!order.value || !handoffContent.value.trim()) return
handoffing.value = true
try {
await submitHandoff(order.value.id, handoffContent.value.trim())
handoffContent.value = ''
await loadOrder()
return true
} finally {
handoffing.value = false
}
}
async function handleConfirmReceive() {
if (!order.value) return
confirming.value = true
try {
await confirmReceive(order.value.id)
await loadOrder()
return true
} finally {
confirming.value = false
}
}
async function handleSubmitCheckout() {
if (!order.value) return
returning.value = true
try {
await submitCheckout(order.value.id, {
content: checkoutForm.value.content.trim(),
consumable_amount: checkoutForm.value.consumable_amount,
coin_consumed_m: checkoutForm.value.coin_consumed_m,
other_amount: checkoutForm.value.other_amount,
evidence: checkoutForm.value.evidenceText.trim(),
})
await loadOrder()
return true
} finally {
returning.value = false
}
}
async function handleAcceptCheckout() {
if (!order.value) return
acceptingCheckout.value = true
try {
await acceptCheckout(order.value.id)
await loadOrder()
return true
} finally {
acceptingCheckout.value = false
}
}
async function handleCounterCheckout() {
if (!order.value) return
countering.value = true
try {
await counterCheckout(order.value.id, {
consumable_amount: counterForm.value.consumable_amount,
coin_consumed_m: counterForm.value.coin_consumed_m,
other_amount: counterForm.value.other_amount,
deposit_deduct_amount: counterForm.value.deposit_deduct_amount,
reason: counterForm.value.reason.trim(),
evidence: counterForm.value.evidenceText.trim(),
})
await loadOrder()
return true
} finally {
countering.value = false
}
}
async function handleRejectCheckout(reason: string) {
if (!order.value) return
rejectingCheckout.value = true
try {
await counterCheckout(order.value.id, {
consumable_amount: 0,
coin_consumed_m: 0,
other_amount: 0,
deposit_deduct_amount: 0,
reason: reason.trim(),
evidence: '',
})
await loadOrder()
return true
} finally {
rejectingCheckout.value = false
}
}
async function handleConfirmCheckout() {
if (!order.value) return
completing.value = true
try {
await confirmCheckout(order.value.id)
await loadOrder()
return true
} finally {
completing.value = false
}
}
async function handleCreateDispute() {
if (!order.value) return
disputing.value = true
try {
await createDispute({
order_id: order.value.id,
type: disputeType.value,
description: disputeDescription.value.trim(),
evidence: disputeEvidenceText.value.trim(),
})
await loadOrder()
return true
} finally {
disputing.value = false
}
}
async function handleUploadEvidence(file: File) {
uploadingEvidence.value = true
try {
const result = await uploadFile(file)
return result.url
} finally {
uploadingEvidence.value = false
}
}
async function handleOpenChat() {
if (!order.value) return
openingChat.value = true
try {
const chat = await fetchOrderChat(order.value.id)
return chat
} finally {
openingChat.value = false
}
}
function startPaymentPolling() {
stopPaymentPolling()
paymentPollingTimer = window.setInterval(checkPaymentStatus, 2000)
}
function stopPaymentPolling() {
if (paymentPollingTimer !== undefined) {
clearInterval(paymentPollingTimer)
paymentPollingTimer = undefined
}
}
async function checkPaymentStatus() {
if (!activePayment.value || checkingPayment.value) return
checkingPayment.value = true
try {
const updated = await queryOrderPayment(activePayment.value.id)
if (updated.paid) {
stopPaymentPolling()
activePayment.value = null
await loadOrder()
return true
}
} catch {
// ignore
} finally {
checkingPayment.value = false
}
return false
}
function getOrderStep(status: string) {
const stepMap: Record<string, number> = {
pending_payment: 0,
pending_handoff: 1,
renting: 2,
overdue: 2,
pending_checkout_confirm: 3,
pending_checkout_accept: 3,
completed: 4,
cancelled: 0,
closed: 4,
}
return stepMap[status] ?? 0
}
onMounted(loadOrder)
onBeforeUnmount(stopPaymentPolling)
return {
// States
loading,
cancelling,
startingPayment,
handoffing,
confirming,
returning,
completing,
countering,
acceptingCheckout,
rejectingCheckout,
disputing,
uploadingEvidence,
openingChat,
// Data
order,
handoffRecords,
handoffContent,
// Payment
activePayment,
paymentQRCodeURL,
qrGenerating,
checkingPayment,
// Forms
checkoutForm,
resourceUsage,
counterForm,
rejectReason,
disputeType,
disputeDescription,
disputeEvidenceText,
// Computed
isOwner,
isRenter,
orderAmountLabel,
canOpenDispute,
isCheckoutDisputeStage,
// Methods
loadOrder,
handleCancel,
handlePay,
handleSubmitHandoff,
handleConfirmReceive,
handleSubmitCheckout,
handleAcceptCheckout,
handleCounterCheckout,
handleRejectCheckout,
handleConfirmCheckout,
handleCreateDispute,
handleUploadEvidence,
handleOpenChat,
startPaymentPolling,
stopPaymentPolling,
checkPaymentStatus,
getOrderStep,
}
}
@@ -1,147 +0,0 @@
import type { Order } from '@/api/orders'
export interface SnapshotResource {
key: string
label: string
quantity: number
unitPrice: number
chargeMode: '赠送' | '收费'
}
export function readSnapshot(order: Order | null) {
if (!order?.listing_snapshot) return null
try {
return JSON.parse(order.listing_snapshot) as Record<string, any>
} catch {
return null
}
}
export function readNumber(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
export function roundQuantity(value: number): number {
return Math.round(value * 10) / 10
}
export function roundMoney(value: number): number {
return Math.round(value * 100) / 100
}
export function readSnapshotResources(order: Order | null): SnapshotResource[] {
const snapshot = readSnapshot(order)
if (!snapshot?.quantities) return []
const quantities = snapshot.quantities as Record<string, any>[]
return quantities
.map((item) => ({
key: String(item.key || ''),
label: String(item.label || ''),
quantity: readNumber(item.quantity),
unitPrice: readNumber(item.price),
chargeMode: item.charge_mode === '收费' ? '收费' : '赠送',
}))
.filter((item) => item.key && item.label)
}
export function isChargedResource(resource: SnapshotResource): boolean {
return resource.chargeMode === '收费'
}
export function getSnapshotHafCoinM(order: Order | null): number {
const snapshot = readSnapshot(order)
return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
}
export function calculateResourceChargeAmount(
resources: SnapshotResource[],
resourceUsage: Record<string, number>
): number {
return roundMoney(
resources.reduce((sum, item) => {
if (!isChargedResource(item)) return sum
const used = resourceUsage[item.key] || 0
return sum + used * item.unitPrice
}, 0)
)
}
export function calculateCheckoutTotal(
resourceCharge: number,
consumableAmount: number,
coinConsumed: number,
otherAmount: number
): number {
return roundMoney(resourceCharge + consumableAmount + coinConsumed + otherAmount)
}
export function calculateCounterTotal(counterForm: {
consumable_amount: number
coin_consumed_m: number
other_amount: number
deposit_deduct_amount: number
}): number {
return roundMoney(
counterForm.consumable_amount +
counterForm.coin_consumed_m +
counterForm.other_amount +
counterForm.deposit_deduct_amount
)
}
export function hydrateResourceUsageFromOrder(
order: Order | null,
resources: SnapshotResource[]
): Record<string, number> {
const usage: Record<string, number> = {}
if (!order?.checkout_info) {
return usage
}
try {
const info = JSON.parse(order.checkout_info) as Record<string, any>
const consumedResources = info.consumed_resources as Record<string, number> | undefined
if (consumedResources) {
resources.forEach((res) => {
if (res.key in consumedResources) {
usage[res.key] = consumedResources[res.key]
}
})
}
} catch {
// ignore
}
return usage
}
export function hydrateCounterFormFromOrder(order: Order | null) {
if (!order?.counter_info) {
return null
}
try {
const info = JSON.parse(order.counter_info) as Record<string, any>
return {
consumable_amount: readNumber(info.consumable_amount),
coin_consumed_m: readNumber(info.coin_consumed_m),
other_amount: readNumber(info.other_amount),
deposit_deduct_amount: readNumber(info.deposit_deduct_amount),
reason: String(info.reason || ''),
evidenceText: String(info.evidence || ''),
}
} catch {
return null
}
}
export function readError(error: unknown, fallback: string): string {
if (error && typeof error === 'object' && 'message' in error) {
return String(error.message)
}
return fallback
}
@@ -1,53 +0,0 @@
import { ref } from 'vue'
/**
* 防抖 - 延迟执行,多次触发只执行最后一次
* @param fn 要执行的函数
* @param delay 延迟时间(毫秒)
*/
export function useDebounce<T extends (...args: any[]) => any>(fn: T, delay = 300) {
const timer = ref<ReturnType<typeof setTimeout> | null>(null)
function debouncedFn(...args: Parameters<T>) {
if (timer.value) {
clearTimeout(timer.value)
}
timer.value = setTimeout(() => {
fn(...args)
timer.value = null
}, delay)
}
function cancel() {
if (timer.value) {
clearTimeout(timer.value)
timer.value = null
}
}
return {
run: debouncedFn,
cancel,
}
}
/**
* 节流 - 固定时间间隔执行
* @param fn 要执行的函数
* @param interval 时间间隔(毫秒)
*/
export function useThrottle<T extends (...args: any[]) => any>(fn: T, interval = 300) {
const lastTime = ref(0)
function throttledFn(...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime.value >= interval) {
lastTime.value = now
fn(...args)
}
}
return {
run: throttledFn,
}
}
@@ -1,51 +0,0 @@
import { onMounted, onUnmounted, ref } from 'vue'
/**
* 图片懒加载 composable
* 使用 Intersection Observer API 实现高性能懒加载
*/
export function useLazyImage() {
const imageRefs = ref<Set<HTMLImageElement>>(new Set())
let observer: IntersectionObserver | null = null
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
const src = img.dataset.src
if (src) {
img.src = src
img.removeAttribute('data-src')
observer?.unobserve(img)
}
}
})
},
{
rootMargin: '50px', // 提前50px加载
threshold: 0.01,
}
)
imageRefs.value.forEach((img) => {
observer?.observe(img)
})
})
onUnmounted(() => {
observer?.disconnect()
})
function registerImage(el: HTMLImageElement | null) {
if (el && observer) {
imageRefs.value.add(el)
observer.observe(el)
}
}
return {
registerImage,
}
}
@@ -1,27 +0,0 @@
import { defineAsyncComponent, type Component } from 'vue'
/**
* 组件懒加载包装器
* 自动添加 loading 和 error 处理
*/
export function lazyLoadComponent(importFunc: () => Promise<Component>) {
return defineAsyncComponent({
loader: importFunc,
loadingComponent: {
template: '<div class="component-loading">加载中...</div>',
},
errorComponent: {
template: '<div class="component-error">加载失败,请刷新重试</div>',
},
delay: 200, // 延迟200ms显示loading
timeout: 10000, // 10秒超时
})
}
/**
* 路由级别懒加载
* 使用 webpack 魔法注释自动分包
*/
export function lazyLoadRoute(path: string) {
return () => import(/* webpackChunkName: "[request]" */ `@/views/${path}.vue`)
}
@@ -1,52 +0,0 @@
import { ref, type Ref } from 'vue'
interface PaginatedResult<T> {
items: T[]
total: number
}
interface AdminPaginatedTableOptions<T> {
fetchFn: (page: number, pageSize: number) => Promise<PaginatedResult<T>>
defaultPageSize?: number
immediate?: boolean
}
interface AdminPaginatedTableResult<T> {
loading: Ref<boolean>
data: Ref<T[]>
total: Ref<number>
currentPage: Ref<number>
currentPageSize: Ref<number>
load: () => Promise<void>
handleSizeChange: () => void
}
export function useAdminPaginatedTable<T>(options: AdminPaginatedTableOptions<T>): AdminPaginatedTableResult<T> {
const loading = ref(false)
const data = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(options.defaultPageSize || 20)
async function load() {
loading.value = true
try {
const result = await options.fetchFn(currentPage.value, currentPageSize.value)
data.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
load()
}
if (options.immediate !== false) {
load()
}
return { loading, data, total, currentPage, currentPageSize, load, handleSizeChange }
}
-42
View File
@@ -1,42 +0,0 @@
import { ref, type Ref } from 'vue'
interface AdminQueryOptions<T> {
fetchFn: () => Promise<T>
immediate?: boolean
initialData?: T
}
interface AdminQueryResult<T> {
loading: Ref<boolean>
error: Ref<string | null>
data: Ref<T>
load: () => Promise<void>
}
export function useAdminQuery<T>(options: AdminQueryOptions<T>): AdminQueryResult<T> {
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<T>(options.initialData as T) as Ref<T>
async function load() {
loading.value = true
error.value = null
try {
data.value = await options.fetchFn()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
console.error('Failed to load data:', e)
} finally {
loading.value = false
}
}
if (options.immediate !== false) {
load()
}
return { loading, error, data, load }
}
// 保持向后兼容
export const useAdminTable = useAdminQuery
-118
View File
@@ -1,118 +0,0 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
import { refreshAccessToken } from '@/api/client'
import { getAccessToken, type AuthScope } from '@/utils/authStorage'
export interface SSEMessage {
id: number
conversation_id: number
sender_type: string
sender_id: number
sender_role: string
sender_name: string
content_type: string
content: string
attachment_urls: string[]
created_at: string
}
export interface ChatEvent {
type: 'new_message' | 'conversation_updated'
conversation_id: number
message?: SSEMessage
}
type EventHandler = (event: ChatEvent) => void
const reconnectDelay = 3000
export function useChatSSE(scope: AuthScope, endpoint: string) {
const connected: Ref<boolean> = ref(false)
let source: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let stopped = false
let refreshing = false
const handlers: EventHandler[] = []
function onEvent(handler: EventHandler) {
handlers.push(handler)
}
function connect() {
if (stopped || source) return
const token = getAccessToken(scope)
if (!token) return
const url = `${endpoint}?token=${encodeURIComponent(token)}`
source = new EventSource(url)
source.addEventListener('connected', () => {
connected.value = true
})
source.addEventListener('new_message', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data))
} catch { /* ignore */ }
})
source.addEventListener('conversation_updated', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data))
} catch { /* ignore */ }
})
source.onerror = async () => {
connected.value = false
source?.close()
source = null
if (stopped || refreshing) return
refreshing = true
try {
await refreshAccessToken(scope)
if (!stopped) {
reconnectTimer = setTimeout(connect, reconnectDelay)
}
} catch {
closeSource()
} finally {
refreshing = false
}
}
}
function closeSource() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
source?.close()
source = null
connected.value = false
}
function handleAuthStorageChanged(event: Event) {
const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail
if (detail?.scope !== scope || stopped) return
closeSource()
connect()
}
function disconnect() {
stopped = true
closeSource()
window.removeEventListener('auth-storage-changed', handleAuthStorageChanged)
}
window.addEventListener('auth-storage-changed', handleAuthStorageChanged)
onBeforeUnmount(() => {
disconnect()
})
connect()
return { connected, onEvent, disconnect }
}
-3
View File
@@ -1,3 +0,0 @@
export function useMoney() {
return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}`
}
@@ -1,195 +0,0 @@
import { computed, type Ref } from 'vue'
import type { ChargeMode, ListingPublishOptions, PublishSalePriceConfig, QuantityKey, ScreenshotKey } from '@/api/listingOptions'
import type { PublishForm } from '@/types/publish'
import {
buildDepositBreakdownItems,
calculateConsumablePrice,
calculateDailyLossRatioAdjustment,
calculatePlatformPricing,
calculateRecommendedDeposit,
calculateSellerReferenceRatio,
formatNumber,
hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue,
isQuantityItemDisabledForInsurance,
readFinalSaleRatio,
roundMoney,
roundRatio,
} from '@/utils/pricing'
export function usePricingCalculator(options: {
publishOptions: Ref<ListingPublishOptions>
salePriceConfig: Ref<PublishSalePriceConfig>
form: PublishForm
quantityValues: Record<QuantityKey, number>
quantityModes: Record<QuantityKey, ChargeMode>
screenshotFiles: Record<ScreenshotKey, string>
selectedSkins: Ref<string[]>
}) {
const serverOptions = computed(() => options.publishOptions.value.server_options)
const faceOptions = computed(() => options.publishOptions.value.face_options)
const rankOptions = computed(() => options.publishOptions.value.rank_options)
const insuranceOptions = computed(() => options.publishOptions.value.insurance_options)
const levelOptions = computed(() => options.publishOptions.value.level_options)
const loginMethodOptions = computed(() => options.publishOptions.value.login_method_options)
const regionOptions = computed(() => options.publishOptions.value.region_options)
const banRecordOptions = computed(() => options.publishOptions.value.ban_record_options)
const banEvidenceOptions = computed(() => options.publishOptions.value.ban_evidence_options)
const skinGroups = computed(() => options.publishOptions.value.skin_groups)
const quantityItems = computed(() => options.publishOptions.value.quantity_items)
const screenshotSlots = computed(() => options.publishOptions.value.screenshot_slots)
const priceConfig = computed(() => options.publishOptions.value.price_config)
const depositRecommendConfig = computed(() => options.publishOptions.value.deposit_recommend_config)
const fireLevelMin = computed(() => options.publishOptions.value.fire_level_min || 38)
const fireLevelPlaceholder = computed(() => `等级低于${fireLevelMin.value}级的号无法发布`)
const coinMAmount = computed(() => Number(options.form.haf_coin_amount || 0))
const coinWanAmount = computed(() => coinMAmount.value * 100)
const dailyLossMAmount = computed(() => Number(options.form.daily_loss_m || 10))
const dailyLossRatioAdjustment = computed(() => calculateDailyLossRatioAdjustment(dailyLossMAmount.value))
const screenshotUrls = computed(() =>
screenshotSlots.value.map((item) => options.screenshotFiles?.[item.key]).filter((url): url is string => Boolean(url)),
)
function hasAcceleratedSaleRatioInput() {
return hasAcceleratedSaleRatioValue(options.form.accelerated_sale_ratio)
}
function isQuantityItemDisabled(item: { key: string; label: string }) {
return isQuantityItemDisabledForInsurance(item, options.form.season_insurance)
}
const calculatedSellerReferenceRatio = computed(() =>
calculateSellerReferenceRatio({
coinMAmount: coinMAmount.value,
form: options.form,
ratioConfig: options.publishOptions.value.ratio_config,
skinGroups: skinGroups.value,
selectedSkins: options.selectedSkins.value,
levelOptions: levelOptions.value,
dailyLossRatioAdjustment: dailyLossRatioAdjustment.value,
}),
)
const calculatedDefaultSaleRatio = computed(() => calculatedSellerReferenceRatio.value)
const maxAcceleratedSaleRatio = computed(() =>
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0,
)
const calculatedRatio = computed(() =>
readFinalSaleRatio(
calculatedDefaultSaleRatio.value,
options.form.accelerated_sale_ratio,
maxAcceleratedSaleRatio.value,
),
)
const calculatedCoinBasePrice = computed(() => {
if (calculatedRatio.value <= 0) return 0
return roundMoney(coinWanAmount.value / calculatedRatio.value)
})
const calculatedConsumablePrice = computed(() =>
calculateConsumablePrice({
quantityItems: quantityItems.value,
quantityValues: options.quantityValues,
quantityModes: options.quantityModes,
seasonInsurance: options.form.season_insurance,
}),
)
const calculatedSellerPrice = computed(() =>
calculatedRatio.value > 0 ? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value) : 0,
)
const calculatedPlatformPricing = computed(() =>
calculatePlatformPricing({
coinMAmount: coinMAmount.value,
coinWanAmount: coinWanAmount.value,
sellerRatio: calculatedRatio.value,
sellerCoinBasePrice: calculatedCoinBasePrice.value,
sellerTotalPrice: calculatedSellerPrice.value,
consumablePrice: calculatedConsumablePrice.value,
salePriceConfig: options.salePriceConfig.value,
}),
)
const calculatedFinalPrice = computed(() => calculatedPlatformPricing.value.buyerTotalPrice)
const calculatedRatioText = computed(() => (calculatedRatio.value > 0 ? `1:${formatNumber(calculatedRatio.value)}` : '--'))
const calculatedDefaultSaleRatioText = computed(() =>
calculatedDefaultSaleRatio.value > 0 ? `1:${formatNumber(calculatedDefaultSaleRatio.value)}` : '--',
)
const saleRatioRangeText = computed(() => {
if (calculatedDefaultSaleRatio.value <= 0) return '完成基础信息后自动计算参考比例'
return `可设置 1:${formatNumber(calculatedDefaultSaleRatio.value)} ~ 1:${formatNumber(maxAcceleratedSaleRatio.value)}`
})
const acceleratedSaleRatioPlaceholder = computed(() => {
if (calculatedDefaultSaleRatio.value <= 0) return '填写资料后自动生成可设置范围'
return `默认 ${formatNumber(calculatedDefaultSaleRatio.value)},最高 ${formatNumber(maxAcceleratedSaleRatio.value)}`
})
const recommendedDepositAmount = computed(() =>
calculateRecommendedDeposit({
depositRecommendConfig: depositRecommendConfig.value,
skinGroups: skinGroups.value,
selectedSkins: options.selectedSkins.value,
}),
)
const depositBreakdownItems = computed(() =>
buildDepositBreakdownItems({
depositRecommendConfig: depositRecommendConfig.value,
skinGroups: skinGroups.value,
selectedSkins: options.selectedSkins.value,
}),
)
const platformRuleLabel = computed(() => {
const labels: Record<string, string> = {
fixed_markup: '固定加价',
ratio_subtract: '比例修正',
none: '无加价',
}
return labels[calculatedPlatformPricing.value.ruleType] || calculatedPlatformPricing.value.ruleType
})
const publishTitle = computed(() => {
const parts = [
options.form.server_region,
options.form.rank_level,
coinMAmount.value ? `${coinMAmount.value}M哈夫币` : '',
].filter(Boolean)
return parts.length ? parts.join(' ') : '待完善账号信息'
})
return {
serverOptions,
faceOptions,
rankOptions,
insuranceOptions,
levelOptions,
loginMethodOptions,
regionOptions,
banRecordOptions,
banEvidenceOptions,
skinGroups,
quantityItems,
screenshotSlots,
priceConfig,
depositRecommendConfig,
fireLevelMin,
fireLevelPlaceholder,
coinMAmount,
coinWanAmount,
dailyLossMAmount,
dailyLossRatioAdjustment,
screenshotUrls,
calculatedSellerReferenceRatio,
calculatedDefaultSaleRatio,
maxAcceleratedSaleRatio,
calculatedRatio,
calculatedCoinBasePrice,
calculatedConsumablePrice,
calculatedSellerPrice,
calculatedPlatformPricing,
calculatedFinalPrice,
calculatedRatioText,
calculatedDefaultSaleRatioText,
saleRatioRangeText,
acceleratedSaleRatioPlaceholder,
recommendedDepositAmount,
depositBreakdownItems,
platformRuleLabel,
publishTitle,
hasAcceleratedSaleRatioInput,
isQuantityItemDisabled,
}
}
-124
View File
@@ -1,124 +0,0 @@
import type { ChargeMode, QuantityKey, ScreenshotKey } from '@/api/listingOptions'
import type { PublishDraft, PublishForm } from '@/types/publish'
export function defaultPublishForm(): PublishForm {
return {
server_region: '',
face_owner: '',
haf_coin_amount: '',
rank_level: '',
secret_kd: '',
fire_level: '',
daily_loss_m: 10,
accelerated_sale_ratio: '',
season_insurance: '',
stamina_level: '',
load_level: '',
login_method: '',
online_start: '',
online_end: '',
ban_record: '',
common_regions: [],
deposit_amount: '',
remark: '',
}
}
export function buildPublishDraft(options: {
form: PublishForm
quantityValues: Record<QuantityKey, number>
quantityModes: Record<QuantityKey, ChargeMode>
screenshotFiles: Record<ScreenshotKey, string>
selectedSkins: string[]
}): PublishDraft {
return {
form: {
...options.form,
common_regions: [...options.form.common_regions],
},
quantityValues: { ...options.quantityValues },
quantityModes: { ...options.quantityModes },
screenshotFiles: { ...options.screenshotFiles },
selectedSkins: [...options.selectedSkins],
}
}
export function readPublishDraft(draftKey: string) {
const raw = localStorage.getItem(draftKey)
if (!raw) return null
try {
const draft = JSON.parse(raw) as Partial<PublishDraft>
return {
form: normalizeDraftForm(draft.form),
quantityValues: normalizeNumberRecord(draft.quantityValues),
quantityModes: normalizeQuantityModes(draft.quantityModes),
screenshotFiles: normalizeStringRecord(draft.screenshotFiles),
selectedSkins: Array.isArray(draft.selectedSkins)
? draft.selectedSkins.filter((skin): skin is string => typeof skin === 'string')
: [],
}
} catch {
localStorage.removeItem(draftKey)
return null
}
}
export function writePublishDraft(draftKey: string, draft: PublishDraft) {
const nextValue = JSON.stringify(draft)
if (localStorage.getItem(draftKey) === nextValue) return
localStorage.setItem(draftKey, nextValue)
}
export function removePublishDraft(draftKey: string) {
localStorage.removeItem(draftKey)
}
export function clearRecord(record: Record<string, unknown>) {
for (const key of Object.keys(record)) delete record[key]
}
function normalizeDraftForm(value: unknown): PublishForm {
const next = defaultPublishForm()
if (!isRecord(value)) return next
for (const key of Object.keys(next) as Array<keyof PublishForm>) {
if (key === 'common_regions') continue
const draftValue = value[key]
if (draftValue !== undefined) next[key] = draftValue as never
}
next.common_regions = Array.isArray(value.common_regions)
? value.common_regions.filter((region): region is string => typeof region === 'string')
: []
return next
}
function normalizeNumberRecord(value: unknown) {
const record: Record<string, number> = {}
if (!isRecord(value)) return record
for (const [key, item] of Object.entries(value)) {
const parsed = Number(item)
if (Number.isFinite(parsed)) record[key] = parsed
}
return record
}
function normalizeStringRecord(value: unknown) {
const record: Record<string, string> = {}
if (!isRecord(value)) return record
for (const [key, item] of Object.entries(value)) {
if (typeof item === 'string') record[key] = item
}
return record
}
function normalizeQuantityModes(value: unknown) {
const record: Record<string, ChargeMode> = {}
if (!isRecord(value)) return record
for (const [key, item] of Object.entries(value)) {
if (item === '赠送' || item === '收费') record[key] = item
}
return record
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
-568
View File
@@ -1,568 +0,0 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { fetchFileBlobByURL, uploadFile } from '@/api/files'
import {
emptyListingPublishOptions,
emptyListingSalePriceConfig,
fetchListingPublishOptions,
fetchListingSalePriceConfig,
type ChargeMode,
type ListingPublishOptions,
type PublishSalePriceConfig,
type ScreenshotKey,
} from '@/api/listingOptions'
import { createListing } from '@/api/listings'
import { usePricingCalculator } from '@/composables/usePricingCalculator'
import {
buildPublishDraft,
clearRecord,
defaultPublishForm,
readPublishDraft,
removePublishDraft,
writePublishDraft,
} from '@/composables/usePublishDraft'
import type { PublishForm } from '@/types/publish'
import { commonOnlineTimes, dailyLossOptions, formatNumber, roundRatio } from '@/utils/pricing'
const draftSaveDelay = 400
interface UsePublishFormOptions {
draftKey: string
submitSuccessPath: string
persistBeforeUnload?: boolean
confirmReset?: () => Promise<void>
notifySuccess: (message: string) => void
notifyWarning: (message: string) => void
notifyError: (message: string) => void
}
export function usePublishForm(options: UsePublishFormOptions) {
const router = useRouter()
const loading = ref(false)
const uploading = ref(false)
const suppressDraftSave = ref(false)
const draftReady = ref(false)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
const fileInput = ref<HTMLInputElement | null>(null)
const activeUploadKey = ref<ScreenshotKey>('coin')
const form = reactive<PublishForm>(defaultPublishForm())
const quantityValues = reactive<Record<string, number>>({})
const quantityModes = reactive<Record<string, ChargeMode>>({})
const screenshotFiles = reactive<Record<string, string>>({})
const screenshotPreviews = reactive<Record<string, string>>({})
const selectedSkins = ref<string[]>([])
let draftSaveTimer: number | undefined
const pricing = usePricingCalculator({
form,
publishOptions,
salePriceConfig,
quantityValues,
quantityModes,
screenshotFiles,
selectedSkins,
})
const uploadedScreenshotCount = computed(() => pricing.screenshotUrls.value.length)
const requiredScreenshotCount = ref(0)
onMounted(() => {
restoreDraft()
draftReady.value = true
if (options.persistBeforeUnload) window.addEventListener('beforeunload', handleBeforeUnload)
loadPublishOptions()
})
onBeforeUnmount(() => {
saveDraft({ force: true })
if (options.persistBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload)
revokeAllScreenshotPreviews()
})
watch(
[form, quantityValues, quantityModes, screenshotFiles, selectedSkins],
() => {
saveDraft()
},
{ deep: true },
)
watch(
pricing.recommendedDepositAmount,
() => {
syncRecommendedDeposit()
},
{ immediate: true },
)
watch(
[() => form.season_insurance, pricing.quantityItems],
() => {
clearForbiddenQuantityItems()
},
{ deep: true },
)
watch(
[pricing.screenshotSlots, () => form.ban_record],
() => {
requiredScreenshotCount.value = pricing.screenshotSlots.value.filter((item) => isScreenshotRequired(item)).length
},
{ immediate: true, deep: true },
)
async function loadPublishOptions() {
try {
const [nextPublishOptions, nextSalePriceConfig] = await Promise.all([
fetchListingPublishOptions(),
fetchListingSalePriceConfig(),
])
publishOptions.value = nextPublishOptions
salePriceConfig.value = nextSalePriceConfig
// 默认数字都是0
for (const item of nextPublishOptions.quantity_items) {
if (quantityValues[item.key] === undefined) {
quantityValues[item.key] = 0
}
}
} catch {
publishOptions.value = emptyListingPublishOptions
salePriceConfig.value = emptyListingSalePriceConfig
}
}
function saveDraft(saveOptions: { force?: boolean } = {}) {
if (suppressDraftSave.value) {
clearDraftSaveTimer()
return
}
if (!saveOptions.force && !draftReady.value) return
if (!saveOptions.force) {
scheduleDraftSave()
return
}
clearDraftSaveTimer()
writeDraft()
}
function scheduleDraftSave() {
clearDraftSaveTimer()
draftSaveTimer = window.setTimeout(() => {
draftSaveTimer = undefined
writeDraft()
}, draftSaveDelay)
}
function clearDraftSaveTimer() {
if (draftSaveTimer === undefined) return
window.clearTimeout(draftSaveTimer)
draftSaveTimer = undefined
}
function writeDraft() {
writePublishDraft(
options.draftKey,
buildPublishDraft({
form,
quantityValues,
quantityModes,
screenshotFiles,
selectedSkins: selectedSkins.value,
}),
)
}
function handleSaveDraft() {
saveDraft({ force: true })
options.notifySuccess('草稿已保存')
}
function handleBeforeUnload() {
saveDraft({ force: true })
}
function restoreDraft() {
const draft = readPublishDraft(options.draftKey)
if (!draft) return
Object.assign(form, draft.form)
clearRecord(quantityValues)
clearRecord(quantityModes)
clearRecord(screenshotFiles)
Object.assign(quantityValues, draft.quantityValues)
Object.assign(quantityModes, draft.quantityModes)
Object.assign(screenshotFiles, draft.screenshotFiles)
selectedSkins.value = draft.selectedSkins
hydrateScreenshotPreviews()
}
function resetDraftState() {
Object.assign(form, defaultPublishForm())
clearRecord(quantityValues)
clearRecord(quantityModes)
clearRecord(screenshotFiles)
revokeAllScreenshotPreviews()
selectedSkins.value = []
activeUploadKey.value = 'coin'
// Also re-initialize quantityValues to 0
for (const item of publishOptions.value.quantity_items) {
quantityValues[item.key] = 0
}
}
async function handleResetDraft() {
try {
await options.confirmReset?.()
} catch {
return
}
suppressDraftSave.value = true
clearDraftSaveTimer()
resetDraftState()
removePublishDraft(options.draftKey)
options.notifySuccess('已重置')
window.setTimeout(() => {
suppressDraftSave.value = false
})
}
function toggleSkin(skin: string) {
selectedSkins.value = selectedSkins.value.includes(skin)
? selectedSkins.value.filter((item) => item !== skin)
: [...selectedSkins.value, skin]
}
function toggleRegion(region: string) {
form.common_regions = form.common_regions.includes(region)
? form.common_regions.filter((item) => item !== region)
: [...form.common_regions, region]
}
function triggerUpload(key: ScreenshotKey) {
activeUploadKey.value = key
fileInput.value?.click()
}
async function handleScreenshotUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const key = activeUploadKey.value
const previewURL = URL.createObjectURL(file)
setScreenshotPreview(key, previewURL)
uploading.value = true
try {
const uploaded = await uploadFile(file, 'listing')
screenshotFiles[key] = uploaded.url
options.notifySuccess('截图已上传')
} catch (error) {
revokeScreenshotPreview(key)
options.notifyError(readError(error, '截图上传失败'))
} finally {
uploading.value = false
input.value = ''
}
}
function removeScreenshot(key: ScreenshotKey) {
screenshotFiles[key] = ''
revokeScreenshotPreview(key)
}
function getScreenshotPreviewURL(key: ScreenshotKey) {
return screenshotPreviews[key] || screenshotFiles[key] || ''
}
function setScreenshotPreview(key: ScreenshotKey, previewURL: string) {
revokeScreenshotPreview(key)
screenshotPreviews[key] = previewURL
}
function revokeScreenshotPreview(key: ScreenshotKey) {
const previewURL = screenshotPreviews[key]
if (previewURL?.startsWith('blob:')) URL.revokeObjectURL(previewURL)
delete screenshotPreviews[key]
}
function revokeAllScreenshotPreviews() {
for (const key of Object.keys(screenshotPreviews)) revokeScreenshotPreview(key)
}
async function hydrateScreenshotPreviews() {
for (const [key, fileURL] of Object.entries(screenshotFiles)) {
if (!fileURL || screenshotPreviews[key] || !fileURL.startsWith('/api/files/object')) continue
try {
const blob = await fetchFileBlobByURL(fileURL)
setScreenshotPreview(key, URL.createObjectURL(blob))
} catch {
// 草稿预览失败不影响已上传文件地址,提交时仍会带上原 URL。
}
}
}
function handleFireLevelInput(value: string | number | undefined) {
if (value === '' || value === undefined) {
form.fire_level = ''
return
}
const level = Number(value)
form.fire_level = Number.isFinite(level) ? Math.trunc(level) : ''
}
function handleAcceleratedSaleRatioInput(value: string | number | undefined) {
if (value === '' || value === undefined) {
form.accelerated_sale_ratio = ''
return
}
const ratio = Number(value)
form.accelerated_sale_ratio = Number.isFinite(ratio) ? ratio : ''
}
function syncRecommendedDeposit() {
const recommended = pricing.recommendedDepositAmount.value
if (recommended <= 0) return
const current = Number(form.deposit_amount)
if (form.deposit_amount === '' || !Number.isFinite(current) || current < recommended) {
form.deposit_amount = recommended
}
}
function useRecommendedDeposit() {
if (pricing.recommendedDepositAmount.value > 0) {
form.deposit_amount = pricing.recommendedDepositAmount.value
}
}
function clearForbiddenQuantityItems() {
for (const item of pricing.quantityItems.value) {
if (!pricing.isQuantityItemDisabled(item)) continue
quantityValues[item.key] = 0
quantityModes[item.key] = '赠送'
}
}
function setQuantityMode(item: { key: string; label: string }, mode: ChargeMode) {
if (pricing.isQuantityItemDisabled(item)) return
quantityModes[item.key] = mode
}
function clampAcceleratedSaleRatioInput() {
if (!pricing.hasAcceleratedSaleRatioInput() || pricing.calculatedDefaultSaleRatio.value <= 0) return
const ratio = Number(form.accelerated_sale_ratio)
if (!Number.isFinite(ratio)) {
form.accelerated_sale_ratio = ''
return
}
form.accelerated_sale_ratio = roundRatio(
Math.min(Math.max(ratio, pricing.calculatedDefaultSaleRatio.value), pricing.maxAcceleratedSaleRatio.value),
)
}
function useReferenceSaleRatio() {
form.accelerated_sale_ratio = ''
}
function useMaxAcceleratedSaleRatio() {
if (pricing.maxAcceleratedSaleRatio.value <= 0) return
form.accelerated_sale_ratio = pricing.maxAcceleratedSaleRatio.value
}
async function handleSubmit() {
const error = validateForm()
if (error) {
options.notifyWarning(error)
return
}
loading.value = true
try {
const listing = await createListing({
title: pricing.publishTitle.value,
description: form.remark,
server_region: form.server_region,
login_platform: form.login_method,
rank_level: form.rank_level,
haf_coin_amount: pricing.coinMAmount.value * 1000000,
asset_summary: buildAssetSummary(),
screenshot_urls: pricing.screenshotUrls.value,
price: pricing.calculatedFinalPrice.value,
deposit_amount: Number(form.deposit_amount),
})
removePublishDraft(options.draftKey)
suppressDraftSave.value = true
clearDraftSaveTimer()
options.notifySuccess(
listing.status === 'published' && listing.review_status === 'approved'
? '发布成功,已上架'
: '发布成功,等待后台审核',
)
await router.push(options.submitSuccessPath)
} catch (error) {
options.notifyError(readError(error, '发布失败,请确认已登录并完成实名认证'))
} finally {
loading.value = false
}
}
function validateForm() {
if (!form.server_region) return '请选择区服'
if (pricing.coinMAmount.value <= 0) return '请填写哈夫币/M'
if (!form.rank_level) return '请选择段位'
if (!form.fire_level) return '请填写烽火等级'
if (Number(form.fire_level) < pricing.fireLevelMin.value) return `烽火等级低于${pricing.fireLevelMin.value}级的号无法发布`
if (!form.season_insurance) return '请选择赛季保险'
if (!form.stamina_level) return '请选择体力等级'
if (!form.load_level) return '请选择负重等级'
if (!dailyLossOptions.includes(pricing.dailyLossMAmount.value)) return '请选择每日损耗'
if (pricing.hasAcceleratedSaleRatioInput()) {
const ratio = Number(form.accelerated_sale_ratio)
if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确'
if (pricing.calculatedDefaultSaleRatio.value > 0 && ratio < pricing.calculatedDefaultSaleRatio.value) {
return `加速出售比例不能低于默认比例 1:${formatNumber(pricing.calculatedDefaultSaleRatio.value)}`
}
if (pricing.maxAcceleratedSaleRatio.value > 0 && ratio > pricing.maxAcceleratedSaleRatio.value) {
return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio.value)}`
}
}
if (pricing.banRecordOptions.value.length && !form.ban_record) return '请选择封禁记录'
if (form.deposit_amount === '' || Number(form.deposit_amount) < 0) return '请填写押金'
if (!Number.isFinite(Number(form.deposit_amount))) return '押金格式不正确'
if (pricing.recommendedDepositAmount.value > 0 && Number(form.deposit_amount) < pricing.recommendedDepositAmount.value) {
return `押金不能低于智能推荐 ¥${pricing.recommendedDepositAmount.value}`
}
if (pricing.calculatedConsumablePrice.value > 0 && Number(form.deposit_amount) <= pricing.calculatedConsumablePrice.value) {
return `押金必须大于额外消耗品总价值 ¥${pricing.calculatedConsumablePrice.value}`
}
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
if (!Number.isFinite(pricing.calculatedFinalPrice.value)) return '发布价格计算异常,请检查填写内容'
for (const item of pricing.screenshotSlots.value) {
if (isScreenshotRequired(item) && !screenshotFiles[item.key]) return `请上传${item.label}`
}
for (const item of pricing.quantityItems.value) {
if (!pricing.isQuantityItemDisabled(item)) {
const val = quantityValues[item.key]
if (val === undefined || val === null || (val as unknown) === '') {
return `请填写${item.label}的数量`
}
if (Number(val) < 0) {
return `${item.label}的数量不能为负数`
}
}
}
for (const item of pricing.quantityItems.value) {
if (pricing.isQuantityItemDisabled(item) && Number(quantityValues[item.key] || 0) > 0) {
return '赛季保险选择 3*3 时不能填写 9格体验卡'
}
}
return ''
}
function isScreenshotRequired(item: { key: string; required: boolean }) {
return item.required || (item.key === 'tencentSecurity' && shouldRequireBanEvidence())
}
function shouldRequireBanEvidence() {
return pricing.banEvidenceOptions.value.includes(form.ban_record)
}
function buildAssetSummary() {
return {
face_owner: form.face_owner,
secret_kd: form.secret_kd,
fire_level: Number(form.fire_level),
daily_loss_m: pricing.dailyLossMAmount.value,
publish_ratio: pricing.calculatedPlatformPricing.value.buyerRatio,
price_breakdown: {
seller_reference_ratio: pricing.calculatedSellerReferenceRatio.value,
seller_ratio: pricing.calculatedRatio.value,
seller_coin_base_price: pricing.calculatedCoinBasePrice.value,
seller_total_price: pricing.calculatedSellerPrice.value,
consumable_price: pricing.calculatedConsumablePrice.value,
daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment.value,
accelerated_sale_ratio: pricing.hasAcceleratedSaleRatioInput()
? Number(form.accelerated_sale_ratio)
: pricing.calculatedDefaultSaleRatio.value,
buyer_coin_base_price: pricing.calculatedPlatformPricing.value.buyerCoinBasePrice,
buyer_total_price: pricing.calculatedFinalPrice.value,
buyer_ratio: pricing.calculatedPlatformPricing.value.buyerRatio,
platform_markup_amount: pricing.calculatedPlatformPricing.value.platformMarkupAmount,
platform_rule_type: pricing.calculatedPlatformPricing.value.ruleType,
},
season_insurance: form.season_insurance,
stamina_level: form.stamina_level,
load_level: form.load_level,
resources: pricing.quantityItems.value.map((item) => ({
key: item.key,
label: item.label,
price: item.price,
quantity: pricing.isQuantityItemDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
mode: pricing.isQuantityItemDisabled(item) ? '赠送' : quantityModes[item.key] || '收费',
})),
skin_groups: pricing.skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
groups[group.key] = group.options.filter((skin) => selectedSkins.value.includes(skin))
return groups
}, {}),
online_time: {
start: form.online_start,
end: form.online_end,
},
ban_record: form.ban_record,
common_regions: form.common_regions,
remark: form.remark,
}
}
return {
...pricing,
dailyLossOptions,
commonOnlineTimes,
formatNumber,
router,
loading,
uploading,
fileInput,
activeUploadKey,
form,
quantityValues,
quantityModes,
screenshotFiles,
screenshotPreviews,
selectedSkins,
uploadedScreenshotCount,
requiredScreenshotCount,
loadPublishOptions,
saveDraft,
handleSaveDraft,
resetDraftState,
handleResetDraft,
toggleSkin,
toggleRegion,
triggerUpload,
handleScreenshotUpload,
removeScreenshot,
getScreenshotPreviewURL,
handleFireLevelInput,
handleAcceleratedSaleRatioInput,
syncRecommendedDeposit,
useRecommendedDeposit,
clearForbiddenQuantityItems,
setQuantityMode,
clampAcceleratedSaleRatioInput,
useReferenceSaleRatio,
useMaxAcceleratedSaleRatio,
handleSubmit,
validateForm,
isScreenshotRequired,
shouldRequireBanEvidence,
buildAssetSummary,
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
@@ -1,69 +0,0 @@
import { onUnmounted, ref } from "vue";
import { showToast } from "vant";
import { sendSmsCode } from "@/api/auth";
export function useSmsCountdown() {
const countDown = ref(0);
const sending = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
function startCountDown() {
countDown.value = 60;
timer = setInterval(() => {
countDown.value--;
if (countDown.value <= 0) {
clearInterval(timer!);
timer = null;
}
}, 1000);
}
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
async function handleSendCode(phone: string) {
if (!phone.trim()) {
showToast({ message: "请输入手机号", icon: "warning-o" });
return false;
}
sending.value = true;
try {
await sendSmsCode(phone);
showToast({
message: "验证码已发送,请注意查收",
icon: "passed",
});
startCountDown();
return true;
} catch (error) {
showToast({
message: readError(error, "验证码发送失败,请稍后重试"),
icon: "cross",
});
return false;
} finally {
sending.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
return {
countDown,
sending,
handleSendCode,
readError,
};
}
-540
View File
@@ -1,540 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
import {
fetchChat,
fetchChatMessages,
markChatRead,
sendChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { uploadFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
const route = useRoute()
const router = useRouter()
const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg) appendMessage({
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
})
markChatRead(conversationID.value).catch(() => {})
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
loadConversation()
}
}
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await loadAll()
})
watch(conversationID, async (id, oldId) => {
if (id && id !== oldId) {
await loadAll()
}
})
async function loadAll() {
if (!conversationID.value) return
loading.value = true
conversation.value = null
messages.value = []
try {
const [chat] = await Promise.all([
fetchChat(conversationID.value),
loadMessages(true),
])
conversation.value = chat
await markChatRead(conversationID.value)
} catch {
ElMessage.error('加载会话失败')
} finally {
loading.value = false
}
}
async function loadConversation() {
if (!conversationID.value) return
try {
conversation.value = await fetchChat(conversationID.value)
} catch { /* ignore */ }
}
async function loadMessages(scrollToBottom = true) {
if (!conversationID.value) return
const res = await fetchChatMessages(conversationID.value, 1, 100)
messages.value = res.items
if (scrollToBottom) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
const imageUrls = [...attachments.value]
if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversation()
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统'
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
</script>
<template>
<section class="page chat-page">
<div class="chat-workbench">
<!-- Header -->
<div class="chat-header">
<button class="back-btn" type="button" @click="router.push('/messages')">
<el-icon :size="18"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="chat-title">
<h2>{{ conversation?.title || '客服会话' }}</h2>
<p>{{ memberText }}</p>
</div>
<el-button
v-if="conversation?.order_id"
type="primary"
link
@click="router.push(`/orders/${conversation.order_id}`)"
>
查看订单
</el-button>
</div>
<!-- Messages -->
<div ref="listRef" v-loading="loading" class="message-list">
<div v-if="loading && messages.length === 0" class="loading-placeholder">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
</div>
<el-empty v-else-if="!loading && messages.length === 0" description="暂无消息" />
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
</template>
<template v-else>
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap" :class="{ self: item.is_self }">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
/>
</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</div>
<!-- Composer -->
<div class="composer">
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-input
v-model="content"
type="textarea"
:rows="3"
:maxlength="1000"
show-word-limit
placeholder="发送消息..."
resize="none"
@keydown="handleKeydown"
/>
<div class="composer-actions">
<span class="composer-hint">Enter 发送Shift+Enter 换行</span>
<div class="composer-buttons">
<el-button :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
<el-button type="primary" :disabled="!canSend || sending || uploading" :loading="sending" @click="handleSend">
发送
</el-button>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.chat-page {
max-width: 1720px;
margin: 0 auto;
}
.chat-workbench {
display: flex;
flex-direction: column;
height: calc(100vh - 56px - 40px);
border: 1px solid #e8edf3;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #eef1f5;
background: #fafbfc;
flex-shrink: 0;
}
.back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #5a6577;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.back-btn:hover {
background: #f0f5ff;
color: #1477ff;
}
.chat-title {
flex: 1;
min-width: 0;
}
.chat-title h2 {
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title p {
margin: 2px 0 0;
color: #6b7785;
font-size: 12px;
}
.message-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.loading-placeholder {
display: flex;
justify-content: center;
padding: 60px 0;
color: #a0aab6;
}
.message-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row.system {
justify-content: center;
}
.avatar {
display: grid;
flex: none;
width: 36px;
height: 36px;
place-items: center;
border-radius: 50%;
background: #1477ff;
color: #fff;
font-size: 14px;
font-weight: 800;
}
.avatar.self {
background: #10b981;
}
.bubble-wrap {
display: flex;
max-width: 60%;
flex-direction: column;
align-items: flex-start;
}
.bubble-wrap.self {
align-items: flex-end;
}
.sender-name {
margin-bottom: 4px;
color: #8a94a6;
font-size: 12px;
}
.bubble {
max-width: 100%;
padding: 10px 14px;
border-radius: 10px;
background: #f4f6f8;
color: #17233d;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.message-row.self .bubble {
background: #dff5eb;
color: #0f5132;
}
.message-attachments {
display: grid;
gap: 6px;
margin-top: 6px;
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
font-size: 11px;
}
.system-message {
max-width: 80%;
padding: 6px 12px;
border-radius: 8px;
background: #e6ebf2;
color: #6b7280;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.composer {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid #eef1f5;
background: #fafbfc;
}
.pending-attachments {
display: flex;
gap: 8px;
margin-bottom: 10px;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 86px;
height: 86px;
}
.pending-item :deep(.chat-image-button img) {
height: 86px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.hidden-file {
display: none;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.composer-buttons {
display: flex;
gap: 8px;
}
.composer-hint {
color: #a0aab6;
font-size: 12px;
}
</style>
-409
View File
@@ -1,409 +0,0 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ChatLineRound, Iphone } from "@element-plus/icons-vue";
import { onUnmounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { sendSmsCode } from "@/api/auth";
import { useSessionStore } from "@/stores/session";
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const sending = ref(false);
const countDown = ref(0);
const form = reactive({
phone: "",
code: "",
});
let timer: ReturnType<typeof setInterval> | null = null;
function startCountDown() {
countDown.value = 60;
timer = setInterval(() => {
countDown.value--;
if (countDown.value <= 0) {
clearInterval(timer!);
timer = null;
}
}, 1000);
}
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
async function handleSendCode() {
if (!form.phone.trim()) {
ElMessage.warning("请输入手机号");
return;
}
sending.value = true;
try {
await sendSmsCode(form.phone);
ElMessage.success("验证码已发送,请注意查收");
startCountDown();
} catch (error) {
ElMessage.error(readError(error, "验证码发送失败"));
} finally {
sending.value = false;
}
}
async function handleLogin() {
loading.value = true;
try {
await session.login(form.phone, form.code);
ElMessage.success("登录成功");
await router.push("/");
} catch (error) {
ElMessage.error(readError(error, "登录失败"));
} finally {
loading.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
</script>
<template>
<section class="login-shell">
<div class="login-card">
<div class="login-left">
<div class="brand-lockup">
<div class="brand-logo"></div>
<strong>大锤商行</strong>
</div>
<div class="login-hero">
<h2>安全租号<br />畅享游戏</h2>
<p>
高效安全的哈夫币账号交易平台<br />
7×24 小时智能订单系统随时为您服务
</p>
</div>
<div class="login-footer">
<span>© 哈夫币租号平台</span>
</div>
</div>
<div class="login-right">
<div class="login-header">
<p class="eyebrow">SMS Login</p>
<h1>欢迎回来</h1>
<p class="subtitle">登录后即可发布或租赁账号</p>
</div>
<el-form 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-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"
>
{{
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>
<p class="login-notice">未收到验证码时请稍后重试或联系客服处理</p>
</el-form>
</div>
</div>
</section>
</template>
<style scoped>
.login-shell {
display: grid;
flex: 1;
place-items: center;
margin: -20px -24px;
padding: 32px 24px;
}
.login-card {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 420px;
width: min(940px, 100%);
min-height: 520px;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(20px);
box-shadow: 0 24px 80px rgba(17, 24, 39, 0.08),
0 1px 0 rgba(255, 255, 255, 0.6) inset;
overflow: hidden;
}
/* ========== 左侧品牌区 ========== */
.login-left {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 40px 36px;
background: radial-gradient(
circle at 30% 20%,
rgba(20, 119, 255, 0.08),
transparent 50%
),
radial-gradient(
circle at 80% 90%,
rgba(109, 40, 217, 0.06),
transparent 50%
),
linear-gradient(160deg, rgba(20, 119, 255, 0.06), rgba(109, 40, 217, 0.03));
}
.brand-lockup {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
box-shadow: 0 8px 24px rgba(20, 119, 255, 0.22);
color: #fff;
font-size: 15px;
font-weight: 900;
}
.brand-lockup strong {
color: #0f172a;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.3px;
}
.login-hero h2 {
margin: 0 0 14px;
color: #0f172a;
font-size: 28px;
font-weight: 800;
line-height: 1.25;
}
.login-hero p {
margin: 0;
color: #475569;
font-size: 14px;
line-height: 1.7;
max-width: 300px;
}
.login-footer span {
color: #94a3b8;
font-size: 12px;
}
/* ========== 右侧表单区 ========== */
.login-right {
display: flex;
flex-direction: column;
justify-content: center;
padding: 36px 32px;
background: rgba(255, 255, 255, 0.45);
}
.login-header {
margin-bottom: 24px;
}
.login-header .eyebrow {
margin: 0 0 8px;
color: #1477ff;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.login-header h1 {
margin: 0;
color: #0f172a;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
}
.login-header .subtitle {
margin: 8px 0 0;
color: #64748b;
font-size: 13px;
}
.user-form :deep(.el-form-item__label) {
color: #334155;
font-size: 13px;
font-weight: 600;
padding-bottom: 6px;
}
.user-form :deep(.el-input__wrapper) {
background: #ffffff;
box-shadow: 0 0 0 1px #e2e8f0 inset;
border-radius: 10px;
padding: 0 12px;
transition: box-shadow 0.2s, background 0.2s;
}
.user-form :deep(.el-input__wrapper:hover) {
background: #ffffff;
}
.user-form :deep(.el-input__wrapper.is-focus) {
background: #ffffff;
box-shadow: 0 0 0 1px rgba(20, 119, 255, 0.45) inset,
0 0 0 3px rgba(20, 119, 255, 0.08);
}
.user-form :deep(.el-input__inner) {
color: #0f172a;
font-size: 14px;
height: 44px;
}
.user-form :deep(.el-input__inner::placeholder) {
color: #94a3b8;
}
.user-form :deep(.el-input__prefix-inner) {
color: #94a3b8;
}
.user-code-row {
display: grid;
grid-template-columns: 1fr 120px;
gap: 10px;
width: 100%;
}
.user-code-row .el-button {
height: 46px;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.3px;
background: #ffffff;
border-color: #1477ff;
color: #1477ff;
box-shadow: none;
transition: background 0.2s, color 0.2s;
}
.user-code-row .el-button:hover:not(:disabled) {
background: #1477ff;
border-color: #1477ff;
color: #ffffff;
}
.user-code-row .el-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.user-login-btn {
width: 100%;
height: 46px;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
border: none;
box-shadow: 0 10px 28px rgba(20, 119, 255, 0.22);
transition: transform 0.15s, box-shadow 0.2s;
}
.user-login-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 36px rgba(20, 119, 255, 0.28);
}
.login-notice {
margin: 18px 0 0;
color: #94a3b8;
font-size: 12px;
text-align: center;
line-height: 1.5;
}
/* ========== 响应式 ========== */
@media (max-width: 860px) {
.login-card {
grid-template-columns: 1fr;
max-width: 420px;
}
.login-left {
display: none;
}
.login-right {
padding: 32px 28px;
}
.login-shell {
padding: 24px 16px;
}
}
@media (max-width: 480px) {
.login-shell {
padding: 16px 12px;
}
.login-right {
padding: 24px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.user-login-btn {
transition: none;
}
}
</style>
-290
View File
@@ -1,290 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => conversations.value.length < total.value)
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(() => loadChats(true))
async function loadChats(isRefresh = false, showLoading = true) {
if (isRefresh) page.value = 1
if (loading.value) return
if (showLoading) loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length < res.total && res.items.length > 0) {
page.value += 1
}
} catch {
ElMessage.error('获取会话失败')
} finally {
loading.value = false
}
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' || event.type === 'conversation_updated') {
loadChats(true, false)
}
}
function openConversation(item: ChatConversation) {
router.push(`/messages/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服', customer: '咨询' }
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
}
</script>
<template>
<section class="page messages-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Messages</p>
<h1>消息</h1>
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
</div>
</div>
<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="暂无会话">
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
</el-empty>
</div>
<div v-else v-loading="loading" class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<div class="conversation-body">
<div class="conversation-head">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span class="order-id">{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
</div>
<p class="conversation-preview">{{ previewText(item) }}</p>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
</div>
</section>
</template>
<style scoped>
.messages-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header-row {
align-items: flex-end;
}
.header-actions {
display: flex;
gap: 10px;
}
.message-loading,
.empty-panel {
min-height: 360px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
}
.empty-panel {
display: grid;
place-items: center;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.conversation-item {
position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid #e8edf3;
border-radius: 10px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.conversation-item:hover {
border-color: #1477ff;
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.avatar {
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
font-weight: 800;
}
.avatar.main {
width: 44px;
height: 44px;
background: #1477ff;
font-size: 16px;
}
.avatar.support {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
border: 2px solid #fff;
background: #10b981;
font-size: 11px;
}
.conversation-body {
min-width: 0;
}
.conversation-head {
display: flex;
align-items: center;
gap: 8px;
}
.conversation-head h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: #6b7785;
font-size: 12px;
}
.role-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-size: 11px;
font-weight: 700;
}
.order-id {
color: #8a94a6;
}
.conversation-preview {
margin: 6px 0 0;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-badge {
position: absolute;
right: 14px;
bottom: 14px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
}
</style>
@@ -1,77 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { fetchNotifications, markNotificationRead, type NotificationItem } from '@/api/notifications'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const notifications = ref<NotificationItem[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadNotifications)
async function loadNotifications() {
loading.value = true
try {
const result = await fetchNotifications(currentPage.value, currentPageSize.value)
notifications.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadNotifications()
}
async function markRead(id: number) {
await markNotificationRead(id)
ElMessage.success('已标记为已读')
await loadNotifications()
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Notifications</p>
<h1>站内信</h1>
<p>接收审核交接归还申诉和仲裁结果通知</p>
</div>
<el-empty v-if="!loading && notifications.length === 0" description="暂无站内信" />
<div v-else v-loading="loading" class="notification-list">
<div v-for="item in notifications" :key="item.id" class="notification-item" :class="{ unread: !item.read_at }">
<div>
<span>{{ item.type }}</span>
<h2>{{ item.title }}</h2>
<p>{{ item.content }}</p>
<small>{{ formatDateTime(item.created_at) }}</small>
</div>
<div class="notification-actions">
<RouterLink v-if="item.biz_type === 'order' && item.biz_id" :to="`/orders/${item.biz_id}`">
<el-button size="small">查看订单</el-button>
</RouterLink>
<el-button v-if="!item.read_at" size="small" type="primary" @click="markRead(item.id)">已读</el-button>
</div>
</div>
</div>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadNotifications"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
@@ -1,9 +0,0 @@
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Create Order</p>
<h1>创建订单</h1>
<p>确认价格押金和账号资产快照</p>
</div>
</section>
</template>
File diff suppressed because it is too large Load Diff
-541
View File
@@ -1,541 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { fetchOrders, type Order } from '@/api/orders'
import { useSessionStore } from '@/stores/session'
import { orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const orders = ref<Order[]>([])
const payingOrderId = ref<number | null>(null)
const session = useSessionStore()
const route = useRoute()
const router = useRouter()
const searchKeyword = ref('')
const statusTabs = [
{ key: 'all', label: '全部' },
{ key: 'pending_payment', label: '待支付' },
{ key: 'pending_handoff', label: '待交接' },
{ key: 'renting', label: '使用中' },
{ key: 'pending_checkout_confirm', label: '待结账' },
{ key: 'completed', label: '已完成' },
]
const tabKeys = new Set(statusTabs.map((item) => item.key))
const activeTab = ref(readTab(route.query.tab))
const displayOrders = computed(() => {
let filtered = orders.value
if (activeTab.value !== 'all') {
filtered = filtered.filter((order) => order.status === activeTab.value)
}
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.trim().toLowerCase()
filtered = filtered.filter((order) =>
order.order_no.toLowerCase().includes(keyword) ||
order.title.toLowerCase().includes(keyword)
)
}
return filtered
})
const tabCounts = computed(() => {
const counts: Record<string, number> = { all: orders.value.length }
statusTabs.forEach((tab) => {
if (tab.key !== 'all') {
counts[tab.key] = orders.value.filter((order) => order.status === tab.key).length
}
})
return counts
})
onMounted(loadOrders)
watch(
() => route.query.tab,
(tab) => {
activeTab.value = readTab(tab)
},
)
function readTab(tab: unknown) {
const value = typeof tab === 'string' ? tab : 'all'
return tabKeys.has(value) ? value : 'all'
}
function handleTabChange(tab: string | number) {
const nextTab = readTab(String(tab))
router.replace({ path: '/orders', query: nextTab === 'all' ? {} : { tab: nextTab } })
}
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchOrders()
} finally {
loading.value = false
}
}
async function handlePay(order: Order) {
payingOrderId.value = order.id
try {
await router.push(`/orders/${order.id}?pay=1`)
} catch (error) {
ElMessage.error(readError(error, '打开支付失败'))
} finally {
payingOrderId.value = null
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
function orderRole(order: Order) {
if (order.renter_id === session.userId) return '租客'
if (order.owner_id === session.userId) return '号主'
return '-'
}
function isRenter(order: Order) {
return order.renter_id === session.userId
}
function isOwner(order: Order) {
return order.owner_id === session.userId
}
function amountLabel(order: Order) {
return isRenter(order) ? '支付金额' : '租金收入'
}
function money(value: unknown) {
return Math.round(Number(value || 0))
}
function shortenOrderNo(orderNo: string) {
if (orderNo.length <= 20) return orderNo
return `${orderNo.slice(0, 12)}...${orderNo.slice(-6)}`
}
async function copyOrderNo(orderNo: string) {
try {
await navigator.clipboard.writeText(orderNo)
ElMessage.success('订单号已复制')
} catch {
ElMessage.error('复制失败')
}
}
function getPaymentDeadline(order: Order) {
if (order.status !== 'pending_payment' || !order.created_at) return null
const created = new Date(order.created_at)
const deadline = new Date(created.getTime() + 30 * 60 * 1000)
return deadline
}
function getCountdownMinutes(order: Order) {
const deadline = getPaymentDeadline(order)
if (!deadline) return 0
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.max(0, Math.ceil(diff / 60000))
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">ORDERS</p>
<h1>我的订单</h1>
<p>跟踪待支付待交接使用中待归还申诉中和已完成订单</p>
</div>
<div class="orders-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索订单号 / 账号 / 起始数字"
:prefix-icon="Search"
clearable
class="search-input"
/>
</div>
<el-tabs v-model="activeTab" class="order-tabs" @tab-change="handleTabChange">
<el-tab-pane v-for="tab in statusTabs" :key="tab.key" :name="tab.key">
<template #label>
<span class="tab-label">
{{ tab.label }}
<span v-if="(tabCounts?.[tab.key] ?? 0) > 0" class="tab-count">{{ tabCounts?.[tab.key] }}</span>
</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-if="displayOrders.length === 0 && !loading" class="empty-state">
<el-empty :description="searchKeyword ? '没有找到匹配的订单' : '暂无订单'" />
</div>
<div v-else class="orders-container">
<el-table v-loading="loading" class="table-panel orders-table" :data="displayOrders">
<el-table-column label="订单号" min-width="180">
<template #default="{ row }">
<div class="order-no-cell">
<span class="order-no-text" :title="row.order_no">{{ shortenOrderNo(row.order_no) }}</span>
<el-icon class="copy-icon" @click="copyOrderNo(row.order_no)">
<CopyDocument />
</el-icon>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="账号" min-width="180" />
<el-table-column label="金额" width="100">
<template #default="{ row }">
<div class="amount-cell">
<span class="amount-value">¥{{ money(row.display_amount) }}</span>
<span class="amount-label">{{ amountLabel(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="押金" width="100">
<template #default="{ row }">¥{{ money(row.deposit_amount) }}</template>
</el-table-column>
<el-table-column label="身份" width="80">
<template #default="{ row }">
<el-tag :type="isRenter(row) ? 'primary' : 'success'" size="small">
{{ orderRole(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="140">
<template #default="{ row }">
<div class="status-cell">
<span>{{ orderStatusLabel(row.status) }}</span>
<span v-if="row.status === 'pending_payment' && getCountdownMinutes(row) > 0" class="countdown-badge">
{{ getCountdownMinutes(row) }}分钟后超时
</span>
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button
v-if="row.status === 'pending_payment'"
size="small"
type="primary"
:disabled="row.renter_id !== session.userId"
:loading="payingOrderId === row.id"
@click="handlePay(row)"
>
使用中
</el-button>
<RouterLink :to="`/orders/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</div>
</template>
</el-table-column>
</el-table>
<div class="mobile-cards">
<div v-for="order in displayOrders" :key="order.id" class="order-card" @click="router.push(`/orders/${order.id}`)">
<div class="card-header">
<div class="order-no-row">
<span class="order-no-text" :title="order.order_no">{{ shortenOrderNo(order.order_no) }}</span>
<el-icon class="copy-icon" @click.stop="copyOrderNo(order.order_no)">
<CopyDocument />
</el-icon>
</div>
<el-tag :type="isRenter(order) ? 'primary' : 'success'" size="small">
{{ orderRole(order) }}
</el-tag>
</div>
<div class="card-title">{{ order.title }}</div>
<div class="card-meta">
<div class="meta-row">
<span class="meta-label">{{ amountLabel(order) }}</span>
<span class="meta-value amount">¥{{ money(order.display_amount) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">押金</span>
<span class="meta-value">¥{{ money(order.deposit_amount) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">创建时间</span>
<span class="meta-value">{{ formatDateTime(order.created_at) }}</span>
</div>
</div>
<div class="card-footer">
<div class="status-info">
<span class="status-label">{{ orderStatusLabel(order.status) }}</span>
<span v-if="order.status === 'pending_payment' && getCountdownMinutes(order) > 0" class="countdown-badge">
{{ getCountdownMinutes(order) }}分钟后超时
</span>
</div>
<el-button
v-if="order.status === 'pending_payment' && isRenter(order)"
size="small"
type="primary"
:loading="payingOrderId === order.id"
@click.stop="handlePay(order)"
>
立即支付
</el-button>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.orders-toolbar {
margin-top: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.search-input {
max-width: 400px;
}
.order-tabs {
margin-top: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #ffffff;
padding: 0 16px;
}
.order-tabs :deep(.el-tabs__header) {
margin: 0;
}
.order-tabs :deep(.el-tabs__nav-wrap::after) {
height: 0;
}
.tab-label {
display: flex;
align-items: center;
gap: 6px;
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: #ff6a00;
color: #ffffff;
font-size: 12px;
font-weight: 600;
}
.order-tabs :deep(.is-active) .tab-count {
background: #ffffff;
color: #ff6a00;
}
.orders-container {
margin-top: 24px;
}
.empty-state {
margin-top: 60px;
}
.orders-table {
display: block;
}
.mobile-cards {
display: none;
}
.order-no-cell {
display: flex;
align-items: center;
gap: 8px;
}
.order-no-text {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
color: #4a5568;
}
.copy-icon {
cursor: pointer;
color: #9ca3af;
font-size: 14px;
transition: color 0.2s;
}
.copy-icon:hover {
color: #ff6a00;
}
.amount-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.amount-value {
font-weight: 600;
color: #1f2937;
}
.amount-label {
font-size: 12px;
color: #9ca3af;
}
.status-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.countdown-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
background: #fef3c7;
color: #d97706;
font-size: 11px;
font-weight: 500;
}
.action-buttons {
display: flex;
gap: 8px;
}
.order-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
background: #ffffff;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 12px;
}
.order-card:hover {
border-color: #ff6a00;
box-shadow: 0 4px 12px rgba(255, 106, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.order-no-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
}
.card-meta {
display: grid;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.meta-label {
font-size: 13px;
color: #6b7280;
}
.meta-value {
font-size: 14px;
color: #374151;
font-weight: 500;
}
.meta-value.amount {
color: #ff6a00;
font-weight: 600;
font-size: 15px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-label {
font-size: 14px;
font-weight: 500;
color: #1f2937;
}
@media (max-width: 768px) {
.orders-table {
display: none;
}
.mobile-cards {
display: block;
}
.search-input {
max-width: 100%;
}
.order-tabs {
padding: 0 8px;
}
.order-tabs :deep(.el-tabs__item) {
padding: 0 12px;
font-size: 14px;
}
}
</style>
-714
View File
@@ -1,714 +0,0 @@
<script setup lang="ts">
import {
Camera,
CircleCheckFilled,
CirclePlus,
Coin,
EditPen,
Finished,
Goods,
Postcard,
RefreshRight,
Shop,
Tickets,
User,
Van,
VideoPlay,
Wallet,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { uploadFile } from '@/api/files'
import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/utils/statusLabels'
const session = useSessionStore()
const router = useRouter()
const saving = ref(false)
const uploadingAvatar = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const form = reactive({
nickname: '',
avatar_url: '',
})
const buyerServices = [
{ label: '待支付', icon: Coin, tone: 'warning', to: { path: '/orders', query: { tab: 'pending_payment' } } },
{ label: '待交接', icon: Van, tone: 'info', to: { path: '/orders', query: { tab: 'pending_handoff' } } },
{ label: '使用中', icon: VideoPlay, tone: 'primary', to: { path: '/orders', query: { tab: 'renting' } } },
{ label: '已完成', icon: Finished, tone: 'success', to: { path: '/orders', query: { tab: 'completed' } } },
]
const sellerServices = [
{ label: '发布商品', icon: CirclePlus, tone: 'orange', to: '/seller/listings/create' },
{ label: '我的商品', icon: Shop, tone: 'purple', to: '/seller/listings' },
{ label: '提现/账单', icon: Wallet, tone: 'teal', to: '/wallet' },
]
const displayName = computed(() => session.displayName)
const maskedPhone = computed(() => {
if (!session.phone) return '未绑定手机号'
return `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}`
})
const avatarText = computed(() => (form.nickname || displayName.value || 'U').slice(0, 1))
const realnameTone = computed(() => {
if (session.realnameStatus === 'verified') return 'verified'
if (session.realnameStatus === 'pending') return 'pending'
if (session.realnameStatus === 'rejected') return 'rejected'
return 'unverified'
})
onMounted(async () => {
if (!session.phone) {
await session.loadMe()
}
resetForm()
})
function resetForm() {
form.nickname = session.nickname || session.displayName
form.avatar_url = session.avatarUrl || ''
}
function resolveAvatarURL(url: string | undefined | null) {
if (!url) return ''
if (url.includes('/api/files/object?key=avatar/')) {
return url.replace('/api/files/object', '/api/public/files/object')
}
return url
}
function triggerAvatarUpload() {
avatarFileInput.value?.click()
}
async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingAvatar.value = true
try {
const uploaded = await uploadFile(file, 'avatar')
form.avatar_url = uploaded.url
ElMessage.success('头像上传成功')
} catch (error) {
ElMessage.error(readError(error, '头像上传失败'))
} finally {
uploadingAvatar.value = false
}
}
async function saveProfile() {
const nickname = form.nickname.trim()
const avatarURL = form.avatar_url.trim()
if (!nickname) {
ElMessage.warning('请输入昵称')
return
}
if (nickname.length > 24) {
ElMessage.warning('昵称不能超过 24 个字符')
return
}
saving.value = true
try {
await session.updateProfile({ nickname, avatar_url: avatarURL })
resetForm()
ElMessage.success('个人资料已保存')
} catch (error) {
ElMessage.error(readError(error, '资料更新失败'))
} finally {
saving.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="profile-page">
<div class="profile-hero">
<div class="hero-avatar">
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
<span v-else>{{ session.avatarText }}</span>
</div>
<div class="hero-copy">
<span class="eyebrow">Profile</span>
<h1>个人资料</h1>
<p>{{ displayName }} · {{ maskedPhone }}</p>
</div>
<button class="hero-realname" :class="`is-${realnameTone}`" type="button" @click="router.push('/realname')">
<el-icon><CircleCheckFilled v-if="session.realnameStatus === 'verified'" /><WarningFilled v-else /></el-icon>
<span>{{ realnameStatusLabel(session.realnameStatus) }}</span>
</button>
</div>
<div class="profile-layout">
<section class="edit-panel">
<div class="panel-head">
<div class="panel-icon">
<el-icon><EditPen /></el-icon>
</div>
<div>
<h2>资料编辑</h2>
<p>修改昵称和头像后会同步展示在顶部用户信息中</p>
</div>
</div>
<div class="avatar-editor">
<button class="avatar-preview" type="button" @click="triggerAvatarUpload">
<img v-if="form.avatar_url" :src="resolveAvatarURL(form.avatar_url)" alt="" />
<span v-else>{{ avatarText }}</span>
<i><el-icon><Camera /></el-icon></i>
</button>
<div class="avatar-actions">
<strong>{{ form.nickname || displayName }}</strong>
<p>支持上传本地图片也可以直接填写头像 URL</p>
<el-button :icon="Camera" :loading="uploadingAvatar" @click="triggerAvatarUpload">上传头像</el-button>
<input ref="avatarFileInput" type="file" accept="image/*" hidden @change="handleAvatarFileChange" />
</div>
</div>
<el-form class="profile-form" label-position="top">
<el-form-item label="昵称">
<el-input v-model="form.nickname" :prefix-icon="User" maxlength="24" placeholder="请输入昵称" size="large" />
</el-form-item>
<el-form-item label="头像地址">
<el-input
v-model="form.avatar_url"
:prefix-icon="Postcard"
maxlength="512"
placeholder="可填写图片 URL,留空使用文字头像"
size="large"
/>
</el-form-item>
<div class="form-actions">
<el-button size="large" :icon="RefreshRight" @click="resetForm">重置</el-button>
<el-button type="primary" size="large" :icon="EditPen" :loading="saving" @click="saveProfile">保存资料</el-button>
</div>
</el-form>
</section>
<aside class="account-card">
<span class="card-kicker">账号信息</span>
<dl>
<div>
<dt>用户 ID</dt>
<dd>{{ session.userId || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ maskedPhone }}</dd>
</div>
<div>
<dt>实名状态</dt>
<dd :class="`is-${realnameTone}`">{{ realnameStatusLabel(session.realnameStatus) }}</dd>
</div>
</dl>
<button class="realname-shortcut" type="button" @click="router.push('/realname')">
<span>查看实名认证</span>
<el-icon><CircleCheckFilled /></el-icon>
</button>
</aside>
</div>
<div class="service-panels">
<section class="service-panel">
<div class="service-head">
<h2>买家服务</h2>
<RouterLink class="service-all" :to="{ path: '/orders', query: { tab: 'all' } }">
<span>全部订单</span>
<el-icon><Tickets /></el-icon>
</RouterLink>
</div>
<div class="service-grid buyer-grid">
<RouterLink v-for="item in buyerServices" :key="item.label" class="service-item" :to="item.to">
<span class="service-icon" :class="`is-${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon>
</span>
<strong>{{ item.label }}</strong>
</RouterLink>
</div>
</section>
<section class="service-panel">
<div class="service-head">
<h2>卖家服务</h2>
<RouterLink class="service-all" to="/seller/listings">
<span>管理发布</span>
<el-icon><Goods /></el-icon>
</RouterLink>
</div>
<div class="service-grid seller-grid">
<RouterLink v-for="item in sellerServices" :key="item.label" class="service-item" :to="item.to">
<span class="service-icon" :class="`is-${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon>
</span>
<strong>{{ item.label }}</strong>
</RouterLink>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.profile-page {
width: min(1180px, 100%);
margin: 0 auto;
padding: 10px 0 40px;
}
.profile-hero {
display: flex;
align-items: center;
gap: 18px;
padding: 30px 34px;
border: 1px solid #eef1f5;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 106, 0, 0.12), rgba(37, 99, 235, 0.08)),
#ffffff;
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
}
.hero-avatar,
.avatar-preview {
display: grid;
place-items: center;
overflow: hidden;
border-radius: 999px;
background: #ff6a00;
color: #ffffff;
font-weight: 900;
}
.hero-avatar {
width: 74px;
height: 74px;
font-size: 28px;
flex-shrink: 0;
}
.hero-avatar img,
.avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-copy {
min-width: 0;
flex: 1;
}
.eyebrow {
color: #ff6a00;
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 8px 0 0;
color: #17233d;
font-size: 34px;
line-height: 1.2;
}
.hero-copy p {
margin: 10px 0 0;
color: #64748b;
font-size: 15px;
font-weight: 700;
}
.hero-realname {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 14px;
border: none;
border-radius: 999px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.hero-realname.is-verified,
.account-card dd.is-verified {
background: #ecfdf3;
color: #16a34a;
}
.hero-realname.is-pending,
.account-card dd.is-pending {
background: #fff7ed;
color: #f97316;
}
.hero-realname.is-rejected,
.account-card dd.is-rejected {
background: #fff1f2;
color: #e11d48;
}
.hero-realname.is-unverified,
.account-card dd.is-unverified {
background: #eff6ff;
color: #2563eb;
}
.profile-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
margin-top: 22px;
}
.edit-panel,
.account-card {
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.edit-panel {
padding: 28px;
}
.panel-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
}
.panel-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 12px;
background: #fff3e8;
color: #ff6a00;
font-size: 24px;
}
.panel-head h2 {
margin: 0;
color: #17233d;
font-size: 22px;
}
.panel-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.avatar-editor {
display: flex;
align-items: center;
gap: 18px;
max-width: 640px;
padding: 20px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.avatar-preview {
position: relative;
width: 82px;
height: 82px;
border: none;
font-size: 30px;
cursor: pointer;
flex-shrink: 0;
}
.avatar-preview i {
position: absolute;
right: 4px;
bottom: 4px;
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 999px;
background: #ffffff;
color: #ff6a00;
box-shadow: 0 4px 12px rgba(23, 35, 61, 0.16);
}
.avatar-actions strong {
display: block;
color: #17233d;
font-size: 20px;
}
.avatar-actions p {
margin: 6px 0 12px;
color: #64748b;
font-size: 14px;
}
.profile-form {
display: grid;
gap: 6px;
max-width: 640px;
margin-top: 24px;
}
.profile-form :deep(.el-form-item__label) {
color: #475569;
font-size: 14px;
font-weight: 800;
}
.profile-form :deep(.el-input__wrapper) {
min-height: 48px;
border-radius: 10px;
box-shadow: 0 0 0 1px #dbe3ee inset;
}
.profile-form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #ff6a00 inset, 0 0 0 4px rgba(255, 106, 0, 0.08);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.form-actions .el-button {
height: 46px;
border-radius: 10px;
font-weight: 900;
}
.form-actions .el-button--primary {
border: none;
background: #ff6a00;
box-shadow: 0 10px 20px rgba(255, 106, 0, 0.18);
}
.account-card {
padding: 24px;
}
.card-kicker {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.account-card dl {
display: grid;
gap: 12px;
margin: 18px 0 0;
}
.account-card dl div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
padding: 0 14px;
border-radius: 10px;
background: #f8fafc;
}
.account-card dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.account-card dd {
margin: 0;
color: #334155;
font-size: 14px;
font-weight: 900;
text-align: right;
}
.realname-shortcut {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 44px;
margin-top: 18px;
padding: 0 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
color: #475569;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.realname-shortcut:hover {
border-color: #ff6a00;
color: #ff6a00;
}
.service-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
margin-top: 22px;
}
.service-panel {
padding: 24px;
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.service-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.service-head h2 {
margin: 0;
color: #17233d;
font-size: 22px;
}
.service-all {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 14px;
font-weight: 900;
text-decoration: none;
}
.service-all:hover {
color: #ff6a00;
}
.service-grid {
display: grid;
gap: 14px;
margin-top: 22px;
}
.buyer-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.seller-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.service-item {
display: grid;
min-width: 0;
min-height: 118px;
place-items: center;
gap: 10px;
padding: 16px 10px;
border: 1px solid #edf2f7;
border-radius: 14px;
background: #fbfdff;
color: #1f2937;
text-align: center;
text-decoration: none;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.service-item:hover {
border-color: rgba(255, 106, 0, 0.35);
box-shadow: 0 12px 24px rgba(23, 35, 61, 0.08);
transform: translateY(-2px);
}
.service-item strong {
color: #334155;
font-size: 15px;
font-weight: 900;
line-height: 1.35;
}
.service-icon {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border-radius: 14px;
font-size: 28px;
}
.service-icon.is-warning {
background: #fff7ed;
color: #f59e0b;
}
.service-icon.is-info {
background: #eff6ff;
color: #2563eb;
}
.service-icon.is-primary {
background: #eef2ff;
color: #4f46e5;
}
.service-icon.is-success {
background: #ecfdf3;
color: #10b981;
}
.service-icon.is-orange {
background: #fff1e8;
color: #ff6a00;
}
.service-icon.is-purple {
background: #f3e8ff;
color: #8b5cf6;
}
.service-icon.is-teal {
background: #e8f7f5;
color: #0f766e;
}
@media (max-width: 1100px) {
.profile-layout {
grid-template-columns: 1fr;
}
.service-panels {
grid-template-columns: 1fr;
}
}
</style>
-584
View File
@@ -1,584 +0,0 @@
<script setup lang="ts">
import {
CircleCheckFilled,
CreditCard,
DocumentChecked,
Lock,
Postcard,
RefreshRight,
User,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getRealnameStatus, startRealname, type RealnameStatus } from '@/api/realname'
import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const session = useSessionStore()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const status = ref<RealnameStatus | null>(null)
const form = reactive({
name: '',
idNo: '',
})
const currentStatus = computed(() => status.value?.status || 'unverified')
const statusMeta = computed(() => {
switch (currentStatus.value) {
case 'verified':
return {
icon: CircleCheckFilled,
tone: 'verified',
title: '认证已完成',
summary: '可发布账号并参与需要实名的交易流程。',
}
case 'pending':
return {
icon: DocumentChecked,
tone: 'pending',
title: '认证处理中',
summary: '认证结果返回后会自动同步到账号状态。',
}
case 'rejected':
return {
icon: WarningFilled,
tone: 'rejected',
title: '认证未通过',
summary: status.value?.fail_reason || '请核对姓名与身份证号后重新提交。',
}
default:
return {
icon: Postcard,
tone: 'unverified',
title: '等待认证',
summary: '提交真实姓名与身份证号后即可完成核验。',
}
}
})
const canSubmit = computed(() => !!session.token && currentStatus.value !== 'verified')
onMounted(loadStatus)
function redirectAfterVerified() {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect) {
router.replace(redirect)
}
}
async function loadStatus() {
if (!session.token) return
try {
status.value = await getRealnameStatus()
if (status.value.status === 'verified') {
await session.loadMe()
redirectAfterVerified()
}
} catch {
status.value = null
}
}
async function handleSubmit() {
if (!form.name.trim()) {
ElMessage.warning('请输入真实姓名')
return
}
if (!form.idNo.trim() || form.idNo.length < 15) {
ElMessage.warning('请输入有效的证件号')
return
}
loading.value = true
try {
status.value = await startRealname(form.name, form.idNo)
await session.loadMe()
ElMessage.success('实名认证已通过')
redirectAfterVerified()
} catch (error) {
ElMessage.error(readError(error, '实名认证失败'))
} finally {
loading.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="realname-page">
<div class="realname-hero">
<div class="hero-copy">
<span class="eyebrow">Realname</span>
<h1>实名认证</h1>
<p>完成实名后可发布账号进入交易流程并提升账号可信度</p>
</div>
<div class="hero-status" :class="`is-${statusMeta.tone}`">
<el-icon><component :is="statusMeta.icon" /></el-icon>
<div>
<span>当前状态</span>
<strong>{{ realnameStatusLabel(currentStatus) }}</strong>
</div>
</div>
</div>
<el-alert
v-if="!session.token"
class="login-alert"
type="warning"
:closable="false"
title="请先登录后再实名认证"
/>
<div class="realname-layout">
<section class="verify-panel">
<div class="panel-head">
<div class="panel-icon">
<el-icon><CreditCard /></el-icon>
</div>
<div>
<h2>{{ currentStatus === 'verified' ? '实名信息' : '提交认证' }}</h2>
<p>{{ currentStatus === 'verified' ? '实名信息已完成脱敏展示。' : '请使用本人真实身份信息完成核验。' }}</p>
</div>
</div>
<div v-if="currentStatus === 'verified'" class="verified-summary">
<div class="verified-mark">
<el-icon><CircleCheckFilled /></el-icon>
</div>
<div class="verified-copy">
<strong>认证通过</strong>
<span>{{ status?.verified_at ? formatDateTime(status.verified_at) : '已完成实名核验' }}</span>
</div>
</div>
<el-form v-else class="verify-form" label-position="top">
<el-form-item label="姓名">
<el-input v-model="form.name" :prefix-icon="User" placeholder="请输入真实姓名" size="large" />
</el-form-item>
<el-form-item label="身份证号">
<el-input
v-model="form.idNo"
:prefix-icon="Postcard"
maxlength="18"
placeholder="请输入 18 位身份证号"
size="large"
/>
</el-form-item>
<el-button
type="primary"
size="large"
:icon="DocumentChecked"
:disabled="!canSubmit"
:loading="loading"
@click="handleSubmit"
>
提交认证
</el-button>
</el-form>
</section>
<aside class="status-card" :class="`is-${statusMeta.tone}`">
<div class="status-visual">
<el-icon><component :is="statusMeta.icon" /></el-icon>
</div>
<div class="status-content">
<span class="status-kicker">认证状态</span>
<h2>{{ statusMeta.title }}</h2>
<p>{{ statusMeta.summary }}</p>
</div>
<dl class="status-details">
<div>
<dt>姓名</dt>
<dd>{{ status?.masked_name || '待提交' }}</dd>
</div>
<div>
<dt>证件号</dt>
<dd>{{ status?.masked_id_no || '待提交' }}</dd>
</div>
<div>
<dt>通过时间</dt>
<dd>{{ status?.verified_at ? formatDateTime(status.verified_at) : '暂无' }}</dd>
</div>
</dl>
<div class="security-note">
<el-icon><Lock /></el-icon>
<span>身份信息仅用于实名核验页面只展示脱敏结果</span>
</div>
</aside>
</div>
<div class="realname-actions">
<RouterLink class="secondary-action" to="/seller/listings/create">
<el-icon><RefreshRight /></el-icon>
<span>返回发布账号</span>
</RouterLink>
</div>
</section>
</template>
<style scoped>
.realname-page {
width: min(1180px, 100%);
margin: 0 auto;
padding: 10px 0 40px;
}
.realname-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
min-height: 156px;
padding: 34px 36px;
border: 1px solid #eef1f5;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 106, 0, 0.12) 0%, rgba(22, 163, 74, 0.08) 52%, rgba(37, 99, 235, 0.08) 100%),
#ffffff;
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
}
.hero-copy {
max-width: 640px;
}
.eyebrow {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #ff6a00;
font-size: 13px;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 0;
color: #17233d;
font-size: 36px;
line-height: 1.2;
}
.hero-copy p {
margin: 14px 0 0;
color: #64748b;
font-size: 16px;
font-weight: 600;
line-height: 1.7;
}
.hero-status {
display: flex;
align-items: center;
gap: 14px;
min-width: 210px;
padding: 18px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 12px 28px rgba(23, 35, 61, 0.08);
}
.hero-status .el-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
font-size: 24px;
}
.hero-status span,
.status-kicker {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.hero-status strong {
display: block;
margin-top: 4px;
color: #17233d;
font-size: 22px;
line-height: 1.2;
}
.hero-status.is-verified .el-icon,
.status-card.is-verified .status-visual {
background: #ecfdf3;
color: #16a34a;
}
.hero-status.is-pending .el-icon,
.status-card.is-pending .status-visual {
background: #fff7ed;
color: #f97316;
}
.hero-status.is-rejected .el-icon,
.status-card.is-rejected .status-visual {
background: #fff1f2;
color: #e11d48;
}
.hero-status.is-unverified .el-icon,
.status-card.is-unverified .status-visual {
background: #eff6ff;
color: #2563eb;
}
.login-alert {
margin-top: 18px;
}
.realname-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 22px;
margin-top: 22px;
}
.verify-panel,
.status-card {
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.verify-panel {
min-height: 430px;
padding: 28px;
}
.panel-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.panel-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 12px;
background: #fff3e8;
color: #ff6a00;
font-size: 24px;
}
.panel-head h2,
.status-content h2 {
margin: 0;
color: #17233d;
font-size: 22px;
line-height: 1.3;
}
.panel-head p,
.status-content p {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.verify-form {
display: grid;
gap: 6px;
max-width: 620px;
}
.verify-form :deep(.el-form-item__label) {
color: #475569;
font-size: 14px;
font-weight: 800;
}
.verify-form :deep(.el-input__wrapper) {
min-height: 48px;
border-radius: 10px;
box-shadow: 0 0 0 1px #dbe3ee inset;
}
.verify-form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #ff6a00 inset, 0 0 0 4px rgba(255, 106, 0, 0.08);
}
.verify-form .el-button {
width: 168px;
height: 46px;
margin-top: 8px;
border: none;
border-radius: 10px;
background: #ff6a00;
font-weight: 900;
box-shadow: 0 10px 20px rgba(255, 106, 0, 0.18);
}
.verified-summary {
display: flex;
align-items: center;
gap: 18px;
max-width: 620px;
padding: 24px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.verified-mark {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border-radius: 16px;
background: #ecfdf3;
color: #16a34a;
font-size: 34px;
}
.verified-copy strong {
display: block;
color: #17233d;
font-size: 22px;
}
.verified-copy span {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 14px;
font-weight: 700;
}
.status-card {
padding: 24px;
}
.status-visual {
display: grid;
width: 56px;
height: 56px;
place-items: center;
border-radius: 16px;
font-size: 30px;
}
.status-content {
margin-top: 18px;
}
.status-details {
display: grid;
gap: 12px;
margin: 24px 0 0;
}
.status-details div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 0 14px;
border-radius: 10px;
background: #f8fafc;
}
.status-details dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.status-details dd {
margin: 0;
color: #334155;
font-size: 14px;
font-weight: 800;
text-align: right;
}
.security-note {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 22px;
padding: 14px;
border-radius: 12px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
line-height: 1.6;
}
.security-note .el-icon {
margin-top: 2px;
color: #2563eb;
font-size: 18px;
flex-shrink: 0;
}
.realname-actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
.secondary-action {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border-radius: 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 14px;
font-weight: 800;
text-decoration: none;
transition: all 0.2s;
}
.secondary-action:hover {
border-color: #ff6a00;
color: #ff6a00;
box-shadow: 0 8px 18px rgba(255, 106, 0, 0.08);
}
@media (max-width: 1100px) {
.realname-layout {
grid-template-columns: 1fr;
}
.realname-hero {
align-items: flex-start;
flex-direction: column;
}
.hero-status {
width: 100%;
}
}
</style>
-691
View File
@@ -1,691 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheck, Lock, Money, Refresh, Tickets, Wallet as WalletIcon } from '@element-plus/icons-vue'
import {
fetchWalletBalance,
fetchWalletLedger,
type WalletAccount,
type WalletLedger,
} from '@/api/wallet'
import { balanceTypeLabel, ledgerDirectionLabel, walletStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const account = ref<WalletAccount | null>(null)
const ledger = ref<WalletLedger[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const walletMetrics = computed(() => {
if (!account.value) {
return []
}
return [
{
label: '可用余额',
value: formatMoney(account.value.available_balance),
hint: '卖家结算收入累计到此账户',
icon: WalletIcon,
tone: 'available',
},
{
label: '冻结余额',
value: formatMoney(account.value.frozen_balance),
hint: '当前暂无冻结资金使用',
icon: Lock,
tone: 'frozen',
},
{
label: '账户状态',
value: walletStatusLabel(account.value.status),
hint: account.value.status === 'active' ? '钱包可正常使用' : '请联系客服处理',
icon: CircleCheck,
tone: account.value.status === 'active' ? 'status' : 'warning',
},
]
})
onMounted(loadWallet)
async function loadWallet() {
loading.value = true
try {
const [balance, result] = await Promise.all([fetchWalletBalance(), fetchWalletLedger(currentPage.value, currentPageSize.value)])
account.value = balance
ledger.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadWallet()
}
function loadLedgerPage() {
loadWallet()
}
function handleWithdraw() {
ElMessage.info('提现功能待实现')
}
function formatMoney(value: number) {
return `¥${Number(value || 0).toFixed(2)}`
}
function walletBizTypeLabel(type: string) {
const map: Record<string, string> = {
dev_recharge: '测试充值',
channel_recharge: '渠道充值',
order_pay: '订单支付',
order_lock: '订单冻结',
channel_order_lock: '支付冻结',
order_cancel: '取消解冻',
order_cancel_refund: '取消退款',
admin_order_close: '客服关闭解冻',
admin_order_close_refund: '客服关闭退款',
order_settle: '订单结算',
owner_income: '号主收入',
deposit_compensation: '押金赔付',
rent_refund: '租金退款',
deposit_release: '押金释放',
arbitration_release_frozen: '仲裁解冻',
arbitration_renter_refund: '仲裁退款',
arbitration_owner_income: '仲裁收入',
cancel_refund: '取消退款',
checkout_refund: '结账退款',
channel_deposit_refund: '押金退还',
withdraw_apply: '申请提现',
}
return map[type] || type || '-'
}
function directionTone(direction: string) {
const map: Record<string, string> = {
in: 'success',
out: 'danger',
freeze: 'warning',
unfreeze: 'info',
}
return map[direction] || 'info'
}
function amountPrefix(direction: string) {
if (direction === 'in' || direction === 'unfreeze') return '+'
if (direction === 'out' || direction === 'freeze') return '-'
return ''
}
</script>
<template>
<section class="page wallet-page" v-loading="loading">
<div class="wallet-hero">
<div class="page-header">
<p class="eyebrow">我的钱包</p>
<h1>资金账户</h1>
<p>查看卖家结算收入可提现余额和每一笔资金变化</p>
</div>
<div class="wallet-hero-action">
<span>当前可用</span>
<strong>{{ account ? formatMoney(account.available_balance) : '¥0.00' }}</strong>
<el-button class="withdraw-button" :icon="Money" disabled @click="handleWithdraw">
申请提现
<el-tag size="small" type="info" effect="plain" class="withdraw-tag">待开发</el-tag>
</el-button>
</div>
</div>
<div v-if="account" class="wallet-metric-grid">
<div v-for="item in walletMetrics" :key="item.label" class="wallet-metric-card" :class="`is-${item.tone}`">
<div class="metric-icon">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small>
</div>
</div>
</div>
<div class="wallet-workspace">
<section class="ledger-summary-card">
<div class="panel-title">
<div class="panel-title-icon is-blue">
<el-icon><Tickets /></el-icon>
</div>
<div>
<h2>资金流水</h2>
<p> {{ total }} 条记录最近变动优先展示</p>
</div>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadWallet">刷新</el-button>
</section>
</div>
<section class="wallet-ledger-table" role="table" aria-label="资金流水">
<div class="ledger-grid ledger-header" role="row">
<span role="columnheader">流水号</span>
<span role="columnheader">业务</span>
<span role="columnheader">方向</span>
<span class="align-right" role="columnheader">金额</span>
<span role="columnheader">余额类型</span>
<span class="align-right" role="columnheader">变化后余额</span>
<span role="columnheader">备注</span>
<span role="columnheader">时间</span>
</div>
<div v-if="ledger.length === 0" class="ledger-empty">暂无资金流水</div>
<div v-else class="ledger-body">
<div v-for="row in ledger" :key="row.id" class="ledger-grid ledger-row" role="row">
<span class="ledger-cell ledger-no" :title="row.ledger_no">{{ row.ledger_no }}</span>
<span class="ledger-cell">
<el-tag effect="plain" class="biz-tag">{{ walletBizTypeLabel(row.biz_type) }}</el-tag>
</span>
<span class="ledger-cell">
<el-tag :type="directionTone(row.direction)" effect="light" round>
{{ ledgerDirectionLabel(row.direction) }}
</el-tag>
</span>
<span class="ledger-cell align-right amount-cell" :class="`is-${row.direction}`">
{{ amountPrefix(row.direction) }}{{ formatMoney(row.amount) }}
</span>
<span class="ledger-cell muted-cell">{{ balanceTypeLabel(row.balance_type) }}</span>
<span class="ledger-cell align-right">{{ formatMoney(row.balance_after) }}</span>
<span class="ledger-cell" :title="row.remark">{{ row.remark || '-' }}</span>
<span class="ledger-cell">{{ formatDateTime(row.created_at) }}</span>
</div>
</div>
</section>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLedgerPage"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.wallet-page {
display: grid;
gap: 18px;
}
.wallet-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 122, 0, 0.08), rgba(15, 118, 110, 0.06)),
#ffffff;
box-shadow: 0 14px 36px rgba(17, 24, 39, 0.06);
}
.wallet-hero :deep(.page-header) {
max-width: 780px;
}
.wallet-hero-action {
min-width: 220px;
padding: 16px 18px;
border: 1px solid rgba(255, 122, 0, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
text-align: right;
}
.wallet-hero-action span {
display: block;
color: #6b7280;
font-size: 13px;
}
.wallet-hero-action strong {
display: block;
margin-top: 6px;
color: #111a44;
font-size: 28px;
line-height: 1.15;
}
.withdraw-button {
width: 100%;
margin-top: 14px;
}
.withdraw-tag {
margin-left: 8px;
vertical-align: middle;
}
.wallet-metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.wallet-metric-card {
display: flex;
align-items: center;
gap: 16px;
min-height: 120px;
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.metric-icon {
display: grid;
flex: 0 0 46px;
width: 46px;
height: 46px;
place-items: center;
border-radius: 8px;
color: #ffffff;
font-size: 22px;
}
.wallet-metric-card.is-available .metric-icon {
background: #ff6b00;
}
.wallet-metric-card.is-frozen .metric-icon {
background: #3b82f6;
}
.wallet-metric-card.is-status .metric-icon {
background: #0f766e;
}
.wallet-metric-card.is-warning .metric-icon {
background: #d97706;
}
.wallet-metric-card span,
.wallet-metric-card small {
display: block;
color: #64748b;
}
.wallet-metric-card span {
font-size: 13px;
font-weight: 600;
}
.wallet-metric-card strong {
display: block;
margin-top: 8px;
color: #111a44;
font-size: 26px;
line-height: 1.1;
}
.wallet-metric-card small {
margin-top: 8px;
font-size: 12px;
}
.wallet-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.recharge-panel,
.ledger-summary-card {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.ledger-summary-card {
align-content: space-between;
}
.ledger-summary-card :deep(.el-button) {
justify-self: start;
min-width: 118px;
}
.panel-title {
display: flex;
align-items: center;
gap: 12px;
}
.panel-title-icon {
display: grid;
flex: 0 0 40px;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
background: #fff4ec;
color: #ff6b00;
font-size: 20px;
}
.panel-title-icon.is-blue {
background: #eff6ff;
color: #2563eb;
}
.panel-title h2 {
margin: 0;
color: #111827;
font-size: 17px;
}
.panel-title p {
margin: 5px 0 0;
color: #64748b;
font-size: 13px;
}
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.quick-amounts button {
min-width: 92px;
height: 36px;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-weight: 600;
cursor: pointer;
}
.quick-amounts button.active,
.quick-amounts button:hover {
border-color: #ff8a3d;
background: #fff4ec;
color: #ea580c;
}
.recharge-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.wallet-ledger-table {
overflow-x: auto;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.ledger-grid {
display: grid;
grid-template-columns:
minmax(200px, 2fr)
minmax(100px, 0.8fr)
minmax(80px, 0.6fr)
minmax(100px, 0.8fr)
minmax(100px, 0.8fr)
minmax(120px, 0.9fr)
minmax(140px, 1.2fr)
minmax(170px, 1.1fr);
align-items: center;
column-gap: clamp(10px, 1vw, 20px);
padding: 0 clamp(16px, 1.5vw, 24px);
}
.ledger-header {
min-height: 48px;
border-bottom: 1px solid #e6eaf2;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.ledger-header span {
text-align: center;
}
.ledger-header span.align-right {
text-align: right;
}
.ledger-row {
min-height: 54px;
border-bottom: 1px solid #edf1f6;
color: #334155;
font-size: 14px;
transition: background 0.15s ease;
}
.ledger-row:last-child {
border-bottom: 0;
}
.ledger-row:hover {
background: #f8fafc;
}
.ledger-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.ledger-cell.align-right {
text-align: right;
}
.ledger-no {
color: #475569;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 12px;
letter-spacing: 0.02em;
text-align: center;
}
.align-right {
text-align: right;
}
.ledger-empty {
display: grid;
min-height: 80px;
place-items: center;
border-top: 1px solid #edf1f6;
color: #94a3b8;
font-size: 14px;
}
.biz-tag {
max-width: 96px;
height: 24px;
line-height: 22px;
}
.amount-cell {
font-weight: 700;
}
.amount-cell.is-in,
.amount-cell.is-unfreeze {
color: #047857;
}
.amount-cell.is-out,
.amount-cell.is-freeze {
color: #dc2626;
}
.muted-cell {
color: #64748b;
}
.pay-dialog-body {
display: grid;
gap: 14px;
}
.pay-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f7f9fc;
color: #64748b;
}
.pay-summary strong {
color: #111a44;
font-size: 22px;
}
.pay-qr-panel {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
align-items: center;
gap: 20px;
padding: 18px;
border-radius: 8px;
background: #f8fafc;
}
.pay-qr-box {
width: 240px;
height: 240px;
display: grid;
place-items: center;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
}
.pay-qr-box img {
width: 220px;
height: 220px;
display: block;
}
.pay-scan-copy {
display: grid;
gap: 8px;
color: #334155;
}
.pay-scan-copy strong {
color: #111a44;
font-size: 18px;
}
.pay-scan-copy span {
color: #64748b;
line-height: 1.7;
}
.pay-hint {
margin: 0;
color: #64748b;
}
.pay-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
:global(.wallet-pay-dialog) {
position: relative;
z-index: 4001;
}
@media (max-width: 720px) {
.wallet-hero {
display: grid;
padding: 20px;
}
.wallet-hero-action {
min-width: 0;
text-align: left;
}
.wallet-metric-grid,
.wallet-workspace {
grid-template-columns: 1fr;
}
.wallet-metric-card {
min-height: auto;
}
.recharge-action-row :deep(.el-input-number) {
width: 100%;
}
.recharge-action-row :deep(.el-button) {
width: 100%;
}
.pay-qr-panel {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.ledger-grid {
grid-template-columns:
minmax(160px, 1.5fr)
minmax(80px, 0.8fr)
minmax(64px, 0.6fr)
minmax(80px, 0.8fr)
minmax(80px, 0.8fr)
minmax(100px, 0.9fr)
minmax(120px, 1fr)
minmax(140px, 1fr);
column-gap: 8px;
padding: 0 12px;
font-size: 13px;
}
.ledger-header {
min-height: 40px;
font-size: 12px;
}
.ledger-row {
min-height: 48px;
font-size: 13px;
}
}
</style>
@@ -1,170 +0,0 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminAuditLogs, type AdminAuditLog } from '@/api/adminAudit'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const logs = ref<AdminAuditLog[]>([])
const activeLog = ref<AdminAuditLog | null>(null)
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
actor_id: '',
action: '',
biz_type: '',
})
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
onMounted(loadLogs)
async function loadLogs() {
loading.value = true
try {
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminAuditLogs(query)
logs.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.actor_id = ''
filters.action = ''
filters.biz_type = ''
currentPage.value = 1
void loadLogs()
}
function handleSizeChange() {
currentPage.value = 1
loadLogs()
}
function actorName(row: AdminAuditLog) {
return row.actor_nickname || row.actor_username || `${row.actor_type} ${row.actor_id}`
}
function detailText(row: AdminAuditLog | null) {
if (!row?.detail) return '{}'
return JSON.stringify(row.detail, null, 2)
}
function actionType(action: string) {
if (action.includes('freeze')) return 'danger'
if (action.includes('update') || action.includes('create')) return 'warning'
return 'info'
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Audit Logs</p>
<h1>审计日志</h1>
<p>查看后台管理员操作业务对象和操作明细用于追踪冻结配置修改等高风险动作</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs">查询</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>高风险动作</span>
<strong>{{ highRiskCount }} </strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="管理员 ID">
<el-input v-model="filters.actor_id" clearable placeholder="按管理员筛选" />
</el-form-item>
<el-form-item label="动作">
<el-select v-model="filters.action" clearable filterable placeholder="全部动作" class="full-control">
<el-option label="冻结用户" value="admin_user.freeze" />
<el-option label="解冻用户" value="admin_user.unfreeze" />
<el-option label="后台下架商品" value="listing.admin_offline" />
<el-option label="商品标记异常" value="listing.mark_abnormal" />
<el-option label="客服关闭订单" value="order.admin_close" />
<el-option label="订单标记异常" value="order.mark_abnormal" />
<el-option label="申诉仲裁" value="dispute.arbitrate" />
<el-option label="更新系统配置" value="system_config.update" />
</el-select>
</el-form-item>
<el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
<el-option label="用户" value="user" />
<el-option label="商品" value="listing" />
<el-option label="订单" value="order" />
<el-option label="申诉" value="dispute" />
<el-option label="系统配置" value="system_config" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="logs">
<el-table-column label="操作人" min-width="160">
<template #default="{ row }">
<strong>{{ actorName(row) }}</strong>
</template>
</el-table-column>
<el-table-column label="动作" min-width="180">
<template #default="{ row }">
<el-tag :type="actionType(row.action)">{{ row.action }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="130" />
<el-table-column prop="biz_id" label="业务 ID" width="100" />
<el-table-column label="时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="activeLog = row">明细</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLogs"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
<div v-if="activeLog" class="dialog-body">
<p><strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}</p>
<p>操作人{{ actorName(activeLog) }} · IP{{ activeLog.ip }}</p>
<p>User-Agent{{ activeLog.user_agent }}</p>
<div class="code-panel">
<pre>{{ detailText(activeLog) }}</pre>
</div>
</div>
<template #footer>
<el-button type="primary" @click="activeLog = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
-751
View File
@@ -1,751 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Picture } from '@element-plus/icons-vue'
import {
fetchAdminChat,
fetchAdminChatMessages,
fetchAdminChats,
fetchQuickReplies,
markAdminChatRead,
sendAdminChatMessage,
updateChatRemark,
type ChatConversation,
type ChatMessage,
type QuickReply,
} from '@/api/chats'
import { uploadAdminFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
import TransferDialog from './components/TransferDialog.vue'
import QuickReplyDialog from './components/QuickReplyDialog.vue'
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
const conversations = ref<ChatConversation[]>([])
const active = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const messageLoading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
const transferVisible = ref(false)
const quickReplyVisible = ref(false)
const quickReplies = ref<QuickReply[]>([])
const remarkEditing = ref(false)
const remarkValue = ref('')
const activeMembers = computed(() => {
const participants = active.value?.participants || []
return participants.map(item => {
const remark = getParticipantRemark(item)
const name = remark ? `${remark}(${item.display_name})` : item.display_name
return `${roleLabel(item.role)}${name}`
}).join(' / ')
})
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
function getParticipantRemark(participant: any) {
if (!active.value) return ''
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || ''
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'conversation_updated') {
loadConversations(false)
}
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'admin' && msg.sender_id === currentAdminId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
}
}
}
const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await Promise.all([loadConversations(), loadQuickReplies()])
})
async function loadConversations(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await fetchAdminChats(1, 100, filter.value)
conversations.value = res.items
const first = conversations.value[0]
if (!active.value && first) {
await openConversation(first)
}
} catch {
ElMessage.error('会话加载失败')
} finally {
loading.value = false
}
}
async function loadQuickReplies() {
try {
quickReplies.value = await fetchQuickReplies()
} catch { /* ignore */ }
}
async function openConversation(item: ChatConversation) {
messageLoading.value = true
try {
active.value = await fetchAdminChat(item.id)
await loadMessages(item.id)
await markAdminChatRead(item.id)
await loadConversations(false)
remarkEditing.value = false
remarkValue.value = ''
attachments.value = []
} catch {
ElMessage.error('会话详情加载失败')
} finally {
messageLoading.value = false
}
}
async function loadMessages(id: number, scroll = true) {
const res = await fetchAdminChatMessages(id, 1, 100)
messages.value = res.items
if (scroll) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
const imageUrls = [...attachments.value]
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversations(false)
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadAdminFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function handleQuickReplySelect(reply: QuickReply) {
content.value = reply.content
quickReplyVisible.value = false
}
function handleFilterChange(val: string) {
filter.value = val as typeof filter.value
active.value = null
messages.value = []
loadConversations()
}
function handleTransferSuccess() {
loadConversations(false)
if (active.value) {
loadMessages(active.value.id, false)
}
}
async function startEditRemark() {
if (!active.value) return
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
remarkValue.value = myParticipant?.remark || active.value.title
remarkEditing.value = true
}
async function saveRemark() {
if (!active.value) return
try {
await updateChatRemark(active.value.id, remarkValue.value)
ElMessage.success('备注已更新')
remarkEditing.value = false
await fetchAdminChat(active.value.id).then(chat => {
active.value = chat
})
await loadConversations(false)
} catch {
ElMessage.error('更新备注失败')
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(item: ChatMessage) {
if (item.sender_type === 'system') return '系统'
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
}
function getConversationTitle(item: ChatConversation) {
const myParticipant = item.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || item.title
}
function getSupportName(item: ChatConversation) {
const support = item.participants?.find(p => p.role === 'support')
return support?.display_name || '未分配'
}
</script>
<template>
<section class="admin-page">
<div class="page-head">
<div>
<h1>客服会话</h1>
<p>处理订单三方沟通和平台咨询</p>
</div>
<div class="head-right">
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
</div>
</div>
<div class="chat-workbench">
<aside class="conversation-pane" v-loading="loading">
<div class="filter-tabs">
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
<el-radio-button value="mine">我的会话</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="unassigned">未分配</el-radio-button>
</el-radio-group>
</div>
<button
v-for="item in conversations"
:key="item.id"
type="button"
class="conversation-row"
:class="{ active: active?.id === item.id }"
@click="openConversation(item)"
>
<div class="row-title">
<strong>{{ getConversationTitle(item) }}</strong>
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
<div class="row-meta">
<span class="support-name">{{ getSupportName(item) }}</span>
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
</div>
</button>
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
</aside>
<main class="message-pane">
<template v-if="active">
<header class="message-head">
<div class="head-title">
<template v-if="remarkEditing">
<el-input
v-model="remarkValue"
size="small"
style="width: 200px"
placeholder="输入备注"
@keyup.enter="saveRemark"
/>
<el-button size="small" type="primary" @click="saveRemark">保存</el-button>
<el-button size="small" @click="remarkEditing = false">取消</el-button>
</template>
<template v-else>
<h2>{{ getConversationTitle(active) }} <el-button link size="small" @click="startEditRemark">编辑备注</el-button></h2>
<p>{{ activeMembers }}</p>
</template>
</div>
<div class="head-actions">
<el-button size="small" @click="transferVisible = true">转接</el-button>
<RouterLink v-if="active.order_id" :to="`/admin/orders/${active.order_id}`">
<el-button size="small">查看订单</el-button>
</RouterLink>
</div>
</header>
<div ref="listRef" class="message-list" v-loading="messageLoading">
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span>{{ item.content }}</span>
</template>
<template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
<p v-if="item.content">{{ item.content }}</p>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
admin
/>
</div>
</template>
</div>
</div>
<footer class="composer">
<div class="composer-tools">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-dropdown trigger="click" @command="handleQuickReplySelect">
<el-button size="small" text>快捷回复</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="reply in quickReplies"
:key="reply.id"
:command="reply"
>
<span class="reply-title">{{ reply.title }}</span>
<span class="reply-preview">{{ reply.content.slice(0, 30) }}{{ reply.content.length > 30 ? '...' : '' }}</span>
</el-dropdown-item>
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
暂无快捷回复
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
</div>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" admin />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<div class="composer-input">
<el-input
v-model="content"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="输入客服回复"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
</div>
</footer>
</template>
<el-empty v-else description="请选择会话" />
</main>
</div>
<TransferDialog
v-if="active"
v-model="transferVisible"
:conversation-id="active.id"
@success="handleTransferSuccess"
/>
<QuickReplyDialog
v-model="quickReplyVisible"
@success="loadQuickReplies"
/>
</section>
</template>
<style scoped>
.admin-page {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.page-head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 {
margin: 0;
font-size: 22px;
}
.page-head p {
margin: 6px 0 0;
color: #6b7280;
}
.head-right {
display: flex;
gap: 8px;
}
.chat-workbench {
display: grid;
min-height: 0;
grid-template-columns: 330px minmax(0, 1fr);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.conversation-pane {
min-height: 0;
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #f8fafc;
}
.filter-tabs {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
.conversation-row {
position: relative;
display: block;
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #e5e7eb;
background: transparent;
text-align: left;
}
.conversation-row.active {
background: #eef6ff;
}
.row-title {
display: flex;
gap: 10px;
align-items: center;
}
.row-title strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title span {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-row p {
margin: 8px 24px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.support-name {
color: #8a94a6;
font-size: 12px;
}
.row-meta em {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-pane {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.message-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.head-title {
flex: 1;
min-width: 0;
}
.head-title h2 {
margin: 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.head-title p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.head-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.head-actions a {
text-decoration: none;
}
.message-list {
min-height: 0;
overflow-y: auto;
padding: 18px;
background: #f3f6fa;
}
.message-row {
max-width: 70%;
margin-bottom: 14px;
}
.message-row.self {
margin-left: auto;
text-align: right;
}
.message-row.system {
max-width: none;
text-align: center;
}
.message-row small {
display: block;
margin-bottom: 5px;
color: #8a94a6;
}
.message-row p {
display: inline-block;
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
color: #111827;
line-height: 1.5;
text-align: left;
}
.message-row.self p {
background: #dff5eb;
}
.message-attachments {
display: grid;
justify-items: start;
gap: 6px;
}
.message-row.self .message-attachments {
justify-items: end;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
border-radius: 8px;
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
}
.composer {
border-top: 1px solid #e5e7eb;
}
.composer-tools {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
gap: 8px;
padding: 10px 14px 0;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 84px;
height: 84px;
}
.pending-item :deep(.chat-image-button img) {
height: 84px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.composer-input {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
align-items: end;
padding: 14px;
}
.reply-title {
font-weight: 500;
margin-right: 8px;
}
.reply-preview {
color: #9ca3af;
font-size: 12px;
}
</style>
@@ -1,494 +0,0 @@
<script setup lang="ts">
import {
Bell,
ChatDotRound,
Document,
Grid,
Loading,
Operation,
Refresh,
ScaleToOriginal,
ShoppingBag,
Shop,
Tickets,
User,
Wallet,
Warning
} from '@element-plus/icons-vue'
import { fetchAdminDashboard, type AdminDashboard } from '@/api/adminDashboard'
import { useAdminTable } from '@/composables/useAdminTable'
import { useMoney } from '@/composables/useMoney'
import { disputeStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const money = useMoney()
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({
fetchFn: fetchAdminDashboard,
})
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Dashboard</p>
<h1>后台仪表盘</h1>
<p>实时监控平台运营数据快速处理待办事项</p>
</div>
<el-button @click="loadDashboard" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新数据
</el-button>
</div>
<!-- 加载状态 -->
<div v-if="loading && !dashboard" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error && !dashboard" class="error-state">
<el-icon><Warning /></el-icon>
<span>{{ error }}</span>
<el-button @click="loadDashboard" type="primary">重试</el-button>
</div>
<!-- 核心指标卡片 -->
<div v-if="dashboard" class="metric-grid dashboard-metrics">
<div class="metric-card metric-card--primary">
<div class="metric-icon">
<el-icon><User /></el-icon>
</div>
<div class="metric-content">
<span>用户总数</span>
<strong>{{ dashboard.metrics.total_users }}</strong>
<small>实名 {{ dashboard.metrics.verified_users }} </small>
</div>
</div>
<div class="metric-card metric-card--success">
<div class="metric-icon">
<el-icon><ShoppingBag /></el-icon>
</div>
<div class="metric-content">
<span>发布商品</span>
<strong>{{ dashboard.metrics.total_listings }}</strong>
<small>已上架 {{ dashboard.metrics.published_listings }} </small>
</div>
</div>
<div class="metric-card metric-card--warning">
<div class="metric-icon">
<el-icon><Document /></el-icon>
</div>
<div class="metric-content">
<span>订单总数</span>
<strong>{{ dashboard.metrics.total_orders }}</strong>
<small>使用中 {{ dashboard.metrics.renting_orders }} </small>
</div>
</div>
<div class="metric-card metric-card--danger">
<div class="metric-icon">
<el-icon><Wallet /></el-icon>
</div>
<div class="metric-content">
<span>今日流水</span>
<strong>{{ money(dashboard.metrics.today_ledger_amount) }}</strong>
<small>今日订单 {{ dashboard.metrics.today_orders }} </small>
</div>
</div>
</div>
<!-- 待处理事项 & 快捷入口 -->
<div v-if="dashboard" class="dashboard-panels">
<div class="dashboard-panel">
<div class="panel-header">
<h2>
<el-icon><Bell /></el-icon>
待处理事项
</h2>
<el-tag type="warning" effect="dark" round>
{{ dashboard.pending.disputes + dashboard.pending.listing_reviews + dashboard.pending.pending_handoffs + dashboard.pending.pending_return_confirms }}
</el-tag>
</div>
<div class="pending-list">
<RouterLink class="pending-row" to="/admin/disputes">
<div class="pending-info">
<span class="pending-label">待仲裁申诉</span>
<span class="pending-desc">需要处理的用户争议</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{ dashboard.pending.disputes }}</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/listings/review">
<div class="pending-info">
<span class="pending-label">待审核商品</span>
<span class="pending-desc">新提交的商品审核</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.listing_reviews > 0 }">{{ dashboard.pending.listing_reviews }}</strong>
</RouterLink>
<div class="pending-row">
<div class="pending-info">
<span class="pending-label">待交接订单</span>
<span class="pending-desc">等待卖家交接</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.pending_handoffs > 0 }">{{ dashboard.pending.pending_handoffs }}</strong>
</div>
<div class="pending-row">
<div class="pending-info">
<span class="pending-label">待结账确认</span>
<span class="pending-desc">等待双方确认</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{ dashboard.pending.pending_return_confirms }}</strong>
</div>
</div>
</div>
<div class="dashboard-panel">
<div class="panel-header">
<h2>
<el-icon><Grid /></el-icon>
快捷入口
</h2>
</div>
<div class="quick-links">
<RouterLink class="quick-link" to="/admin/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/orders">
<el-icon><Tickets /></el-icon>
<span>订单管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/disputes">
<el-icon><ScaleToOriginal /></el-icon>
<span>仲裁中心</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/listings">
<el-icon><Shop /></el-icon>
<span>商品管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/chats">
<el-icon><ChatDotRound /></el-icon>
<span>客服群聊</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/system-configs">
<el-icon><Operation /></el-icon>
<span>系统配置</span>
</RouterLink>
</div>
</div>
</div>
<!-- 最近订单 -->
<div v-if="dashboard" class="dashboard-section">
<h2 class="section-title">
<el-icon><Tickets /></el-icon>
最近订单
</h2>
<el-table class="table-panel" :data="dashboard.recent_orders">
<el-table-column prop="order_no" label="订单号" min-width="210" />
<el-table-column prop="title" label="商品名称" min-width="170" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'renting' ? 'primary' : 'warning'" size="small" effect="plain">
{{ orderStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rent_amount" label="金额" width="100">
<template #default="{ row }">
<span class="amount">{{ money(row.rent_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
</el-table>
</div>
<!-- 最近申诉 -->
<div v-if="dashboard && dashboard.recent_disputes.length > 0" class="dashboard-section">
<h2 class="section-title">
<el-icon><Warning /></el-icon>
最近申诉
</h2>
<el-table class="table-panel" :data="dashboard.recent_disputes">
<el-table-column prop="order_no" label="订单号" min-width="210" />
<el-table-column prop="title" label="商品名称" min-width="170" />
<el-table-column prop="type" label="申诉类型" width="140" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 'resolved' ? 'success' : 'danger'" size="small" effect="plain">
{{ disputeStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default>
<RouterLink to="/admin/disputes">
<el-button size="small" type="primary" link>处理</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
</div>
</section>
</template>
<style scoped>
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 300px;
color: #8f9bba;
}
.loading-state .el-icon {
font-size: 40px;
color: #4f7cff;
}
.loading-state span {
font-size: 14px;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 300px;
color: #ef4444;
}
.error-state .el-icon {
font-size: 40px;
}
.error-state span {
font-size: 14px;
color: #8f9bba;
}
.metric-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.metric-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.metric-card--primary .metric-icon {
background: rgba(79, 124, 255, 0.1);
color: #4f7cff;
}
.metric-card--success .metric-icon {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.metric-card--warning .metric-icon {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.metric-card--danger .metric-icon {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.metric-content {
flex: 1;
min-width: 0;
}
.metric-content span {
font-size: 13px;
color: #8f9bba;
}
.metric-content strong {
display: block;
margin-top: 4px;
font-size: 28px;
font-weight: 800;
color: #1b2559;
line-height: 1;
}
.metric-content small {
display: block;
margin-top: 8px;
font-size: 12px;
color: #a3aed0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.panel-header h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1b2559;
}
.panel-header h2 .el-icon {
color: #4f7cff;
}
.pending-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.pending-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 10px;
text-decoration: none;
transition: background 0.2s;
}
.pending-row:hover {
background: #f8f9fe;
}
.pending-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.pending-label {
font-size: 14px;
font-weight: 600;
color: #1b2559;
}
.pending-desc {
font-size: 12px;
color: #a3aed0;
}
.pending-row strong {
font-size: 20px;
font-weight: 800;
color: #10b981;
min-width: 32px;
text-align: center;
}
.pending-row strong.has-pending {
color: #ef4444;
}
.quick-links {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.quick-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 16px;
border-radius: 12px;
background: #f8f9fe;
text-decoration: none;
transition: all 0.2s;
}
.quick-link:hover {
background: #eef2ff;
transform: translateY(-2px);
}
.quick-link .el-icon {
font-size: 24px;
color: #4f7cff;
}
.quick-link span {
font-size: 13px;
font-weight: 600;
color: #1b2559;
}
.dashboard-section {
margin-top: 28px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
font-size: 16px;
font-weight: 700;
color: #1b2559;
}
.section-title .el-icon {
color: #4f7cff;
}
.amount {
font-weight: 700;
color: #1b2559;
}
@media (max-width: 768px) {
.quick-links {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.metric-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.metric-icon {
width: 40px;
height: 40px;
font-size: 18px;
}
.metric-content strong {
font-size: 24px;
}
}
</style>
@@ -1,192 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
import { fetchAdminFileBlob } from '@/api/files'
import { disputeStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const submitting = ref(false)
const disputes = ref<Dispute[]>([])
const activeDispute = ref<Dispute | null>(null)
const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit')
const remark = ref('')
const amount = ref<number | undefined>()
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadDisputes)
async function loadDisputes() {
loading.value = true
try {
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
disputes.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadDisputes()
}
function openArbitration(row: Dispute) {
activeDispute.value = row
result.value = row.arbitration_result || 'release_deposit'
remark.value = row.arbitration_remark || ''
amount.value = undefined
}
function evidenceItems(row: Dispute | null) {
const raw = row?.evidence_urls
if (!raw) return []
if (Array.isArray(raw)) return raw
return []
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function openEvidence(url: string) {
const key = extractObjectKey(url)
if (!key) {
window.open(url, '_blank')
return
}
try {
const blob = await fetchAdminFileBlob(key)
const objectURL = URL.createObjectURL(blob)
window.open(objectURL, '_blank')
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000)
} catch (error) {
ElMessage.error(readError(error, '证据文件打开失败'))
}
}
async function handleArbitrate() {
if (!activeDispute.value) return
submitting.value = true
try {
await arbitrateDispute(activeDispute.value.id, {
result: result.value,
remark: remark.value,
amount: amount.value,
})
ElMessage.success('仲裁结果已保存,双方已收到通知')
activeDispute.value = null
remark.value = ''
await loadDisputes()
} catch (error) {
ElMessage.error(readError(error, '仲裁失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Arbitration</p>
<h1>仲裁中心</h1>
<p>处理无法登录资产损失哈夫币争议和结账争议</p>
</div>
<el-table v-loading="loading" class="table-panel" :data="disputes">
<el-table-column prop="order_no" label="订单号" min-width="210" />
<el-table-column prop="title" label="账号" min-width="160" />
<el-table-column prop="type" label="类型" width="150" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadDisputes"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
<div v-if="activeDispute" class="dialog-body">
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
<p>{{ activeDispute.description }}</p>
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
<el-option label="全额退款" value="full_refund" />
<el-option label="部分退款" value="partial_refund" />
<el-option label="扣押金" value="deduct_deposit" />
<el-option label="释放押金" value="release_deposit" />
<el-option label="赔付号主" value="compensate_owner" />
<el-option label="关闭订单" value="order_close" />
<el-option label="标记异常" value="mark_abnormal" />
</el-select>
<el-input-number
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
v-model="amount"
class="full-control panel-action"
:min="0"
:precision="0"
:step="10"
placeholder="裁决金额"
/>
<p v-if="result === 'partial_refund'">部分退款金额表示退给租客的金额剩余冻结金额结算给号主</p>
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">金额表示从押金中赔付给号主的部分不填则默认处理全额押金</p>
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
</div>
<template #footer>
<el-button @click="activeDispute = null">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleArbitrate">保存裁决</el-button>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceDispute" title="申诉证据" width="640px" @update:model-value="evidenceDispute = null">
<div v-if="evidenceDispute" class="dialog-body">
<p><strong>{{ evidenceDispute.order_no }}</strong> · {{ evidenceDispute.title }}</p>
<div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
<span>{{ item }}</span>
<el-button size="small" @click="openEvidence(item)">打开</el-button>
</div>
</div>
<template #footer>
<el-button type="primary" @click="evidenceDispute = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -1,178 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/api/files'
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const listing = ref<Listing | null>(null)
const actionType = ref<'offline' | 'abnormal' | ''>('')
const reason = ref('')
const actionTitle = computed(() => (actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'))
const canOperate = computed(() => !!listing.value && listing.value.status !== 'rented' && !['offline', 'abnormal'].includes(listing.value.status))
onMounted(loadListing)
async function loadListing() {
loading.value = true
try {
listing.value = await fetchAdminListing(String(route.params.id))
} finally {
loading.value = false
}
}
function openAction(type: 'offline' | 'abnormal') {
actionType.value = type
reason.value = ''
}
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
}
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
}
function listingPrice(row: Listing) {
return money(row.price)
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || url
} catch {
return url
}
}
async function openScreenshot(url: string) {
try {
const blob = await fetchAdminFileBlob(extractObjectKey(url))
window.open(URL.createObjectURL(blob), '_blank')
} catch {
window.open(url, '_blank')
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="listing" class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listing #{{ listing.id }}</p>
<h1>商品详情</h1>
<p>{{ listing.title }} · {{ listing.server_region }} / {{ listing.login_platform }}</p>
</div>
<div class="toolbar-actions">
<RouterLink to="/admin/listings">
<el-button>返回列表</el-button>
</RouterLink>
<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="metric-grid dashboard-metrics">
<div class="metric-card">
<span>商品状态</span>
<strong>{{ listingStatusLabel(listing.status) }}</strong>
</div>
<div class="metric-card">
<span>审核状态</span>
<strong>{{ listingReviewStatusLabel(listing.review_status) }}</strong>
</div>
<div class="metric-card">
<span>价格</span>
<strong>{{ listingPrice(listing) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>{{ money(listing.deposit_amount) }}</strong>
</div>
</div>
<div v-if="listing" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>账号信息</h2>
<p>账号 ID{{ listing.account_id }}</p>
<p>游戏{{ listing.game_name }}</p>
<p>区服{{ listing.server_region }}</p>
<p>平台{{ listing.login_platform }}</p>
<p>段位{{ listing.rank_level || '-' }}</p>
<p>哈夫币{{ listing.haf_coin_amount }}</p>
<p>资产截图{{ listing.screenshot_urls?.length || 0 }} </p>
</div>
<div class="order-panel dashboard-panel">
<h2>号主与价格</h2>
<p>号主{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</p>
<p>号主 ID{{ listing.owner_id }}</p>
<p>价格{{ listingPrice(listing) }}</p>
<p>押金{{ money(listing.deposit_amount) }}</p>
</div>
</div>
<div v-if="listing" class="order-panel dashboard-panel">
<h2>说明与审核原因</h2>
<p>{{ listing.description || '暂无商品说明' }}</p>
<p>审核/后台原因{{ listing.review_reason || '-' }}</p>
<p>上架时间{{ formatDateTime(listing.published_at) }}</p>
<p>更新时间{{ formatDateTime(listing.updated_at) }}</p>
</div>
<div v-if="listing" class="order-panel dashboard-panel">
<h2>资产截图</h2>
<div v-if="listing.screenshot_urls?.length" class="evidence-list">
<div v-for="url in listing.screenshot_urls" :key="url" class="evidence-row">
<span>{{ url }}</span>
<el-button size="small" @click="openScreenshot(url)">打开</el-button>
</div>
</div>
<p v-else>暂无截图</p>
</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>
</section>
</template>
@@ -1,963 +0,0 @@
<script setup lang="ts">
import { Check, Close, Picture, Refresh, Search, WarningFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/api/files'
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
import {
assetRegions,
formatHafCoinM,
formatRatio,
getCoinWan,
getListingConsumablePrice,
getListingResources,
getResourceQuantity,
getSkinNames,
readAssetNumber,
readAssetString,
} from '@/utils/listingDisplay'
import { formatDateTime } from '@/utils/time'
type RiskLevel = 'danger' | 'warning' | 'info'
interface RiskItem {
label: string
level: RiskLevel
}
const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常']
const loading = ref(false)
const submitting = ref(false)
const listings = ref<Listing[]>([])
const selectedID = ref<number | null>(null)
const activeListing = ref<Listing | null>(null)
const evidenceListing = ref<Listing | null>(null)
const rejectReason = ref('')
const filters = reactive({
keyword: '',
risk: 'all',
})
const previewURLs = reactive<Record<string, string>>({})
const createdObjectURLs = new Set<string>()
const selectedListing = computed(() => listings.value.find((item) => item.id === selectedID.value) || listings.value[0] || null)
const filteredListings = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return listings.value.filter((item) => {
if (filters.risk === 'external' && !isExternalUpload(item)) return false
if (filters.risk === 'defaultImage' && !hasDefaultScreenshot(item)) return false
if (filters.risk === 'ban' && !hasBanRecord(item)) return false
if (!keyword) return true
return reviewSearchText(item).includes(keyword)
})
})
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
watch(
() => selectedListing.value,
async (listing) => {
if (!listing) return
selectedID.value = listing.id
await nextTick()
loadScreenshotPreviews(listing)
},
{ immediate: true },
)
onMounted(loadListings)
onBeforeUnmount(() => {
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
})
async function loadListings() {
loading.value = true
try {
listings.value = await fetchPendingReviewListings()
if (!selectedID.value || !listings.value.some((item) => item.id === selectedID.value)) {
selectedID.value = listings.value[0]?.id || null
}
} finally {
loading.value = false
}
}
function selectListing(row: Listing) {
selectedID.value = row.id
}
async function handleApprove(row: Listing) {
try {
await ElMessageBox.confirm(`确认通过「${row.title}」并上架?`, '审核通过确认', {
confirmButtonText: '通过并上架',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
submitting.value = true
try {
await approveListing(row.id)
ElMessage.success('审核通过,商品已上架')
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '审核失败'))
} finally {
submitting.value = false
}
}
function openReject(row: Listing) {
activeListing.value = row
rejectReason.value = row.review_reason || ''
}
function appendRejectReason(reason: string) {
const current = rejectReason.value.trim()
if (!current) {
rejectReason.value = reason
return
}
if (!current.includes(reason)) {
rejectReason.value = `${current}${reason}`
}
}
async function handleReject() {
if (!activeListing.value) return
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写拒绝原因')
return
}
submitting.value = true
try {
await rejectListing(activeListing.value.id, reason)
ElMessage.success('已拒绝发布并通知号主')
activeListing.value = null
rejectReason.value = ''
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '拒绝失败'))
} finally {
submitting.value = false
}
}
function openEvidence(row: Listing) {
evidenceListing.value = row
loadScreenshotPreviews(row)
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
}
function quantity(value: number) {
const rounded = Math.round(Number(value || 0) * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
function assetText(row: Listing, key: string) {
return readAssetString(row, key) || '-'
}
function assetNumberText(row: Listing, key: string) {
const value = readAssetNumber(row, key)
return value > 0 ? quantity(value) : '-'
}
function dailyLossText(row: Listing) {
const value = readAssetNumber(row, 'daily_loss_m')
return value > 0 ? `${quantity(value)}M` : '未上传'
}
function uploadMeta(row: Listing) {
const meta = row.asset_summary?.import_meta
return typeof meta === 'object' && meta !== null ? (meta as Record<string, unknown>) : {}
}
function uploaderName(row: Listing) {
const value = uploadMeta(row).uploader_name
return typeof value === 'string' && value.trim() ? value : '-'
}
function contactPhone(row: Listing) {
const value = uploadMeta(row).contact_phone
return typeof value === 'string' && value.trim() ? value : '-'
}
function ownerOnlineText(row: Listing) {
const text = row.asset_summary?.online_time_text
if (typeof text === 'string' && text.trim()) return text
const onlineTime = row.asset_summary?.online_time
if (typeof onlineTime !== 'object' || onlineTime === null) return '-'
const start = (onlineTime as Record<string, unknown>).start
const end = (onlineTime as Record<string, unknown>).end
if (typeof start === 'string' && typeof end === 'string' && start && end) return `${start}-${end}`
return '-'
}
function commonRegionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function banRecordText(row: Listing) {
return assetText(row, 'ban_record')
}
function hasBanRecord(row: Listing) {
const value = banRecordText(row)
return value !== '-' && !value.includes('无')
}
function isExternalUpload(row: Listing) {
return uploaderName(row) !== '-'
}
function hasDefaultScreenshot(row: Listing) {
return row.screenshot_urls?.some((url) => isDefaultScreenshot(url)) || false
}
function isDefaultScreenshot(url: string) {
return url.includes(defaultScreenshotURL)
}
function riskItems(row: Listing): RiskItem[] {
const items: RiskItem[] = []
if (isExternalUpload(row)) items.push({ label: `外部上传:${uploaderName(row)}`, level: 'info' })
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
if (getListingConsumablePrice(row) > 0 && Number(row.deposit_amount || 0) <= getListingConsumablePrice(row)) {
items.push({ label: '押金不高于消耗品价值', level: 'danger' })
}
if (readAssetNumber(row, 'daily_loss_m') <= 0) items.push({ label: '缺少每日损耗', level: 'warning' })
if (readAssetNumber(row, 'fire_level') <= 40) items.push({ label: '烽火等级接近下限', level: 'warning' })
if (!readAssetString(row, 'season_insurance')) items.push({ label: '缺少保险格数', level: 'warning' })
if (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
return items
}
function riskTagType(level: RiskLevel) {
if (level === 'danger') return 'danger'
if (level === 'warning') return 'warning'
return 'info'
}
function reviewSearchText(row: Listing) {
return [
row.title,
row.owner_phone,
row.owner_nickname,
row.owner_id,
row.server_region,
row.login_platform,
row.rank_level,
uploaderName(row),
getSkinNames(row).join(' '),
]
.join(' ')
.toLowerCase()
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function loadScreenshotPreviews(row: Listing) {
for (const url of row.screenshot_urls || []) {
if (previewURLs[url]) continue
if (isDefaultScreenshot(url) || !extractObjectKey(url)) {
previewURLs[url] = url
continue
}
try {
const blob = await fetchAdminFileBlob(extractObjectKey(url))
const objectURL = URL.createObjectURL(blob)
createdObjectURLs.add(objectURL)
previewURLs[url] = objectURL
} catch {
previewURLs[url] = url
}
}
}
async function openScreenshot(url: string) {
if (isDefaultScreenshot(url)) {
window.open(url, '_blank')
return
}
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')
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="page review-page">
<div class="page-header-row review-header">
<div class="page-header">
<p class="eyebrow">Review</p>
<h1>商品审核</h1>
<p>集中核对账号资产上传来源价格押金和截图风险</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadListings">刷新</el-button>
</div>
</div>
<div class="review-summary">
<div>
<span>待审核</span>
<strong>{{ listings.length }}</strong>
</div>
<div>
<span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
</div>
<div>
<span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</div>
<div>
<span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
</div>
</div>
<div class="review-workbench">
<aside class="review-queue">
<div class="queue-toolbar">
<el-input v-model="filters.keyword" :prefix-icon="Search" clearable placeholder="搜索标题、客服、段位、皮肤" />
<el-select v-model="filters.risk" placeholder="风险筛选">
<el-option label="全部待审" value="all" />
<el-option label="外部上传" value="external" />
<el-option label="默认截图" value="defaultImage" />
<el-option label="有封禁记录" value="ban" />
</el-select>
</div>
<div v-loading="loading" class="queue-list">
<button
v-for="item in filteredListings"
:key="item.id"
class="queue-item"
:class="{ active: selectedListing?.id === item.id }"
type="button"
@click="selectListing(item)"
>
<div class="queue-title-row">
<strong>{{ item.title }}</strong>
<span>{{ formatHafCoinM(getCoinWan(item)) }}</span>
</div>
<div class="queue-meta">
<span>{{ item.rank_level || '-' }}</span>
<span>{{ assetText(item, 'season_insurance') }}</span>
<span>{{ assetText(item, 'stamina_level') }}/{{ assetText(item, 'load_level') }}</span>
<span>KD {{ assetNumberText(item, 'secret_kd') }}</span>
</div>
<div class="queue-footer">
<span>{{ money(item.price) }} / {{ money(item.deposit_amount) }}</span>
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{ uploaderName(item) }}</el-tag>
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
</div>
</button>
<el-empty v-if="!loading && !filteredListings.length" description="暂无符合条件的待审核商品" />
</div>
</aside>
<main v-if="selectedListing" class="review-detail">
<section class="review-hero-panel">
<div class="hero-title">
<div>
<p>商品 #{{ selectedListing.id }}</p>
<h2>{{ selectedListing.title }}</h2>
</div>
<div class="hero-actions">
<RouterLink :to="`/admin/listings/${selectedListing.id}`">
<el-button>详情页</el-button>
</RouterLink>
<el-button :icon="Close" type="danger" :loading="submitting" @click="openReject(selectedListing)">拒绝</el-button>
<el-button :icon="Check" type="primary" :loading="submitting" @click="handleApprove(selectedListing)">通过</el-button>
</div>
</div>
<div class="risk-strip">
<el-tag v-for="risk in activeRisks" :key="risk.label" :type="riskTagType(risk.level)" effect="light">
{{ risk.label }}
</el-tag>
</div>
</section>
<section class="metric-grid review-metrics">
<div class="metric-card">
<span>哈夫币</span>
<strong>{{ formatHafCoinM(getCoinWan(selectedListing)) }}</strong>
</div>
<div class="metric-card">
<span>回收比例</span>
<strong>{{ formatRatio(selectedListing) }}</strong>
</div>
<div class="metric-card">
<span>回收租金</span>
<strong>{{ money(selectedListing.price) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
</div>
<div class="metric-card">
<span>每日损耗</span>
<strong>{{ dailyLossText(selectedListing) }}</strong>
</div>
</section>
<section class="review-grid">
<div class="review-panel">
<div class="panel-title">
<h3>账号属性</h3>
</div>
<dl class="detail-list">
<div><dt>上号方式</dt><dd>{{ selectedListing.login_platform || '-' }}</dd></div>
<div><dt>区服</dt><dd>{{ selectedListing.server_region || '-' }}</dd></div>
<div><dt>段位</dt><dd>{{ selectedListing.rank_level || '-' }}</dd></div>
<div><dt>烽火等级</dt><dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd></div>
<div><dt>保险格数</dt><dd>{{ assetText(selectedListing, 'season_insurance') }}</dd></div>
<div><dt>绝密KD</dt><dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd></div>
<div><dt>体力/负重</dt><dd>{{ assetText(selectedListing, 'stamina_level') }} / {{ assetText(selectedListing, 'load_level') }}</dd></div>
<div><dt>封禁记录</dt><dd>{{ banRecordText(selectedListing) }}</dd></div>
</dl>
</div>
<div class="review-panel">
<div class="panel-title">
<h3>上传信息</h3>
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info">外部上传</el-tag>
</div>
<dl class="detail-list">
<div><dt>上传人</dt><dd>{{ uploaderName(selectedListing) }}</dd></div>
<div><dt>号主</dt><dd>{{ selectedListing.owner_phone || selectedListing.owner_nickname || selectedListing.owner_id }}</dd></div>
<div><dt>联系电话</dt><dd>{{ contactPhone(selectedListing) }}</dd></div>
<div><dt>常用地区</dt><dd>{{ commonRegionText(selectedListing) }}</dd></div>
<div><dt>在线时间</dt><dd>{{ ownerOnlineText(selectedListing) }}</dd></div>
<div><dt>提交时间</dt><dd>{{ formatDateTime(selectedListing.updated_at) }}</dd></div>
</dl>
</div>
</section>
<section class="review-panel">
<div class="panel-title">
<h3>库存资产</h3>
<span>额外消耗品约 {{ money(getListingConsumablePrice(selectedListing)) }}</span>
</div>
<div class="inventory-grid">
<div><span>AWM子弹</span><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong></div>
<div><span>6</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong></div>
<div><span>6</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong></div>
</div>
<div v-if="selectedResources.length" class="resource-table">
<div v-for="resource in selectedResources" :key="resource.key">
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>{{ resource.mode }} · {{ resource.price }}</em>
</div>
</div>
<div class="skin-list">
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{ skin }}</el-tag>
<span v-if="!selectedSkins.length">暂无皮肤数据</span>
</div>
</section>
<section class="review-panel">
<div class="panel-title">
<h3>账号截图</h3>
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)">查看全部</el-button>
</div>
<div v-if="selectedListing.screenshot_urls?.length" class="screenshot-grid">
<button v-for="url in selectedListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
</div>
<div v-else class="empty-warning">
<el-icon><WarningFilled /></el-icon>
<span>暂无截图</span>
</div>
</section>
</main>
<main v-else class="review-detail empty-detail">
<el-empty description="暂无待审核商品" />
</main>
</div>
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="620px" @update:model-value="activeListing = null">
<div v-if="activeListing" class="dialog-body reject-dialog">
<p><strong>{{ activeListing.title }}</strong></p>
<div class="reject-reasons">
<el-button v-for="reason in rejectReasonOptions" :key="reason" size="small" @click="appendRejectReason(reason)">
{{ reason }}
</el-button>
</div>
<el-input v-model="rejectReason" type="textarea" :rows="5" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
</div>
<template #footer>
<el-button @click="activeListing = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleReject">确认拒绝</el-button>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
<div v-if="evidenceListing" class="dialog-body">
<p><strong>{{ evidenceListing.title }}</strong></p>
<div v-if="evidenceListing.screenshot_urls?.length" class="screenshot-grid dialog-screenshots">
<button v-for="url in evidenceListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
</div>
<p v-else>暂无截图</p>
</div>
<template #footer>
<el-button @click="evidenceListing = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.review-page {
min-height: calc(100vh - 128px);
}
.review-header {
margin-bottom: 18px;
}
.review-summary {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.review-summary div {
min-width: 0;
border-radius: 8px;
background: #ffffff;
padding: 14px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.review-summary span,
.queue-footer span,
.panel-title span,
.inventory-grid span,
.resource-table span {
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.review-summary strong {
display: block;
margin-top: 5px;
color: #1b2559;
font-size: 24px;
font-weight: 800;
}
.review-workbench {
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.review-queue,
.review-detail,
.review-panel,
.review-hero-panel {
min-width: 0;
}
.review-queue {
position: sticky;
top: 16px;
border-radius: 8px;
background: #ffffff;
padding: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.queue-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.queue-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 300px);
overflow: auto;
padding-right: 2px;
}
.queue-item {
width: 100%;
min-height: 112px;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #ffffff;
padding: 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.queue-item:hover,
.queue-item.active {
border-color: #4f7cff;
background: #f8faff;
box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1);
}
.queue-title-row,
.queue-footer,
.panel-title,
.hero-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.queue-title-row strong {
min-width: 0;
color: #1b2559;
font-size: 14px;
font-weight: 800;
line-height: 1.4;
}
.queue-title-row span {
flex-shrink: 0;
color: #4f7cff;
font-weight: 800;
}
.queue-meta,
.risk-strip,
.skin-list,
.reject-reasons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.queue-meta {
margin: 10px 0;
}
.queue-meta span {
border-radius: 6px;
background: #f0f2f5;
padding: 4px 7px;
color: #4b5563;
font-size: 12px;
font-weight: 700;
}
.queue-footer {
align-items: center;
}
.review-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.review-hero-panel,
.review-panel {
border-radius: 8px;
background: #ffffff;
padding: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.hero-title h2,
.panel-title h3 {
margin: 0;
color: #1b2559;
font-size: 18px;
font-weight: 800;
line-height: 1.35;
}
.hero-title p {
margin: 0 0 6px;
color: #8f9bba;
font-size: 12px;
font-weight: 700;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.risk-strip {
margin-top: 14px;
}
.review-metrics {
grid-template-columns: repeat(5, minmax(132px, 1fr));
gap: 12px;
margin-bottom: 0;
}
.review-metrics .metric-card {
border-radius: 8px;
padding: 16px;
}
.review-metrics .metric-card strong {
font-size: 20px;
}
.review-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.panel-title {
align-items: center;
margin-bottom: 14px;
}
.detail-list {
display: grid;
gap: 10px;
margin: 0;
}
.detail-list div {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.detail-list dt {
color: #8f9bba;
font-size: 12px;
font-weight: 700;
}
.detail-list dd {
min-width: 0;
margin: 0;
color: #1f2937;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.inventory-grid div {
border-radius: 8px;
background: #f8f9fe;
padding: 12px;
}
.inventory-grid strong {
display: block;
margin-top: 4px;
color: #1b2559;
font-size: 22px;
font-weight: 900;
}
.resource-table {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.resource-table div {
display: grid;
grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr);
gap: 10px;
align-items: center;
border-bottom: 1px solid #f0f2f5;
padding-bottom: 8px;
}
.resource-table strong {
color: #1f2937;
font-size: 13px;
font-weight: 800;
}
.resource-table em {
color: #8f9bba;
font-size: 12px;
font-style: normal;
font-weight: 600;
}
.skin-list {
align-items: center;
}
.skin-list > span {
color: #8f9bba;
font-size: 13px;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.screenshot-tile {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #f8f9fe;
padding: 0;
cursor: pointer;
}
.screenshot-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.screenshot-tile span {
position: absolute;
top: 8px;
left: 8px;
border-radius: 6px;
background: rgba(245, 158, 11, 0.95);
padding: 3px 7px;
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.empty-warning {
display: flex;
align-items: center;
gap: 8px;
min-height: 84px;
border-radius: 8px;
background: #fff7ed;
padding: 16px;
color: #c2410c;
font-weight: 700;
}
.dialog-screenshots {
max-height: 540px;
overflow: auto;
}
.reject-dialog p {
margin-top: 0;
}
.reject-reasons {
margin: 12px 0;
}
.empty-detail {
min-height: 420px;
display: grid;
place-items: center;
border-radius: 8px;
background: #ffffff;
}
@media (max-width: 1280px) {
.review-workbench {
grid-template-columns: 1fr;
}
.review-queue {
position: static;
}
.queue-list {
max-height: none;
}
}
@media (max-width: 900px) {
.review-summary,
.review-metrics,
.review-grid {
grid-template-columns: 1fr;
}
.hero-title {
flex-direction: column;
}
.hero-actions {
justify-content: flex-start;
}
.queue-toolbar {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,468 +0,0 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import { fetchAdminListings, type AdminListingPage, type AdminListingQuery, type Listing } from '@/api/listings'
import { useAdminTable } from '@/composables/useAdminTable'
import { useMoney } from '@/composables/useMoney'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getResourceQuantity,
getSkinGroup,
} from '@/utils/listingDisplay'
const money = useMoney()
const filters = reactive<AdminListingQuery>({
owner_id: '',
status: '',
review_status: '',
})
const pageSize = ref(10)
const currentPage = ref(1)
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
fetchFn: () =>
fetchAdminListings({
...filters,
page: currentPage.value,
page_size: pageSize.value,
}),
initialData: {
items: [],
total: 0,
page: 1,
page_size: 10,
},
})
const listings = computed(() => listingPage.value.items)
const totalListings = computed(() => listingPage.value.total)
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
const rentedCount = computed(() => listings.value.filter((item) => item.status === 'rented').length)
const pendingCount = computed(() => listings.value.filter((item) => item.review_status === 'pending').length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
const screenshotColumns = [
{ label: '商品编码', width: 88, read: (row: Listing) => formatListingCode(row) },
{ label: '地区', width: 92, read: (row: Listing) => regionText(row) },
{ label: '上号方式', width: 78, read: (row: Listing) => row.login_platform || '-' },
{ label: '哈夫币(M)', width: 86, read: (row: Listing) => listingCoinM(row) },
{ label: '保险', width: 56, read: (row: Listing) => assetText(row, 'season_insurance') },
{ label: '体力(级)', width: 70, read: (row: Listing) => levelText(row, 'stamina_level') },
{ label: '负重(级)', width: 70, read: (row: Listing) => levelText(row, 'load_level') },
{ label: '账号等级', width: 76, read: (row: Listing) => assetText(row, 'fire_level') },
{ label: '绝密KD', width: 66, read: (row: Listing) => assetText(row, 'secret_kd') },
{ label: 'awm子弹', width: 74, read: (row: Listing) => resourceText(row, 'awmAmmo') },
{ label: '六头', width: 50, read: (row: Listing) => resourceText(row, 'helmet6') },
{ label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') },
{ label: '特殊刀皮', width: 110, read: (row: Listing) => skinGroupText(row, 'melee') },
{ label: '人物红皮/人物金皮/武器皮肤', width: 420, read: (row: Listing) => characterAndWeaponSkinText(row) },
{ label: '租金/押金', width: 96, read: (row: Listing) => rentAndDepositText(row) },
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(row) },
{ label: '租期', width: 92, read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}` },
] as const
async function queryListings() {
currentPage.value = 1
await loadListings()
}
function resetFilters() {
filters.owner_id = ''
filters.status = ''
filters.review_status = ''
void queryListings()
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
await loadListings()
}
async function prevPage() {
currentPage.value = Math.max(1, currentPage.value - 1)
await loadListings()
}
async function nextPage() {
currentPage.value = Math.min(totalPages.value, currentPage.value + 1)
await loadListings()
}
async function copyTableScreenshot() {
try {
const blob = await createTableScreenshotBlob()
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
ElMessage.warning('当前浏览器不支持直接复制图片,请使用下载截图')
return
}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
ElMessage.success('表格截图已复制到剪贴板')
} catch (error) {
ElMessage.error(readScreenshotError(error, '复制截图失败'))
}
}
async function downloadTableScreenshot() {
try {
const blob = await createTableScreenshotBlob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `商品管理表格-${formatFileTime(new Date())}.png`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('表格截图已下载')
} catch (error) {
ElMessage.error(readScreenshotError(error, '下载截图失败'))
}
}
async function createTableScreenshotBlob() {
if (!listings.value.length) throw new Error('当前页暂无可截图数据')
const canvas = renderTableScreenshotCanvas(listings.value)
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('截图生成失败')
return blob
}
function renderTableScreenshotCanvas(rows: Listing[]) {
const ratio = window.devicePixelRatio || 1
const paddingX = 24
const paddingY = 20
const titleHeight = 44
const headerHeight = 34
const lineHeight = 18
const cellPaddingX = 8
const cellPaddingY = 10
const tableWidth = screenshotColumns.reduce((sum, column) => sum + column.width, 0)
const canvasWidth = tableWidth + paddingX * 2
const measureCanvas = document.createElement('canvas')
const measureCtx = measureCanvas.getContext('2d')
if (!measureCtx) throw new Error('截图画布初始化失败')
measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif'
const rowLines = rows.map((row) =>
screenshotColumns.map((column) => wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)),
)
const rowHeights = rowLines.map((lineGroups) => {
const maxLines = Math.max(...lineGroups.map((lines) => lines.length), 1)
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2)
})
const canvasHeight = paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
const canvas = document.createElement('canvas')
canvas.width = Math.round(canvasWidth * ratio)
canvas.height = Math.round(canvasHeight * ratio)
canvas.style.width = `${canvasWidth}px`
canvas.style.height = `${canvasHeight}px`
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('截图画布初始化失败')
ctx.scale(ratio, ratio)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
ctx.fillStyle = '#1b2559'
ctx.font = '700 18px Arial, "Microsoft YaHei", sans-serif'
ctx.fillText('商品管理', paddingX, paddingY + 20)
ctx.fillStyle = '#8f9bba'
ctx.font = '12px Arial, "Microsoft YaHei", sans-serif'
ctx.fillText(`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value}`, paddingX, paddingY + 40)
let y = paddingY + titleHeight
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
ctx.font = '700 12px Arial, "Microsoft YaHei", sans-serif'
ctx.fillStyle = '#8f9bba'
let x = paddingX
for (const column of screenshotColumns) {
drawCellText(ctx, [column.label], x + cellPaddingX, y + 22, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + headerHeight, paddingX + tableWidth, y + headerHeight)
y += headerHeight
ctx.font = '13px Arial, "Microsoft YaHei", sans-serif'
rows.forEach((row, rowIndex) => {
const rowHeight = rowHeights[rowIndex] ?? 44
drawRect(ctx, paddingX, y, tableWidth, rowHeight, rowIndex % 2 === 0 ? '#ffffff' : '#fbfcff')
x = paddingX
for (const [columnIndex, column] of screenshotColumns.entries()) {
const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-']
ctx.fillStyle = columnIndex === 0 ? '#4b5563' : '#3f4654'
ctx.font = columnIndex === 0 ? '700 13px Arial, "Microsoft YaHei", sans-serif' : '13px Arial, "Microsoft YaHei", sans-serif'
drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + rowHeight, paddingX + tableWidth, y + rowHeight)
y += rowHeight
})
return canvas
}
function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const paragraphs = String(text || '-').split('\n')
const lines: string[] = []
for (const paragraph of paragraphs) {
let line = ''
for (const char of paragraph) {
const nextLine = line + char
if (line && ctx.measureText(nextLine).width > maxWidth) {
lines.push(line)
line = char
} else {
line = nextLine
}
}
lines.push(line || '-')
}
return lines
}
function drawCellText(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number) {
lines.forEach((line, index) => {
ctx.fillText(line, x, y + index * lineHeight)
})
}
function drawRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, color: string) {
ctx.fillStyle = color
ctx.fillRect(x, y, width, height)
}
function drawLine(ctx: CanvasRenderingContext2D, startX: number, startY: number, endX: number, endY: number) {
ctx.strokeStyle = '#e6eaf2'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.stroke()
}
function formatFileTime(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`
}
function readScreenshotError(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback
}
function listingPrice(row: Listing) {
return money(row.price)
}
function listingCoinM(row: Listing) {
return formatHafCoinM(getCoinWan(row))
}
function assetText(row: Listing, key: string) {
const value = row.asset_summary?.[key]
if (typeof value === 'number') return formatQuantity(value)
if (typeof value === 'string' && value.trim()) return value.trim()
return '-'
}
function levelText(row: Listing, key: string) {
const value = assetText(row, key)
return value === '-' ? value : value.replace(/级$/, '')
}
function regionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function resourceText(row: Listing, key: string) {
const quantity = getResourceQuantity(row, key)
return quantity > 0 ? formatQuantity(quantity) : '-'
}
function skinGroupText(row: Listing, groupKey: string) {
const skins = getSkinGroup(row, groupKey)
return skins.length ? skins.join('、') : '-'
}
function characterAndWeaponSkinText(row: Listing) {
const groups = [
{ label: '红皮', key: 'operatorRed' },
{ label: '金皮', key: 'operatorGold' },
{ label: '武器', key: 'weapon' },
]
const parts = groups
.map((group) => {
const names = getSkinGroup(row, group.key)
return names.length ? `${group.label}${names.join('、')}` : ''
})
.filter(Boolean)
return parts.length ? parts.join(' / ') : '-'
}
function rentAndDepositText(row: Listing) {
return `${listingPrice(row)}/${money(row.deposit_amount)}`
}
function estimateTitle(row: Listing) {
return `按哈夫币 ${listingCoinM(row)}、日耗 ${getDailyLoss(row)} 估算`
}
function formatQuantity(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
</script>
<template>
<section class="page admin-listings-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listings</p>
<h1>商品管理</h1>
<p>查看商品编码地区上号方式账号资产租金押金比例和预计租期</p>
</div>
</div>
<div class="listing-control-panel">
<div class="listing-metric-strip">
<span>结果 <strong>{{ totalListings }}</strong></span>
<span>本页 <strong>{{ listings.length }}</strong></span>
<span>已上架 <strong>{{ publishedCount }}</strong></span>
<span>已锁定 <strong>{{ rentedCount }}</strong></span>
<span>待审核 <strong>{{ pendingCount }}</strong></span>
</div>
<el-form class="listing-filter-bar" label-position="top">
<el-form-item label="号主 ID">
<el-input v-model="filters.owner_id" clearable placeholder="按号主筛选" />
</el-form-item>
<el-form-item label="商品状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="草稿" value="draft" />
<el-option label="已上架" value="published" />
<el-option label="已锁定" value="rented" />
<el-option label="已下架" value="offline" />
<el-option label="异常" value="abnormal" />
</el-select>
</el-form-item>
<el-form-item label="审核状态">
<el-select v-model="filters.review_status" clearable placeholder="全部审核状态" class="full-control">
<el-option label="未提交" value="none" />
<el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</el-form-item>
</el-form>
<div class="listing-action-strip">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings">查询</el-button>
<el-button :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button>
</div>
</div>
<el-table
v-loading="loading"
class="table-panel admin-listings-table"
:data="listings"
row-key="id"
stripe
empty-text="暂无商品"
>
<el-table-column label="商品编码" width="88">
<template #default="{ row }">
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
</template>
</el-table-column>
<el-table-column label="地区" width="86">
<template #default="{ row }">{{ regionText(row) }}</template>
</el-table-column>
<el-table-column prop="login_platform" label="上号方式" width="78" />
<el-table-column label="哈夫币(M)" width="82">
<template #default="{ row }">{{ listingCoinM(row) }}</template>
</el-table-column>
<el-table-column label="保险" width="54">
<template #default="{ row }">{{ assetText(row, 'season_insurance') }}</template>
</el-table-column>
<el-table-column label="体力(级)" width="68">
<template #default="{ row }">{{ levelText(row, 'stamina_level') }}</template>
</el-table-column>
<el-table-column label="负重(级)" width="68">
<template #default="{ row }">{{ levelText(row, 'load_level') }}</template>
</el-table-column>
<el-table-column label="账号等级" width="74">
<template #default="{ row }">{{ assetText(row, 'fire_level') }}</template>
</el-table-column>
<el-table-column label="绝密KD" width="66">
<template #default="{ row }">{{ assetText(row, 'secret_kd') }}</template>
</el-table-column>
<el-table-column label="awm子弹" width="72">
<template #default="{ row }">{{ resourceText(row, 'awmAmmo') }}</template>
</el-table-column>
<el-table-column label="六头" width="48">
<template #default="{ row }">{{ resourceText(row, 'helmet6') }}</template>
</el-table-column>
<el-table-column label="六甲" width="48">
<template #default="{ row }">{{ resourceText(row, 'armor6') }}</template>
</el-table-column>
<el-table-column label="特殊刀皮" width="104">
<template #default="{ row }">
{{ skinGroupText(row, 'melee') }}
</template>
</el-table-column>
<el-table-column label="人物红皮/人物金皮/武器皮肤" min-width="226">
<template #default="{ row }">
{{ characterAndWeaponSkinText(row) }}
</template>
</el-table-column>
<el-table-column label="租金/押金" width="90">
<template #default="{ row }">{{ rentAndDepositText(row) }}</template>
</el-table-column>
<el-table-column label="比例" width="62">
<template #default="{ row }">{{ formatRatio(row) }}</template>
</el-table-column>
<el-table-column label="租期" width="82">
<template #default="{ row }">
<span :title="estimateTitle(row)">{{ formatEstimatedRentalDuration(row) }}</span>
<span class="table-subtext">日耗 {{ getDailyLoss(row) }}</span>
</template>
</el-table-column>
<el-table-column label="详情" width="60">
<template #default="{ row }">
<RouterLink :to="`/admin/listings/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<span class="pagination-summary">当前页 {{ listings.length }} {{ totalListings }} </span>
<div class="pagination-controls">
<span class="pagination-size-label">每页</span>
<el-select
:model-value="pageSize"
class="page-size-select"
size="small"
@update:model-value="handlePageSizeChange"
>
<el-option :value="10" label="10条" />
<el-option :value="20" label="20条" />
<el-option :value="50" label="50条" />
<el-option :value="100" label="100条" />
</el-select>
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span>
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下页</el-button>
</div>
</div>
</section>
</template>
-434
View File
@@ -1,434 +0,0 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { Lock, User } from "@element-plus/icons-vue";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { fetchAdminCaptcha, type AdminCaptcha } from "@/api/adminAuth";
import { useAdminSessionStore } from "@/stores/adminSession";
const router = useRouter();
const adminSession = useAdminSessionStore();
const loading = ref(false);
const captchaLoading = ref(false);
const captcha = ref<AdminCaptcha | null>(null);
const form = reactive({
username: "admin",
password: "admin123456",
captchaCode: "",
});
onMounted(loadCaptcha);
async function loadCaptcha() {
captchaLoading.value = true;
try {
captcha.value = await fetchAdminCaptcha();
form.captchaCode = "";
} catch (error) {
ElMessage.error(readError(error, "验证码加载失败"));
} finally {
captchaLoading.value = false;
}
}
async function handleLogin() {
loading.value = true;
try {
await adminSession.login(
form.username,
form.password,
captcha.value?.captcha_id || "",
form.captchaCode
);
ElMessage.success("后台登录成功");
await router.push("/admin/dashboard");
} catch (error) {
ElMessage.error(readError(error, "后台登录失败"));
await loadCaptcha();
} finally {
loading.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
</script>
<template>
<section class="admin-login-shell">
<div class="admin-login-glow" aria-hidden="true" />
<div class="admin-login-glow secondary" aria-hidden="true" />
<div class="admin-login-card">
<div class="admin-login-left">
<div class="brand-lockup">
<div class="brand-logo">H</div>
<strong>大锤商行 - 后台管理</strong>
</div>
<div class="admin-login-hero">
<h2>高效管理<br />从容掌控</h2>
<p>统一的后台管理系统助您轻松处理订单用户与运营数据</p>
</div>
<div class="admin-login-footer">
<span>© 哈夫币租号平台</span>
</div>
</div>
<div class="admin-login-right">
<div class="admin-login-header">
<p class="eyebrow">Admin Login</p>
<h1>管理员登录</h1>
</div>
<el-form class="admin-form" label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="form.username"
placeholder="请输入管理员用户名"
size="large"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入管理员密码"
size="large"
:prefix-icon="Lock"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="验证码">
<div class="admin-captcha-row">
<el-input
v-model="form.captchaCode"
maxlength="4"
placeholder="请输入验证码"
size="large"
@keyup.enter="handleLogin"
/>
<button
class="captcha-image-button"
type="button"
:disabled="captchaLoading"
@click="loadCaptcha"
>
<img v-if="captcha" :src="captcha.image" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</el-form-item>
<el-button
class="admin-login-btn"
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
>
登录后台
</el-button>
</el-form>
<RouterLink class="back-home" to="/"> 返回首页</RouterLink>
</div>
</div>
</section>
</template>
<style scoped>
.admin-login-shell {
position: relative;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
background: radial-gradient(
circle at 18% 20%,
rgba(20, 119, 255, 0.14),
transparent 42%
),
radial-gradient(
circle at 82% 80%,
rgba(109, 40, 217, 0.12),
transparent 42%
),
linear-gradient(180deg, #0b1120 0%, #0f172a 60%, #111827 100%);
padding: 24px;
}
.admin-login-glow {
position: absolute;
top: -10%;
left: -10%;
width: 50vw;
height: 50vw;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(20, 119, 255, 0.22),
transparent 65%
);
filter: blur(80px);
pointer-events: none;
}
.admin-login-glow.secondary {
top: auto;
left: auto;
right: -10%;
bottom: -10%;
background: radial-gradient(
circle,
rgba(109, 40, 217, 0.18),
transparent 65%
);
}
.admin-login-card {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 420px;
width: min(960px, 100%);
min-height: 560px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(24px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
/* ========== 左侧品牌区 ========== */
.admin-login-left {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 40px 36px;
background: radial-gradient(
circle at 30% 20%,
rgba(20, 119, 255, 0.12),
transparent 50%
),
radial-gradient(circle at 80% 90%, rgba(109, 40, 217, 0.1), transparent 50%),
linear-gradient(160deg, rgba(20, 119, 255, 0.1), rgba(109, 40, 217, 0.06));
}
.brand-lockup {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
box-shadow: 0 8px 24px rgba(20, 119, 255, 0.25);
color: #fff;
font-size: 15px;
font-weight: 900;
}
.brand-lockup strong {
color: #f8fafc;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.5px;
}
.admin-login-hero h2 {
margin: 0 0 14px;
color: #f1f5f9;
font-size: 28px;
font-weight: 800;
line-height: 1.25;
}
.admin-login-hero p {
margin: 0;
color: #94a3b8;
font-size: 14px;
line-height: 1.7;
max-width: 320px;
}
.admin-login-footer span {
color: #475569;
font-size: 12px;
}
/* ========== 右侧表单区 ========== */
.admin-login-right {
display: flex;
flex-direction: column;
justify-content: center;
padding: 36px 32px;
background: rgba(255, 255, 255, 0.03);
}
.admin-login-header {
margin-bottom: 24px;
}
.admin-login-header .eyebrow {
margin: 0 0 8px;
color: #1477ff;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.admin-login-header h1 {
margin: 0;
color: #f8fafc;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
}
.admin-form :deep(.el-form-item__label) {
color: #cbd5e1;
font-size: 13px;
font-weight: 600;
padding-bottom: 6px;
}
.admin-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.04);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
border-radius: 10px;
padding: 0 12px;
transition: box-shadow 0.2s, background 0.2s;
}
.admin-form :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.06);
}
.admin-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(20, 119, 255, 0.45) inset,
0 0 0 3px rgba(20, 119, 255, 0.08);
}
.admin-form :deep(.el-input__inner) {
color: #f1f5f9;
font-size: 14px;
height: 44px;
}
.admin-form :deep(.el-input__inner::placeholder) {
color: #64748b;
}
.admin-form :deep(.el-input__prefix-inner) {
color: #64748b;
}
.admin-captcha-row {
display: grid;
grid-template-columns: 1fr 132px;
gap: 10px;
width: 100%;
}
.captcha-image-button {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
padding: 0;
cursor: pointer;
overflow: hidden;
transition: background 0.2s, border-color 0.2s;
}
.captcha-image-button:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.14);
}
.captcha-image-button:disabled {
cursor: wait;
opacity: 0.6;
}
.captcha-image-button img {
display: block;
width: 132px;
height: 44px;
}
.captcha-image-button span {
color: #94a3b8;
font-size: 13px;
}
.admin-login-btn {
width: 100%;
height: 46px;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
border: none;
box-shadow: 0 10px 28px rgba(20, 119, 255, 0.25);
transition: transform 0.15s, box-shadow 0.2s;
}
.admin-login-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 36px rgba(20, 119, 255, 0.32);
}
.back-home {
display: block;
margin-top: 18px;
text-align: center;
color: #64748b;
font-size: 13px;
text-decoration: none;
transition: color 0.2s;
}
.back-home:hover {
color: #94a3b8;
}
/* ========== 响应式 ========== */
@media (max-width: 860px) {
.admin-login-card {
grid-template-columns: 1fr;
max-width: 420px;
}
.admin-login-left {
display: none;
}
.admin-login-right {
padding: 32px 28px;
}
}
@media (max-width: 480px) {
.admin-login-shell {
padding: 16px;
}
.admin-login-right {
padding: 24px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-login-btn {
transition: none;
}
}
</style>
@@ -1,205 +0,0 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminMgrUsers, deleteAdminMgrUser, changeAdminPassword, type AdminMgrUser } from '@/api/adminMgr'
import { useAdminPaginatedTable } from '@/composables/useAdminPaginatedTable'
import { formatDateTime } from '@/utils/time'
import AdminUserDialog from './components/AdminUserDialog.vue'
import AssignRolesDialog from './components/AssignRolesDialog.vue'
const showDialog = ref(false)
const editingAdmin = ref<AdminMgrUser | null>(null)
const showRolesDialog = ref(false)
const rolesAdmin = ref<AdminMgrUser | null>(null)
const showPasswordDialog = ref(false)
const passwordAdmin = ref<AdminMgrUser | null>(null)
const passwordForm = ref({ old_password: '', new_password: '' })
const passwordSubmitting = ref(false)
const { loading, data: admins, total, currentPage, currentPageSize, load: loadAdmins, handleSizeChange } = useAdminPaginatedTable<AdminMgrUser>({
fetchFn: fetchAdminMgrUsers,
})
function openCreate() {
editingAdmin.value = null
showDialog.value = true
}
function openEdit(row: AdminMgrUser) {
editingAdmin.value = row
showDialog.value = true
}
function openRoles(row: AdminMgrUser) {
rolesAdmin.value = row
showRolesDialog.value = true
}
function openPassword(row: AdminMgrUser) {
passwordAdmin.value = row
passwordForm.value = { old_password: '', new_password: '' }
showPasswordDialog.value = true
}
async function handleDelete(row: AdminMgrUser) {
try {
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteAdminMgrUser(row.id)
ElMessage.success('管理员已删除')
await loadAdmins()
} catch {
// 用户取消
}
}
async function handleChangePassword() {
if (!passwordAdmin.value) return
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
ElMessage.warning('请填写完整')
return
}
passwordSubmitting.value = true
try {
await changeAdminPassword(passwordAdmin.value.id, passwordForm.value)
ElMessage.success('密码已修改')
showPasswordDialog.value = false
} catch (error) {
ElMessage.error(readError(error, '修改失败'))
} finally {
passwordSubmitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
const statusLabel: Record<string, string> = {
active: '启用',
disabled: '禁用',
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Admin Users</p>
<h1>管理员管理</h1>
<p>管理后台管理员账号分配角色和权限</p>
</div>
<div class="toolbar-actions">
<el-button @click="loadAdmins">刷新</el-button>
<el-button type="primary" @click="openCreate">新建管理员</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="admins">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column label="角色" min-width="200">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role.id"
size="small"
style="margin-right: 4px; margin-bottom: 2px"
>
{{ role.name }}
</el-tag>
<span v-if="!row.roles?.length" style="color: #8f9bba">未分配</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ statusLabel[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ row.last_login_at ? formatDateTime(row.last_login_at) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openRoles(row)">角色</el-button>
<el-button size="small" @click="openPassword(row)">密码</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadAdmins"
@size-change="handleSizeChange"
/>
</div>
<!-- 新建/编辑对话框 -->
<AdminUserDialog
v-model="showDialog"
:admin="editingAdmin"
@saved="loadAdmins"
/>
<!-- 角色分配对话框 -->
<AssignRolesDialog
v-model="showRolesDialog"
:admin="rolesAdmin"
@saved="loadAdmins"
/>
<!-- 修改密码对话框 -->
<el-dialog
:model-value="showPasswordDialog"
:title="`修改密码 - ${passwordAdmin?.username || ''}`"
width="460px"
@update:model-value="showPasswordDialog = $event"
>
<div class="dialog-body">
<el-form-item label="原密码" class="full-control">
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码" class="full-control">
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="请输入新密码(至少6位)" />
</el-form-item>
</div>
<template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword">确认修改</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.toolbar-actions {
display: flex;
gap: 8px;
}
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
@@ -1,202 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { adminCloseOrder, adminMarkOrderAbnormal, adminRefundOrder, adminRefundStatus, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order, type RefundStatus } from '@/api/orders'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const actionType = ref<'close' | 'abnormal' | ''>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null)
const refunding = ref(false)
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
const canOperate = computed(() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status))
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
await loadRefundStatus()
} finally {
loading.value = false
}
}
async function loadRefundStatus() {
try {
refundStatus.value = await adminRefundStatus(Number(route.params.id))
} catch {
refundStatus.value = null
}
}
function openAction(type: 'close' | 'abnormal') {
actionType.value = type
reason.value = ''
}
async function submitAction() {
if (!order.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭')
} else {
await adminMarkOrderAbnormal(order.value.id, reason.value)
ElMessage.success('订单已标记异常')
}
actionType.value = ''
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
function orderRentedAt() {
return order.value?.rented_at
}
function orderEstimatedEndAt() {
if (!order.value) return undefined
const rentedAt = orderRentedAt()
const durationHours = Number(order.value.estimated_duration_hours || 0)
if (!rentedAt || durationHours <= 0) return undefined
return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString()
}
function money(value: unknown) {
return Math.round(Number(value || 0))
}
async function handleRefund() {
if (!order.value) return
refunding.value = true
try {
refundStatus.value = await adminRefundOrder(order.value.id)
ElMessage.success('退款已发起')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '退款失败'))
} finally {
refunding.value = false
}
}
function refundStatusLabel(status: string) {
const map: Record<string, string> = {
pending: '待退款',
refunded: '已退款',
failed: '退款失败',
}
return map[status] || status || '未退款'
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="order" class="page-header-row">
<div class="page-header">
<p class="eyebrow">{{ order.order_no }}</p>
<h1>订单详情</h1>
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
</div>
<div class="toolbar-actions">
<RouterLink to="/admin/orders">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
<el-button type="primary" :loading="refunding" :disabled="refundStatus?.refund_status === 'refunded'" @click="handleRefund">
{{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
</el-button>
</div>
</div>
<div v-if="order" class="metric-grid dashboard-metrics">
<div class="metric-card">
<span>订单状态</span>
<strong>{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span>交接状态</span>
<strong>{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card">
<span>订单金额</span>
<strong>¥{{ money(order.rent_amount) }}</strong>
</div>
<div class="metric-card">
<span>平台费用</span>
<strong>¥{{ money(order.platform_fee) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>¥{{ money(order.deposit_amount) }}</strong>
</div>
<div v-if="refundStatus" class="metric-card">
<span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
<small v-if="refundStatus.refund_amount_cent > 0">¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small>
</div>
</div>
<div v-if="order" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>用户信息</h2>
<p>租客{{ order.renter_phone || order.renter_id }}</p>
<p>号主{{ order.owner_phone || order.owner_id }}</p>
<p>开始{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
<p>预计截止{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
</div>
<div class="order-panel dashboard-panel">
<h2>交接记录</h2>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-for="record in handoffRecords" :key="record.id" class="timeline-item">
<strong>{{ record.type }}</strong>
<p>{{ record.content }}</p>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
</div>
<div v-if="order" class="order-panel dashboard-panel code-panel">
<h2>账号快照</h2>
<pre>{{ snapshotText }}</pre>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<div v-if="order" class="dialog-body">
<p><strong>{{ order.order_no }}</strong> · {{ order.title }}</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>
</section>
</template>
@@ -1,78 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { fetchAdminOrders, type Order } from '@/api/orders'
import { useAdminTable } from '@/composables/useAdminTable'
import { handoffStatusLabel, orderStatusLabel, settlementStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const status = ref('')
const { loading, data: orders, load: loadOrders } = useAdminTable<Order[]>({
fetchFn: fetchAdminOrders,
initialData: [],
})
const filteredOrders = computed(() => {
if (!status.value) return orders.value
return orders.value.filter((item) => item.status === status.value)
})
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Orders</p>
<h1>订单管理</h1>
<p>查看全量订单交接状态金额和结算状态</p>
</div>
<div class="toolbar-actions">
<el-select v-model="status" clearable placeholder="订单状态" style="width: 180px">
<el-option label="待支付" value="pending_payment" />
<el-option label="待交接" value="pending_handoff" />
<el-option label="使用中" value="renting" />
<el-option label="逾期中" value="overdue" />
<el-option label="待结账确认" value="pending_return_confirm" />
<el-option label="待号主确认结账" value="pending_checkout_confirm" />
<el-option label="待租客确认修正" value="pending_checkout_accept" />
<el-option label="结账争议中" value="checkout_disputing" />
<el-option label="申诉中" value="disputing" />
<el-option label="异常" value="abnormal" />
<el-option label="已完成" value="completed" />
<el-option label="已关闭" value="closed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button @click="loadOrders">刷新</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
<el-table-column prop="order_no" label="订单号" min-width="230" />
<el-table-column prop="title" label="账号" min-width="170" />
<el-table-column prop="renter_phone" label="租客" min-width="130" />
<el-table-column prop="owner_phone" label="号主" min-width="130" />
<el-table-column label="状态" width="140">
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="交接" width="180">
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
</el-table-column>
<el-table-column label="结算" width="120">
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
</el-table-column>
<el-table-column prop="rent_amount" label="订单金额" width="100" />
<el-table-column prop="deposit_amount" label="押金" width="100" />
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<RouterLink :to="`/admin/orders/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
</section>
</template>
-108
View File
@@ -1,108 +0,0 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchRoles, deleteRole, type Role } from '@/api/adminRoles'
import { useAdminTable } from '@/composables/useAdminTable'
import AssignPermissionsDialog from './components/AssignPermissionsDialog.vue'
import RoleDialog from './components/RoleDialog.vue'
const showDialog = ref(false)
const editingRole = ref<Role | null>(null)
const showPermsDialog = ref(false)
const permsRole = ref<Role | null>(null)
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({
fetchFn: fetchRoles,
})
function openCreate() {
editingRole.value = null
showDialog.value = true
}
function openEdit(row: Role) {
editingRole.value = row
showDialog.value = true
}
function openPerms(row: Role) {
permsRole.value = row
showPermsDialog.value = true
}
async function handleDelete(row: Role) {
try {
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteRole(row.id)
ElMessage.success('角色已删除')
await loadRoles()
} catch {
// 用户取消
}
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Roles</p>
<h1>角色管理</h1>
<p>管理系统角色及其权限配置</p>
</div>
<div class="toolbar-actions">
<el-button @click="loadRoles">刷新</el-button>
<el-button type="primary" @click="openCreate">新建角色</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="roles">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="code" label="编码" min-width="120" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="perm_count" label="权限数" width="90" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openPerms(row)">权限</el-button>
<el-button
v-if="row.code !== 'super_admin'"
size="small"
type="danger"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑对话框 -->
<RoleDialog
v-model="showDialog"
:role="editingRole"
@saved="loadRoles"
/>
<!-- 权限分配对话框 -->
<AssignPermissionsDialog
v-model="showPermsDialog"
:role="permsRole"
@saved="loadRoles"
/>
</section>
</template>
<style scoped>
.toolbar-actions {
display: flex;
gap: 8px;
}
</style>
@@ -1,406 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
emptyListingSalePriceConfig,
emptyListingPublishOptions,
mergeListingSalePriceConfig,
mergeListingPublishOptions,
type ListingPublishOptions,
type PublishSalePriceConfig,
} from '@/api/listingOptions'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
mergeHomeConfig,
type HomeBannerSlide,
} from '@/api/homeConfig'
import { fetchSystemConfigs, type SystemConfig } from '@/api/systemConfigs'
import { formatDateTime } from '@/utils/time'
import { safeParseJSON } from '@/utils/json'
import { formatSystemConfigSelectValue } from '@/utils/systemConfigOptions'
// 导入重构后的 Dialog 子组件
import PublishOptionsDialog from './components/PublishOptionsDialog.vue'
import SalePriceDialog from './components/SalePriceDialog.vue'
import HomeAnnouncementsDialog from './components/HomeAnnouncementsDialog.vue'
import HomeBannersDialog from './components/HomeBannersDialog.vue'
import GeneralConfigDialog from './components/GeneralConfigDialog.vue'
import AutoWelcomeConfig from './components/AutoWelcomeConfig.vue'
const loading = ref(false)
const configs = ref<SystemConfig[]>([])
const currentEditingConfig = ref<SystemConfig | null>(null)
// 各种子 Dialog 的可见性控制
const publishVisible = ref(false)
const salePriceVisible = ref(false)
const announcementsVisible = ref(false)
const bannersVisible = ref(false)
const generalVisible = ref(false)
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
const salePriceConfig = computed(() => configs.value.find((item) => item.key === 'listing.sale_price_config') || null)
const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null)
const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null)
const regularConfigs = computed(() =>
configs.value.filter(
(item) =>
item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners',
),
)
// 只读计算属性,用于渲染卡片上的统计信息
const publishStats = computed(() => {
const options = safeParsePublishOptions(publishConfig.value?.value || '')
return {
baseCount:
options.server_options.length +
options.rank_options.length +
options.insurance_options.length +
options.level_options.length,
skinCount: options.skin_groups.reduce((sum, group) => sum + group.options.length, 0),
resourceCount: options.quantity_items.length,
screenshotCount: options.screenshot_slots.length,
regionCount: options.region_options.length,
}
})
const salePriceStats = computed(() => {
const config = safeParseSalePriceConfig(salePriceConfig.value?.value || '')
return {
fixedCount: config.fixed_markup_rules.length,
ratioCount: config.ratio_adjustment_rules.length,
}
})
const homeStats = computed(() => {
const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '')
const banners = parseHomeBanners(homeBannersConfig.value?.value || '')
return {
announcementCount: announcements.length,
bannerCount: banners.length,
}
})
onMounted(loadConfigs)
async function loadConfigs() {
loading.value = true
try {
configs.value = await fetchSystemConfigs()
} finally {
loading.value = false
}
}
function openEdit(row: SystemConfig) {
currentEditingConfig.value = row
if (row.key === 'listing.publish_options') {
publishVisible.value = true
} else if (row.key === 'listing.sale_price_config') {
salePriceVisible.value = true
} else if (row.key === 'mobile.home_announcements') {
announcementsVisible.value = true
} else if (row.key === 'mobile.home_banners') {
bannersVisible.value = true
} else {
generalVisible.value = true
}
}
function safeParsePublishOptions(raw: string) {
const parsed = safeParseJSON(raw, emptyListingPublishOptions)
return cloneOptions(mergeListingPublishOptions(parsed))
}
function safeParseSalePriceConfig(raw: string) {
const parsed = safeParseJSON(raw, emptyListingSalePriceConfig)
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
}
function parseHomeAnnouncements(raw: string) {
const parsed = safeParseJSON(raw, defaultHomeAnnouncements)
return mergeHomeConfig({ announcements: Array.isArray(parsed) ? parsed : [] }).announcements
}
function parseHomeBanners(raw: string) {
const parsed = safeParseJSON(raw, defaultHomeBanners)
return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners)
}
function cloneOptions(options: ListingPublishOptions) {
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
}
function cloneSalePriceConfig(config: PublishSalePriceConfig) {
return JSON.parse(JSON.stringify(config)) as PublishSalePriceConfig
}
function cloneHomeBanners(banners: HomeBannerSlide[]) {
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
}
function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) {
if (!row) return fallback
return formatDateTime(row.updated_at, fallback)
}
function formatConfigValue(row: SystemConfig) {
const trimmed = row.value.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return 'JSON 配置'
}
const selectedLabel = formatSystemConfigSelectValue(row.key, row.value)
if (selectedLabel) {
return selectedLabel
}
return row.value
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Configs</p>
<h1>系统配置</h1>
<p>管理交接超时归还超时短信限流最低押金和抽成比例</p>
</div>
<section v-if="publishConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Publish Options</p>
<h2>发布表单选项</h2>
<span>管理移动端发布页的区服段位皮肤额外消耗品截图材料和地区选项</span>
</div>
<el-button type="primary" @click="openEdit(publishConfig)">编辑发布选项</el-button>
</div>
<div class="publish-stat-grid">
<div class="publish-stat">
<strong>{{ publishStats.baseCount }}</strong>
<span>基础选项</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.skinCount }}</strong>
<span>皮肤项</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.resourceCount }}</strong>
<span>额外消耗品</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.screenshotCount }}</strong>
<span>截图材料</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.regionCount }}</strong>
<span>地区</span>
</div>
</div>
</section>
<section v-if="salePriceConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Sale Pricing</p>
<h2>内部出售定价规则</h2>
<span>管理出售专用比例计算规则仅用于平台内部定价计算不在发布页展示</span>
</div>
<el-button type="primary" @click="openEdit(salePriceConfig)">编辑出售规则</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ salePriceStats.fixedCount }}</strong>
<span>固定加价</span>
</div>
<div class="publish-stat">
<strong>{{ salePriceStats.ratioCount }}</strong>
<span>比例修正</span>
</div>
<div class="publish-stat">
<strong>内部</strong>
<span>不展示给发布用户</span>
</div>
<div class="publish-stat">
<strong>配置</strong>
<span>listing.sale_price_config</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(salePriceConfig, '未初始化') }}</span>
</div>
</div>
</section>
<section v-if="homeAnnouncementsConfig || homeBannersConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Home Content</p>
<h2>移动端首页运营配置</h2>
<span>管理首页公告滚动内容和顶部轮播图保存后移动端会从接口读取最新配置</span>
</div>
<div class="panel-actions">
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)">编辑公告</el-button>
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)">编辑轮播图</el-button>
</div>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ homeStats.announcementCount }}</strong>
<span>公告条数</span>
</div>
<div class="publish-stat">
<strong>{{ homeStats.bannerCount }}</strong>
<span>轮播图</span>
</div>
<div class="publish-stat">
<strong>接口</strong>
<span>/api/mobile-home-config</span>
</div>
<div class="publish-stat">
<strong>公告</strong>
<span>{{ formatHomeConfigStatus(homeAnnouncementsConfig, '未初始化') }}</span>
</div>
<div class="publish-stat">
<strong>轮播</strong>
<span>{{ formatHomeConfigStatus(homeBannersConfig, '未初始化') }}</span>
</div>
</div>
</section>
<AutoWelcomeConfig />
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
<el-table-column prop="key" label="配置项" min-width="260" />
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="formatConfigValue(row) === 'JSON 配置'" type="info">JSON 配置</el-tag>
<span v-else class="config-value">{{ formatConfigValue(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="260" show-overflow-tooltip />
<el-table-column prop="updated_by" label="更新人" width="100" />
<el-table-column label="更新时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<!-- 弹窗编辑器子组件 -->
<PublishOptionsDialog
v-if="publishConfig"
v-model="publishVisible"
:config="publishConfig"
@saved="loadConfigs"
/>
<SalePriceDialog
v-if="salePriceConfig"
v-model="salePriceVisible"
:config="salePriceConfig"
@saved="loadConfigs"
/>
<HomeAnnouncementsDialog
v-if="homeAnnouncementsConfig"
v-model="announcementsVisible"
:config="homeAnnouncementsConfig"
@saved="loadConfigs"
/>
<HomeBannersDialog
v-if="homeBannersConfig"
v-model="bannersVisible"
:config="homeBannersConfig"
@saved="loadConfigs"
/>
<GeneralConfigDialog
v-if="currentEditingConfig"
v-model="generalVisible"
:config="currentEditingConfig"
@saved="loadConfigs"
/>
</section>
</template>
<style scoped>
.publish-config-panel {
display: grid;
gap: 18px;
margin-bottom: 18px;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.publish-config-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.publish-config-main h2 {
margin: 4px 0 6px;
color: #111827;
font-size: 20px;
}
.publish-config-main span {
color: #6b7280;
font-size: 13px;
}
.publish-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.home-stat-grid .publish-stat strong {
font-size: 18px;
}
.publish-stat {
display: grid;
gap: 4px;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
}
.publish-stat strong {
color: #1477ff;
font-size: 22px;
line-height: 1;
}
.publish-stat span,
.config-value {
color: #4b5563;
font-size: 13px;
}
@media (max-width: 1100px) {
.publish-stat-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
-114
View File
@@ -1,114 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
import { useAdminPaginatedTable } from '@/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const submitting = ref(false)
const activeUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
const { loading, data: users, total, currentPage, currentPageSize, load: loadUsers, handleSizeChange } = useAdminPaginatedTable<AdminUserItem>({
fetchFn: fetchAdminUsers,
})
function openFreeze(row: AdminUserItem) {
activeUser.value = row
freezeReason.value = ''
}
async function handleFreeze() {
if (!activeUser.value) return
submitting.value = true
try {
await freezeAdminUser(activeUser.value.id, freezeReason.value)
ElMessage.success('用户已冻结')
activeUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '冻结失败'))
} finally {
submitting.value = false
}
}
async function handleUnfreeze(row: AdminUserItem) {
submitting.value = true
try {
await unfreezeAdminUser(row.id)
ElMessage.success('用户已解冻')
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '解冻失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Users</p>
<h1>用户管理</h1>
<p>查看用户信息和状态处理冻结与解冻</p>
</div>
<el-button @click="loadUsers">刷新</el-button>
</div>
<el-table v-loading="loading" class="table-panel" :data="users">
<el-table-column prop="id" label="用户ID" width="80" />
<el-table-column prop="nickname" label="昵称" min-width="130" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="注册时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button v-if="row.status === 'active'" size="small" type="danger" :loading="submitting" @click="openFreeze(row)">
冻结
</el-button>
<el-button v-else size="small" type="primary" :loading="submitting" @click="handleUnfreeze(row)">解冻</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadUsers"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
<div v-if="activeUser" class="dialog-body">
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
<el-input v-model="freezeReason" type="textarea" :rows="4" placeholder="填写冻结原因,便于审计追踪" />
</div>
<template #footer>
<el-button @click="activeUser = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -1,164 +0,0 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/api/adminWallet'
import { balanceTypeLabel, ledgerDirectionLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const ledger = ref<AdminWalletLedger[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
user_id: '',
order_id: '',
biz_type: '',
})
const inAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'in').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
const outAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
onMounted(loadLedger)
async function loadLedger() {
loading.value = true
try {
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminWalletLedger(query)
ledger.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.user_id = ''
filters.order_id = ''
filters.biz_type = ''
currentPage.value = 1
void loadLedger()
}
function handleSizeChange() {
currentPage.value = 1
loadLedger()
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
}
function directionType(direction: string) {
if (direction === 'in') return 'success'
if (direction === 'out') return 'danger'
return 'warning'
}
function directionLabel(direction: string) {
return ledgerDirectionLabel(direction)
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Wallet Ledger</p>
<h1>资金流水</h1>
<p>查看订单金额押金冻结解冻退款和结算流水</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>入账合计</span>
<strong>{{ money(inAmount) }}</strong>
</div>
<div class="metric-card">
<span>出账合计</span>
<strong>{{ money(outAmount) }}</strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户筛选" />
</el-form-item>
<el-form-item label="订单 ID">
<el-input v-model="filters.order_id" clearable placeholder="按订单筛选" />
</el-form-item>
<el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
<el-option label="订单冻结" value="order_freeze" />
<el-option label="订单取消释放" value="order_cancel_release" />
<el-option label="订单结算" value="order_settlement" />
<el-option label="押金退回" value="deposit_refund" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="ledger">
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
<el-table-column label="用户" min-width="130">
<template #default="{ row }">
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
</template>
</el-table-column>
<el-table-column label="订单" min-width="160">
<template #default="{ row }">
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="150" />
<el-table-column label="方向" width="90">
<template #default="{ row }">
<el-tag :type="directionType(row.direction)">{{ directionLabel(row.direction) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="110">
<template #default="{ row }">{{ money(Number(row.amount)) }}</template>
</el-table-column>
<el-table-column label="余额类型" width="110">
<template #default="{ row }">{{ balanceTypeLabel(row.balance_type) }}</template>
</el-table-column>
<el-table-column label="变化后余额" width="130">
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLedger"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
@@ -1,124 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { createAdminMgrUser, updateAdminMgrUser, type AdminMgrUser, type CreateAdminRequest, type UpdateAdminRequest } from '@/api/adminMgr'
const props = defineProps<{
modelValue: boolean
admin?: AdminMgrUser | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const form = ref({
username: '',
password: '',
nickname: '',
status: 'active',
})
const isEdit = ref(false)
watch(
() => props.modelValue,
(val) => {
if (val) {
if (props.admin) {
isEdit.value = true
form.value = {
username: props.admin.username,
password: '',
nickname: props.admin.nickname,
status: props.admin.status,
}
} else {
isEdit.value = false
form.value = { username: '', password: '', nickname: '', status: 'active' }
}
}
},
{ immediate: true },
)
async function handleSave() {
submitting.value = true
try {
if (isEdit.value && props.admin) {
const req: UpdateAdminRequest = {
nickname: form.value.nickname,
status: form.value.status,
}
await updateAdminMgrUser(props.admin.id, req)
ElMessage.success('管理员已更新')
} else {
const req: CreateAdminRequest = {
username: form.value.username,
password: form.value.password,
nickname: form.value.nickname,
}
await createAdminMgrUser(req)
ElMessage.success('管理员已创建')
}
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑管理员' : '新建管理员'"
width="500px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<el-form-item label="用户名" class="full-control">
<el-input v-model="form.username" :disabled="isEdit" placeholder="请输入用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码" class="full-control">
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码(至少6位)" />
</el-form-item>
<el-form-item label="昵称" class="full-control">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item v-if="isEdit" label="状态" class="full-control">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
@@ -1,177 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { fetchPermissions, fetchRole, assignRolePermissions, type Permission, type Role } from '@/api/adminRoles'
const props = defineProps<{
modelValue: boolean
role: Role | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const loading = ref(false)
const allPermissions = ref<Permission[]>([])
const selectedPermIds = ref<number[]>([])
// 按 resource 分组
const groupedPermissions = ref<Record<string, Permission[]>>({})
watch(
() => props.modelValue,
async (val) => {
if (val && props.role) {
loading.value = true
try {
const [perms, roleDetail] = await Promise.all([fetchPermissions(), fetchRole(props.role.id)])
allPermissions.value = perms
selectedPermIds.value = (roleDetail.permissions || []).map((p) => p.id)
// 按 resource 分组
const grouped: Record<string, Permission[]> = {}
for (const p of perms) {
const arr = grouped[p.resource] ?? (grouped[p.resource] = [])
arr.push(p)
}
groupedPermissions.value = grouped
} catch {
ElMessage.error('加载权限列表失败')
} finally {
loading.value = false
}
}
},
)
async function handleSave() {
if (!props.role) return
submitting.value = true
try {
await assignRolePermissions(props.role.id, selectedPermIds.value)
ElMessage.success('权限已分配')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '分配失败'))
} finally {
submitting.value = false
}
}
function toggleGroup(perms: Permission[]) {
const ids = perms.map((p) => p.id)
const allSelected = ids.every((id) => selectedPermIds.value.includes(id))
if (allSelected) {
selectedPermIds.value = selectedPermIds.value.filter((id) => !ids.includes(id))
} else {
const newIds = [...selectedPermIds.value]
for (const id of ids) {
if (!newIds.includes(id)) newIds.push(id)
}
selectedPermIds.value = newIds
}
}
function isGroupAllSelected(perms: Permission[]) {
return perms.length > 0 && perms.every((p) => selectedPermIds.value.includes(p.id))
}
function isGroupPartial(perms: Permission[]) {
return perms.some((p) => selectedPermIds.value.includes(p.id)) && !isGroupAllSelected(perms)
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
const resourceLabels: Record<string, string> = {
dashboard: '仪表盘',
user: '用户管理',
order: '订单管理',
listing: '商品管理',
dispute: '仲裁中心',
chat: '客服群聊',
wallet: '资金流水',
admin_user: '管理员管理',
role: '角色管理',
system_config: '系统配置',
audit_log: '审计日志',
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="`分配权限 - ${role?.name || ''}`"
width="600px"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="dialog-body">
<p v-if="role" class="role-info">
<strong>{{ role.name }}</strong> · {{ role.description }}
</p>
<div v-for="(perms, resource) in groupedPermissions" :key="resource" class="perm-group">
<div class="perm-group-header">
<el-checkbox
:model-value="isGroupAllSelected(perms)"
:indeterminate="isGroupPartial(perms)"
@change="toggleGroup(perms)"
>
<strong>{{ resourceLabels[resource] || resource }}</strong>
</el-checkbox>
</div>
<div class="perm-group-items">
<el-checkbox
v-for="perm in perms"
:key="perm.id"
v-model="selectedPermIds"
:value="perm.id"
:label="perm.id"
>
{{ perm.name }}
</el-checkbox>
</div>
</div>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.role-info {
margin: 0;
color: #374151;
}
.perm-group {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
}
.perm-group-header {
margin-bottom: 8px;
}
.perm-group-items {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding-left: 24px;
}
</style>
@@ -1,115 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { fetchRoles, type Role } from '@/api/adminRoles'
import { assignAdminRoles, type AdminMgrUser } from '@/api/adminMgr'
const props = defineProps<{
modelValue: boolean
admin: AdminMgrUser | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const loading = ref(false)
const allRoles = ref<Role[]>([])
const selectedRoleIds = ref<number[]>([])
watch(
() => props.modelValue,
async (val) => {
if (val && props.admin) {
loading.value = true
try {
allRoles.value = await fetchRoles()
selectedRoleIds.value = props.admin.roles.map((r) => r.id)
} catch {
ElMessage.error('加载角色列表失败')
} finally {
loading.value = false
}
}
},
)
async function handleSave() {
if (!props.admin) return
submitting.value = true
try {
await assignAdminRoles(props.admin.id, selectedRoleIds.value)
ElMessage.success('角色已分配')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '分配失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="`分配角色 - ${admin?.username || ''}`"
width="500px"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="dialog-body">
<p v-if="admin" class="admin-info">
<strong>{{ admin.username }}</strong> · {{ admin.nickname }}
</p>
<el-checkbox-group v-model="selectedRoleIds">
<div v-for="role in allRoles" :key="role.id" class="role-option">
<el-checkbox :value="role.id" :label="role.id">
<span class="role-name">{{ role.name }}</span>
<span class="role-desc">{{ role.description }}</span>
</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.admin-info {
margin: 0;
color: #374151;
}
.role-option {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.role-option:last-child {
border-bottom: none;
}
.role-name {
font-weight: 500;
margin-right: 8px;
}
.role-desc {
color: #8f9bba;
font-size: 13px;
}
</style>
@@ -1,136 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchAutoWelcomeMessage, updateAutoWelcomeMessage } from '@/api/chats'
const loading = ref(false)
const saving = ref(false)
const message = ref('')
const editing = ref(false)
onMounted(async () => {
await loadMessage()
})
async function loadMessage() {
loading.value = true
try {
message.value = await fetchAutoWelcomeMessage()
} catch {
ElMessage.error('加载自动话术失败')
} finally {
loading.value = false
}
}
async function handleSave() {
if (!message.value.trim()) {
ElMessage.warning('话术内容不能为空')
return
}
saving.value = true
try {
await updateAutoWelcomeMessage(message.value.trim())
ElMessage.success('保存成功')
editing.value = false
} catch {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
function startEdit() {
editing.value = true
}
function cancelEdit() {
editing.value = false
loadMessage()
}
</script>
<template>
<div class="auto-welcome-config" v-loading="loading">
<div class="config-header">
<h3>建群自动话术</h3>
<p>订单群聊创建后自动发送的欢迎消息</p>
</div>
<div class="config-content">
<template v-if="editing">
<el-input
v-model="message"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="输入建群后自动发送的话术"
/>
<div class="config-actions">
<el-button size="small" @click="cancelEdit">取消</el-button>
<el-button size="small" type="primary" :loading="saving" @click="handleSave">
保存
</el-button>
</div>
</template>
<template v-else>
<div class="preview-box">
<p>{{ message || '未设置' }}</p>
</div>
<el-button size="small" @click="startEdit">编辑</el-button>
</template>
</div>
</div>
</template>
<style scoped>
.auto-welcome-config {
padding: 20px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.config-header {
margin-bottom: 16px;
}
.config-header h3 {
margin: 0 0 4px;
font-size: 16px;
color: #111827;
}
.config-header p {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.config-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.preview-box {
padding: 12px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 6px;
min-height: 60px;
}
.preview-box p {
margin: 0;
color: #374151;
line-height: 1.5;
white-space: pre-wrap;
}
</style>
@@ -1,134 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
import { getSystemConfigSelectOptions, type SystemConfigOption } from '@/utils/systemConfigOptions'
const props = defineProps<{
modelValue: boolean
config: SystemConfig
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const value = ref('')
const description = ref('')
watch(
() => props.modelValue,
(val) => {
if (val) {
value.value = props.config.value || ''
description.value = props.config.description || ''
}
},
{ immediate: true }
)
const isStructuredConfig = computed(() => {
const trimmed = value.value.trim()
return trimmed.startsWith('{') || trimmed.startsWith('[')
})
const selectOptions = computed<SystemConfigOption[] | null>(() => {
const options = getSystemConfigSelectOptions(props.config.key)
if (!options) return null
if (!value.value || options.some((item) => item.value === value.value)) {
return options
}
return [{ label: `当前值:${value.value}`, value: value.value }, ...options]
})
async function handleSave() {
submitting.value = true
try {
await updateSystemConfig(props.config.key, {
value: value.value,
description: description.value,
})
ElMessage.success('配置已更新')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑系统配置"
width="560px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<el-form-item v-if="isStructuredConfig" label="配置值 (JSON)" class="full-control">
<el-input v-model="value" type="textarea" :rows="12" placeholder="配置值 JSON" />
</el-form-item>
<el-form-item v-else-if="selectOptions" label="配置值" class="full-control">
<el-select v-model="value" class="full-select" placeholder="请选择配置值">
<el-option
v-for="option in selectOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item v-else label="配置值" class="full-control">
<el-input v-model="value" placeholder="配置值" />
</el-form-item>
<el-form-item label="配置说明" class="desc-item">
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.config-key-label {
margin: 0;
color: #374151;
}
.full-control {
width: 100%;
}
.full-select {
width: 100%;
}
.desc-item {
margin-top: 14px;
margin-bottom: 0;
}
</style>
@@ -1,153 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { defaultHomeAnnouncements, mergeHomeConfig } from '@/api/homeConfig'
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
import { safeParseJSON } from '@/utils/json'
const props = defineProps<{
modelValue: boolean
config: SystemConfig
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const description = ref('')
const homeAnnouncementLines = ref('')
watch(
() => props.modelValue,
(val) => {
if (val) {
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(props.config.value))
description.value = props.config.description || ''
}
},
{ immediate: true }
)
function parseHomeAnnouncements(raw: string) {
const parsed = safeParseJSON(raw, defaultHomeAnnouncements)
return mergeHomeConfig({ announcements: Array.isArray(parsed) ? parsed : [] }).announcements
}
function linesToItems(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
function itemsToLines(items: string[]) {
return items.join('\n')
}
function resetHomeAnnouncements() {
homeAnnouncementLines.value = itemsToLines(defaultHomeAnnouncements)
}
async function handleSave() {
submitting.value = true
try {
const value = JSON.stringify(linesToItems(homeAnnouncementLines.value), null, 2)
await updateSystemConfig(props.config.key, {
value,
description: description.value,
})
ElMessage.success('配置已更新')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑首页公告"
width="680px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<div class="home-config-editor">
<div class="editor-toolbar">
<span>首页公告</span>
<el-button size="small" @click="resetHomeAnnouncements">恢复默认公告</el-button>
</div>
<el-form-item label="公告内容" class="full-control">
<el-input
v-model="homeAnnouncementLines"
type="textarea"
:rows="8"
placeholder="一行一条公告,移动端会自动轮播展示"
/>
</el-form-item>
</div>
<el-form-item label="配置说明" class="desc-item">
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.config-key-label {
margin: 0;
color: #374151;
}
.home-config-editor {
display: grid;
gap: 14px;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor-toolbar span {
color: #30343a;
font-weight: 700;
}
.full-control {
width: 100%;
}
.desc-item {
margin-top: 14px;
margin-bottom: 0;
}
</style>
@@ -1,286 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { uploadAdminFile } from '@/api/files'
import { defaultHomeBanners, mergeHomeConfig, type HomeBannerSlide } from '@/api/homeConfig'
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
import { safeParseJSON } from '@/utils/json'
const props = defineProps<{
modelValue: boolean
config: SystemConfig
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const description = ref('')
const homeBannersDraft = ref<HomeBannerSlide[]>([])
const uploadingBannerIndex = ref<number | null>(null)
watch(
() => props.modelValue,
(val) => {
if (val) {
homeBannersDraft.value = parseHomeBanners(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true }
)
function parseHomeBanners(raw: string) {
const parsed = safeParseJSON(raw, defaultHomeBanners)
return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners)
}
function cloneHomeBanners(banners: HomeBannerSlide[]) {
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
}
function addHomeBanner() {
homeBannersDraft.value.push({
eyebrow: '首页推荐',
title: '',
badge: 'NEW',
pill: '',
tone: 'blue',
image_url: '',
})
}
function removeHomeBanner(index: number) {
homeBannersDraft.value.splice(index, 1)
}
function resetHomeBanners() {
homeBannersDraft.value = cloneHomeBanners(defaultHomeBanners)
}
async function handleHomeBannerUpload(event: Event, index: number) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingBannerIndex.value = index
try {
const uploaded = await uploadAdminFile(file, 'home-banner')
const banner = homeBannersDraft.value[index]
if (banner) {
banner.image_url = uploaded.url
if (!banner.title) {
banner.title = banner.eyebrow || '首页轮播图'
}
}
ElMessage.success('图片已上传')
} catch (error) {
ElMessage.error(readError(error, '图片上传失败'))
} finally {
uploadingBannerIndex.value = null
}
}
async function handleSave() {
submitting.value = true
try {
const value = JSON.stringify(
homeBannersDraft.value.filter((item) => item.title.trim() || item.image_url?.trim()),
null,
2
)
await updateSystemConfig(props.config.key, {
value,
description: description.value,
})
ElMessage.success('配置已更新')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑首页轮播图"
width="920px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<div class="home-config-editor">
<div class="editor-toolbar">
<span>首页轮播图</span>
<div class="panel-actions">
<el-button size="small" @click="resetHomeBanners">恢复默认轮播</el-button>
<el-button size="small" type="primary" @click="addHomeBanner">添加轮播</el-button>
</div>
</div>
<el-table :data="homeBannersDraft" size="small" border>
<el-table-column label="图片" min-width="240">
<template #default="{ row, $index }">
<div class="banner-image-editor">
<el-input v-model="row.image_url" placeholder="图片 URL,留空使用文字卡片" />
<div class="banner-image-tools">
<img v-if="row.image_url" :src="row.image_url" alt="轮播图预览" />
<div v-else class="empty-image">无图</div>
<label class="upload-trigger">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
@change="handleHomeBannerUpload($event, $index)"
/>
{{ uploadingBannerIndex === $index ? '上传中' : '上传图片' }}
</label>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="眉标" min-width="160">
<template #default="{ row }"><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区" /></template>
</el-table-column>
<el-table-column label="主标题" min-width="220">
<template #default="{ row }"><el-input v-model="row.title" placeholder="轮播主文案" /></template>
</el-table-column>
<el-table-column label="角标" width="110">
<template #default="{ row }"><el-input v-model="row.badge" placeholder="HOT" /></template>
</el-table-column>
<el-table-column label="胶囊文案" min-width="220">
<template #default="{ row }"><el-input v-model="row.pill" placeholder="底部补充文案" /></template>
</el-table-column>
<el-table-column label="色调" width="130">
<template #default="{ row }">
<el-select v-model="row.tone" placeholder="色调">
<el-option label="蓝色" value="blue" />
<el-option label="绿色" value="green" />
<el-option label="橙色" value="orange" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-form-item label="配置说明" class="desc-item">
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
max-height: 65vh;
overflow-y: auto;
padding-right: 8px;
}
.config-key-label {
margin: 0;
color: #374151;
}
.home-config-editor {
display: grid;
gap: 14px;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor-toolbar span {
color: #30343a;
font-weight: 700;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.banner-image-editor {
display: grid;
gap: 8px;
}
.banner-image-tools {
display: flex;
align-items: center;
gap: 8px;
}
.banner-image-tools img,
.empty-image {
width: 68px;
height: 38px;
border-radius: 6px;
background: #f3f4f6;
object-fit: cover;
}
.empty-image {
display: grid;
place-items: center;
color: #9ca3af;
font-size: 12px;
}
.upload-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
color: #606266;
cursor: pointer;
font-size: 12px;
}
.upload-trigger input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.desc-item {
margin-top: 14px;
margin-bottom: 0;
}
</style>
@@ -1,574 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import {
emptyListingPublishOptions,
mergeListingPublishOptions,
type ListingPublishOptions,
} from '@/api/listingOptions'
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
import { safeParseJSON } from '@/utils/json'
const props = defineProps<{
modelValue: boolean
config: SystemConfig
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const description = ref('')
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListingPublishOptions))
watch(
() => props.modelValue,
(val) => {
if (val) {
publishOptionsDraft.value = parsePublishOptions(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true }
)
function parsePublishOptions(raw: string) {
const parsed = safeParseJSON(raw, emptyListingPublishOptions)
return cloneOptions(mergeListingPublishOptions(parsed))
}
function cloneOptions(options: ListingPublishOptions) {
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
}
function linesToItems(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
function itemsToLines(items: string[]) {
return items.join('\n')
}
function updateOptionLines(
key: keyof Pick<
ListingPublishOptions,
| 'server_options'
| 'face_options'
| 'rank_options'
| 'insurance_options'
| 'level_options'
| 'login_method_options'
| 'region_options'
| 'ban_record_options'
| 'ban_evidence_options'
>,
nextValue: string
) {
publishOptionsDraft.value[key] = linesToItems(nextValue)
}
function updateSkinGroupOptions(index: number, nextValue: string) {
const group = publishOptionsDraft.value.skin_groups[index]
if (group) {
group.options = linesToItems(nextValue)
}
}
function addSkinGroup() {
publishOptionsDraft.value.skin_groups.push({
key: `group_${Date.now()}`,
title: '新皮肤分类',
options: [],
})
}
function removeSkinGroup(index: number) {
publishOptionsDraft.value.skin_groups.splice(index, 1)
}
function addQuantityItem() {
publishOptionsDraft.value.quantity_items.push({
key: `item_${Date.now()}`,
label: '',
price: '',
})
}
function removeQuantityItem(index: number) {
publishOptionsDraft.value.quantity_items.splice(index, 1)
}
function addScreenshotSlot() {
publishOptionsDraft.value.screenshot_slots.push({
key: `screenshot_${Date.now()}`,
label: '',
required: false,
hint: '',
})
}
function removeScreenshotSlot(index: number) {
publishOptionsDraft.value.screenshot_slots.splice(index, 1)
}
function addInsuranceBaseRatio() {
publishOptionsDraft.value.ratio_config.insurance_base_ratios.push({
insurance: '',
ratio: 0,
})
}
function removeInsuranceBaseRatio(index: number) {
publishOptionsDraft.value.ratio_config.insurance_base_ratios.splice(index, 1)
}
function addRatioConfigItem() {
publishOptionsDraft.value.ratio_config.config_items.push({
key: `config_${Date.now()}`,
label: '',
kind: 'skin_group',
group_key: '',
missing_penalty: 1,
})
}
function removeRatioConfigItem(index: number) {
publishOptionsDraft.value.ratio_config.config_items.splice(index, 1)
}
function addCoinCorrection() {
publishOptionsDraft.value.ratio_config.coin_corrections.push({
threshold_m: 0,
correction: 0,
})
}
function removeCoinCorrection(index: number) {
publishOptionsDraft.value.ratio_config.coin_corrections.splice(index, 1)
}
function addDepositSkinGroupRule() {
publishOptionsDraft.value.deposit_recommend_config.skin_group_rules.push({
group_key: '',
label: '',
amount_per_item: 0,
})
}
function removeDepositSkinGroupRule(index: number) {
publishOptionsDraft.value.deposit_recommend_config.skin_group_rules.splice(index, 1)
}
async function handleSave() {
submitting.value = true
try {
const value = JSON.stringify(publishOptionsDraft.value, null, 2)
await updateSystemConfig(props.config.key, {
value,
description: description.value,
})
ElMessage.success('配置已更新')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑发布选项配置"
width="920px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<div class="publish-options-editor">
<div class="editor-toolbar">
<span>发布页选项配置</span>
</div>
<div class="editor-grid">
<el-form-item label="区服">
<el-input
:model-value="itemsToLines(publishOptionsDraft.server_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('server_options', $event)"
/>
</el-form-item>
<el-form-item label="段位">
<el-input
:model-value="itemsToLines(publishOptionsDraft.rank_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('rank_options', $event)"
/>
</el-form-item>
<el-form-item label="保险">
<el-input
:model-value="itemsToLines(publishOptionsDraft.insurance_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('insurance_options', $event)"
/>
</el-form-item>
<el-form-item label="体力/负重">
<el-input
:model-value="itemsToLines(publishOptionsDraft.level_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('level_options', $event)"
/>
</el-form-item>
<el-form-item label="人脸选项">
<el-input
:model-value="itemsToLines(publishOptionsDraft.face_options)"
type="textarea"
:rows="3"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('face_options', $event)"
/>
</el-form-item>
<el-form-item label="上号方式">
<el-input
:model-value="itemsToLines(publishOptionsDraft.login_method_options)"
type="textarea"
:rows="3"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('login_method_options', $event)"
/>
</el-form-item>
</div>
<el-form-item label="常用登录地区">
<el-input
:model-value="itemsToLines(publishOptionsDraft.region_options)"
type="textarea"
:rows="5"
placeholder="一行一个省市"
@update:model-value="updateOptionLines('region_options', $event)"
/>
</el-form-item>
<div class="editor-grid">
<el-form-item label="封禁记录">
<el-input
:model-value="itemsToLines(publishOptionsDraft.ban_record_options)"
type="textarea"
:rows="3"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('ban_record_options', $event)"
/>
</el-form-item>
<el-form-item label="需传安全图">
<el-input
:model-value="itemsToLines(publishOptionsDraft.ban_evidence_options)"
type="textarea"
:rows="3"
placeholder="选择这些封禁记录时,腾讯安全中心截图必传"
@update:model-value="updateOptionLines('ban_evidence_options', $event)"
/>
</el-form-item>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>发布规则</strong>
</div>
<el-form-item label="最低烽火等级">
<el-input-number v-model="publishOptionsDraft.fire_level_min" :min="1" :step="1" class="full-control" />
</el-form-item>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>押金与价格提示</strong>
</div>
<el-form-item label="押金提示">
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="价格提示">
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" class="full-control" />
</el-form-item>
<el-form-item label="比例说明">
<el-input v-model="publishOptionsDraft.price_config.ratio_description" class="full-control" />
</el-form-item>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>智能推荐押金</strong>
<el-button size="small" @click="addDepositSkinGroupRule">添加皮肤规则</el-button>
</div>
<el-form-item label="基础押金">
<el-input-number
v-model="publishOptionsDraft.deposit_recommend_config.base_amount"
:min="0"
:step="5"
class="full-control"
/>
</el-form-item>
<el-table :data="publishOptionsDraft.deposit_recommend_config.skin_group_rules" size="small" border>
<el-table-column label="皮肤分组 Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.group_key" placeholder="operatorRed" /></template>
</el-table-column>
<el-table-column label="名称" min-width="140">
<template #default="{ row }"><el-input v-model="row.label" placeholder="干员红皮" /></template>
</el-table-column>
<el-table-column label="每个增加押金" min-width="140">
<template #default="{ row }"><el-input-number v-model="row.amount_per_item" :min="0" :step="5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeDepositSkinGroupRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>比例计算配置</strong>
</div>
<div class="editor-block-title subtle-title">
<span>保险基础比例</span>
<el-button size="small" @click="addInsuranceBaseRatio">添加保险比例</el-button>
</div>
<el-table :data="publishOptionsDraft.ratio_config.insurance_base_ratios" size="small" border>
<el-table-column label="保险" min-width="160">
<template #default="{ row }"><el-input v-model="row.insurance" placeholder="3*3" /></template>
</el-table-column>
<el-table-column label="基础比例" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.ratio" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeInsuranceBaseRatio($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>配置项缺失加成</span>
<el-button size="small" @click="addRatioConfigItem">添加配置项</el-button>
</div>
<el-table :data="publishOptionsDraft.ratio_config.config_items" size="small" border>
<el-table-column label="Key" min-width="140">
<template #default="{ row }"><el-input v-model="row.key" /></template>
</el-table-column>
<el-table-column label="名称" min-width="140">
<template #default="{ row }"><el-input v-model="row.label" /></template>
</el-table-column>
<el-table-column label="类型" min-width="160">
<template #default="{ row }">
<el-select v-model="row.kind">
<el-option label="皮肤分组" value="skin_group" />
<el-option label="满体力" value="max_stamina" />
<el-option label="满负重" value="max_load" />
</el-select>
</template>
</el-table-column>
<el-table-column label="皮肤分组 Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.group_key" /></template>
</el-table-column>
<el-table-column label="缺失加成" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.missing_penalty" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeRatioConfigItem($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>大额币修正</span>
<el-button size="small" @click="addCoinCorrection">添加修正</el-button>
</div>
<el-table :data="publishOptionsDraft.ratio_config.coin_corrections" size="small" border>
<el-table-column label="大于 M" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.threshold_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="比例加成" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.correction" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeCoinCorrection($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>皮肤分类</strong>
<el-button size="small" @click="addSkinGroup">添加分类</el-button>
</div>
<div v-for="(group, index) in publishOptionsDraft.skin_groups" :key="`${group.key}-${index}`" class="skin-config-row">
<el-input v-model="group.key" placeholder="分类 key" />
<el-input v-model="group.title" placeholder="分类名称" />
<el-input
:model-value="itemsToLines(group.options)"
type="textarea"
:rows="4"
placeholder="皮肤名称,一行一个"
@update:model-value="updateSkinGroupOptions(index, $event)"
/>
<el-button type="danger" plain @click="removeSkinGroup(index)">删除</el-button>
</div>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>额外消耗品</strong>
<el-button size="small" @click="addQuantityItem">添加消耗品</el-button>
</div>
<el-table :data="publishOptionsDraft.quantity_items" size="small" border>
<el-table-column label="Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.key" /></template>
</el-table-column>
<el-table-column label="名称" min-width="150">
<template #default="{ row }"><el-input v-model="row.label" /></template>
</el-table-column>
<el-table-column label="价格" min-width="130">
<template #default="{ row }"><el-input v-model="row.price" /></template>
</el-table-column>
<el-table-column label="提示" min-width="220">
<template #default="{ row }"><el-input v-model="row.placeholder" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>截图材料</strong>
<el-button size="small" @click="addScreenshotSlot">添加截图项</el-button>
</div>
<el-table :data="publishOptionsDraft.screenshot_slots" size="small" border>
<el-table-column label="Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.key" /></template>
</el-table-column>
<el-table-column label="名称" min-width="150">
<template #default="{ row }"><el-input v-model="row.label" /></template>
</el-table-column>
<el-table-column label="必填" width="90">
<template #default="{ row }"><el-switch v-model="row.required" /></template>
</el-table-column>
<el-table-column label="提示" min-width="260">
<template #default="{ row }"><el-input v-model="row.hint" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-form-item label="配置说明" class="desc-item">
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
max-height: 65vh;
overflow-y: auto;
padding-right: 8px;
}
.config-key-label {
margin: 0;
color: #374151;
}
.publish-options-editor {
display: grid;
gap: 16px;
}
.editor-toolbar,
.editor-block-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor-toolbar span {
color: #30343a;
font-weight: 700;
}
.editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.editor-block {
display: grid;
gap: 10px;
}
.subtle-title {
margin-top: 6px;
color: #4b5563;
font-size: 13px;
}
.skin-config-row {
display: grid;
grid-template-columns: 140px 180px minmax(0, 1fr) 72px;
gap: 8px;
align-items: start;
}
.full-control {
width: 100%;
}
.desc-item {
margin-top: 14px;
margin-bottom: 0;
}
</style>
@@ -1,281 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchQuickReplies,
createQuickReply,
updateQuickReply,
deleteQuickReply,
type QuickReply,
} from '@/api/chats'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const visible = ref(false)
const loading = ref(false)
const replies = ref<QuickReply[]>([])
const editingId = ref<number | null>(null)
const form = ref({
title: '',
content: '',
sort_order: 0,
is_global: false,
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
loadReplies()
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
async function loadReplies() {
loading.value = true
try {
replies.value = await fetchQuickReplies()
} catch {
ElMessage.error('加载快捷回复失败')
} finally {
loading.value = false
}
}
function resetForm() {
form.value = { title: '', content: '', sort_order: 0, is_global: false }
editingId.value = null
}
function startEdit(reply: QuickReply) {
editingId.value = reply.id
form.value = {
title: reply.title,
content: reply.content,
sort_order: reply.sort_order,
is_global: reply.is_global,
}
}
async function handleSubmit() {
if (!form.value.title || !form.value.content) {
ElMessage.warning('标题和内容不能为空')
return
}
try {
if (editingId.value) {
await updateQuickReply(editingId.value, form.value)
ElMessage.success('更新成功')
} else {
await createQuickReply(form.value.title, form.value.content, form.value.sort_order, form.value.is_global)
ElMessage.success('创建成功')
}
resetForm()
await loadReplies()
emit('success')
} catch {
ElMessage.error('操作失败')
}
}
async function handleDelete(reply: QuickReply) {
if (reply.is_global) {
ElMessage.warning('不能删除全局快捷回复')
return
}
try {
await ElMessageBox.confirm('确定删除这条快捷回复?', '确认删除', {
type: 'warning',
})
await deleteQuickReply(reply.id)
ElMessage.success('删除成功')
await loadReplies()
emit('success')
} catch { /* ignore */ }
}
function handleCancel() {
resetForm()
}
</script>
<template>
<el-dialog v-model="visible" title="快捷回复管理" width="600px">
<div class="quick-reply-content">
<div class="reply-form">
<el-input
v-model="form.title"
placeholder="快捷回复标题"
maxlength="64"
style="margin-bottom: 8px"
/>
<el-input
v-model="form.content"
type="textarea"
:rows="3"
placeholder="回复内容"
maxlength="500"
show-word-limit
style="margin-bottom: 8px"
/>
<div class="form-actions">
<div class="form-meta">
<label class="sort-field">
<span>排序</span>
<el-input-number
v-model="form.sort_order"
:min="0"
:max="999"
size="small"
placeholder="排序"
style="width: 120px"
/>
</label>
<el-radio-group v-model="form.is_global" size="small" :disabled="!!editingId">
<el-radio-button :value="false">个人</el-radio-button>
<el-radio-button :value="true">全局</el-radio-button>
</el-radio-group>
</div>
<div>
<el-button v-if="editingId" size="small" @click="handleCancel">取消</el-button>
<el-button size="small" type="primary" @click="handleSubmit">
{{ editingId ? '更新' : '添加' }}
</el-button>
</div>
</div>
</div>
<div class="reply-list" v-loading="loading">
<div
v-for="reply in replies"
:key="reply.id"
class="reply-item"
:class="{ global: reply.is_global }"
>
<div class="reply-info">
<div class="reply-header">
<span class="reply-title">{{ reply.title }}</span>
<el-tag v-if="reply.is_global" size="small" type="info">全局</el-tag>
<el-tag v-else size="small" type="success">个人</el-tag>
</div>
<div class="reply-content">{{ reply.content }}</div>
</div>
<div class="reply-actions">
<el-button link size="small" @click="startEdit(reply)">编辑</el-button>
<el-button
v-if="!reply.is_global"
link
size="small"
type="danger"
@click="handleDelete(reply)"
>
删除
</el-button>
</div>
</div>
<el-empty v-if="!loading && replies.length === 0" description="暂无快捷回复" />
</div>
</div>
</el-dialog>
</template>
<style scoped>
.quick-reply-content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 60vh;
}
.reply-form {
padding: 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.form-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.sort-field {
display: inline-flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 13px;
font-weight: 600;
}
.reply-list {
overflow-y: auto;
max-height: 400px;
}
.reply-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
}
.reply-item.global {
background: #f0f9ff;
border-color: #bae6fd;
}
.reply-info {
flex: 1;
min-width: 0;
}
.reply-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.reply-title {
font-weight: 500;
color: #111827;
}
.reply-content {
color: #6b7280;
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 12px;
}
</style>
@@ -1,116 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { createRole, updateRole, type Role, type CreateRoleRequest, type UpdateRoleRequest } from '@/api/adminRoles'
const props = defineProps<{
modelValue: boolean
role?: Role | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const form = ref({
code: '',
name: '',
description: '',
})
const isEdit = ref(false)
watch(
() => props.modelValue,
(val) => {
if (val) {
if (props.role) {
isEdit.value = true
form.value = {
code: props.role.code,
name: props.role.name,
description: props.role.description,
}
} else {
isEdit.value = false
form.value = { code: '', name: '', description: '' }
}
}
},
{ immediate: true },
)
async function handleSave() {
submitting.value = true
try {
if (isEdit.value && props.role) {
const req: UpdateRoleRequest = {
name: form.value.name,
description: form.value.description,
}
await updateRole(props.role.id, req)
ElMessage.success('角色已更新')
} else {
const req: CreateRoleRequest = {
code: form.value.code,
name: form.value.name,
description: form.value.description,
}
await createRole(req)
ElMessage.success('角色已创建')
}
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑角色' : '新建角色'"
width="500px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<el-form-item label="角色编码" class="full-control">
<el-input v-model="form.code" :disabled="isEdit" placeholder="例如: cs, ops, finance" />
</el-form-item>
<el-form-item label="角色名称" class="full-control">
<el-input v-model="form.name" placeholder="例如: 客服, 运营, 财务" />
</el-form-item>
<el-form-item label="描述" class="full-control">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="角色描述" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
@@ -1,216 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import {
emptyListingSalePriceConfig,
mergeListingSalePriceConfig,
type PublishSalePriceConfig,
} from '@/api/listingOptions'
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
import { safeParseJSON } from '@/utils/json'
const props = defineProps<{
modelValue: boolean
config: SystemConfig
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const description = ref('')
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig))
watch(
() => props.modelValue,
(val) => {
if (val) {
salePriceConfigDraft.value = parseSalePriceConfig(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true }
)
function parseSalePriceConfig(raw: string) {
const parsed = safeParseJSON(raw, emptyListingSalePriceConfig)
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
}
function cloneSalePriceConfig(config: PublishSalePriceConfig) {
return JSON.parse(JSON.stringify(config)) as PublishSalePriceConfig
}
function addSaleFixedMarkupRule() {
salePriceConfigDraft.value.fixed_markup_rules.push({
min_m: 0,
max_m: 0,
markup_amount: 0,
})
}
function removeSaleFixedMarkupRule(index: number) {
salePriceConfigDraft.value.fixed_markup_rules.splice(index, 1)
}
function addSaleRatioAdjustmentRule() {
salePriceConfigDraft.value.ratio_adjustment_rules.push({
min_m: 0,
max_m: 0,
ratio_subtract: 0,
})
}
function removeSaleRatioAdjustmentRule(index: number) {
salePriceConfigDraft.value.ratio_adjustment_rules.splice(index, 1)
}
async function handleSave() {
submitting.value = true
try {
const value = JSON.stringify(salePriceConfigDraft.value, null, 2)
await updateSystemConfig(props.config.key, {
value,
description: description.value,
})
ElMessage.success('配置已更新')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑出售定价配置"
width="920px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<div class="publish-options-editor">
<div class="editor-toolbar">
<span>内部出售定价规则</span>
</div>
<div class="editor-block">
<div class="editor-block-title subtle-title">
<span>90M 及以下固定加价出售价格 = 回收价格 + 固定加价</span>
<el-button size="small" @click="addSaleFixedMarkupRule">添加固定加价</el-button>
</div>
<el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="最大 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="加价金额/元" min-width="140">
<template #default="{ row }"><el-input-number v-model="row.markup_amount" :min="0" :step="1" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleFixedMarkupRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>90M 以上比例修正出售比例 = 回收比例 - 比例修正最大 M 0 表示无上限</span>
<el-button size="small" @click="addSaleRatioAdjustmentRule">添加比例修正</el-button>
</div>
<el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="最大 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="比例修正" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.ratio_subtract" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleRatioAdjustmentRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-form-item label="配置说明" class="desc-item">
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
max-height: 65vh;
overflow-y: auto;
padding-right: 8px;
}
.config-key-label {
margin: 0;
color: #374151;
}
.publish-options-editor {
display: grid;
gap: 16px;
}
.editor-toolbar,
.editor-block-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor-toolbar span {
color: #30343a;
font-weight: 700;
}
.editor-block {
display: grid;
gap: 10px;
}
.subtle-title {
margin-top: 6px;
color: #4b5563;
font-size: 13px;
}
.desc-item {
margin-top: 14px;
margin-bottom: 0;
}
</style>
@@ -1,195 +0,0 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchSupportAdmins, transferChat, type SupportAdmin } from '@/api/chats'
import { User } from '@element-plus/icons-vue'
const props = defineProps<{
modelValue: boolean
conversationId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const visible = ref(false)
const loading = ref(false)
const submitting = ref(false)
const admins = ref<SupportAdmin[]>([])
const selectedAdminId = ref<number | null>(null)
// 按会话数排序,空闲客服优先
const sortedAdmins = computed(() => {
return [...admins.value].sort((a, b) => a.chat_count - b.chat_count)
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
loadAdmins()
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
async function loadAdmins() {
loading.value = true
try {
admins.value = await fetchSupportAdmins()
} catch {
ElMessage.error('加载客服列表失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!selectedAdminId.value) {
ElMessage.warning('请选择目标客服')
return
}
submitting.value = true
try {
await transferChat(props.conversationId, selectedAdminId.value)
ElMessage.success('转接成功')
visible.value = false
emit('success')
} catch {
ElMessage.error('转接失败')
} finally {
submitting.value = false
}
}
function getStatusTag(count: number) {
if (count === 0) return { text: '空闲', type: 'success' }
if (count <= 3) return { text: '正常', type: '' }
return { text: '繁忙', type: 'warning' }
}
</script>
<template>
<el-dialog v-model="visible" title="转接会话" width="480px">
<div v-loading="loading" class="transfer-content">
<p class="tip">选择要转接给的客服</p>
<el-radio-group v-model="selectedAdminId" class="admin-list">
<el-radio
v-for="admin in sortedAdmins"
:key="admin.id"
:value="admin.id"
class="admin-item"
>
<div class="admin-info">
<el-avatar :size="36" :icon="User" />
<div class="admin-detail">
<span class="admin-name">{{ admin.nickname }}</span>
<span class="admin-id">ID: {{ admin.id }}</span>
</div>
</div>
<div class="admin-status">
<el-tag :type="getStatusTag(admin.chat_count).type" size="small">
{{ getStatusTag(admin.chat_count).text }}
</el-tag>
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
</div>
</el-radio>
</el-radio-group>
<el-empty v-if="!loading && admins.length === 0" description="暂无可用客服" />
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!selectedAdminId" @click="handleSubmit">
确认转接
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.transfer-content {
min-height: 100px;
}
.tip {
margin: 0 0 16px;
color: #6b7280;
font-size: 14px;
}
.admin-list {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.admin-item {
display: flex;
align-items: center;
gap: 12px;
height: auto;
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-right: 0;
width: 100%;
}
.admin-item :deep(.el-radio__label) {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.admin-item.is-checked {
border-color: #409eff;
background: #ecf5ff;
}
.admin-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.admin-detail {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.admin-name {
font-weight: 500;
font-size: 14px;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-id {
color: #9ca3af;
font-size: 12px;
}
.admin-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.admin-count {
color: #9ca3af;
font-size: 12px;
}
</style>
@@ -1,517 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import {
fetchChat,
fetchChatMessages,
markChatRead,
sendChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { uploadFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
const route = useRoute()
const router = useRouter()
const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
}
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
loadConversation()
}
}
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await loadAll()
})
async function loadAll() {
if (!conversationID.value) return
loading.value = true
try {
const [chat] = await Promise.all([
fetchChat(conversationID.value),
loadMessages(false),
])
conversation.value = chat
await markChatRead(conversationID.value)
} catch {
showToast({ message: '加载会话失败', icon: 'cross' })
} finally {
loading.value = false
}
}
async function loadConversation() {
if (!conversationID.value) return
try {
conversation.value = await fetchChat(conversationID.value)
} catch { /* ignore */ }
}
async function loadMessages(scrollToBottom = true) {
if (!conversationID.value) return
const res = await fetchChatMessages(conversationID.value, 1, 100)
messages.value = res.items
if (scrollToBottom) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
const imageUrls = [...attachments.value]
if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
} catch {
showToast({ message: '发送失败', icon: 'cross' })
} finally {
sending.value = false
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
showToast('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
showToast(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
showToast('每条消息最多发送 9 张图片')
}
} catch {
showToast({ message: '图片上传失败', icon: 'cross' })
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统'
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
}
</script>
<template>
<main class="mobile-chat">
<header class="chat-header">
<button class="icon-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<div class="chat-title">
<h1>{{ conversation?.title || '客服会话' }}</h1>
<p>{{ memberText }}</p>
</div>
<button v-if="conversation?.order_id" class="icon-btn" type="button" @click="router.push(`/m/orders/${conversation.order_id}`)">
<van-icon name="orders-o" :size="20" />
</button>
<span v-else class="icon-placeholder"></span>
</header>
<section ref="listRef" class="message-list" :class="{ loading }">
<van-loading v-if="loading && messages.length === 0" class="loading-state" />
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
</template>
<template v-else>
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div v-if="item.content" class="bubble">{{ item.content }}</div>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
/>
</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</section>
<footer class="composer">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<van-icon name="cross" :size="12" />
</button>
</div>
</div>
<button class="tool-btn" type="button" :disabled="uploading || attachments.length >= 9" @click="pickImages">
<van-icon name="photo-o" :size="20" />
</button>
<van-field
v-model="content"
class="composer-input"
type="textarea"
autosize
:maxlength="1000"
rows="1"
placeholder="发送消息"
@keydown.enter.prevent="handleSend"
/>
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
<van-icon name="guide-o" :size="20" />
</button>
</footer>
</main>
</template>
<style scoped>
.mobile-chat {
display: grid;
grid-template-rows: 56px minmax(0, 1fr) auto;
height: 100dvh;
background: #f3f6fa;
}
.chat-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
align-items: center;
border-bottom: 1px solid #e7ecf2;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
}
.icon-btn {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 0;
background: transparent;
color: #374151;
}
.icon-placeholder {
display: block;
width: 44px;
height: 44px;
}
.chat-title {
min-width: 0;
text-align: center;
}
.chat-title h1 {
margin: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title p {
margin: 3px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-list {
min-height: 0;
overflow-y: auto;
padding: 14px 12px 18px;
}
.loading-state {
display: block;
margin: 70px auto;
}
.message-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row.system {
justify-content: center;
}
.avatar {
display: grid;
flex: none;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: #1477ff;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.message-row.self .avatar {
background: #10b981;
}
.bubble-wrap {
display: flex;
max-width: min(76vw, 330px);
flex-direction: column;
align-items: flex-start;
}
.message-row.self .bubble-wrap {
align-items: flex-end;
}
.sender-name {
margin-bottom: 4px;
color: #8a94a6;
font-size: 11px;
}
.bubble {
max-width: 100%;
padding: 9px 11px;
border-radius: 8px;
background: #fff;
color: #111827;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05);
}
.message-row.self .bubble {
background: #dff5eb;
}
.message-attachments {
display: grid;
gap: 6px;
margin-top: 6px;
}
.message-attachments :deep(.chat-image-button) {
max-width: min(62vw, 220px);
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
font-size: 10px;
}
.system-message {
max-width: 82%;
padding: 5px 9px;
border-radius: 8px;
background: #e6ebf2;
color: #6b7280;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
.composer {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 42px;
gap: 8px;
align-items: end;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid #e7ecf2;
background: #fff;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
grid-column: 1 / -1;
gap: 8px;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 72px;
height: 72px;
}
.pending-item :deep(.chat-image-button img) {
height: 72px;
}
.remove-attachment {
position: absolute;
top: 3px;
right: 3px;
display: grid;
width: 20px;
height: 20px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
}
.composer-input {
border: 1px solid #d9e0e8;
border-radius: 8px;
overflow: hidden;
}
.tool-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
border-radius: 8px;
background: #eef4ff;
color: #1477ff;
}
.tool-btn:disabled {
color: #9ca3af;
}
.send-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
border-radius: 8px;
background: #1477ff;
color: #fff;
}
.send-btn:disabled {
background: #c8d1dd;
}
</style>
@@ -1,448 +0,0 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
export type FilterSection =
| {
key: string;
title: string;
type: "range";
unit?: string;
minPlaceholder?: string;
maxPlaceholder?: string;
}
| { key: string; title: string; type: "chips"; options: string[] };
type RangeFilters = Record<string, { min: string; max: string }>;
type SelectedFilters = Record<string, string[]>;
type RangePresets = Record<
string,
Array<{ label: string; min: string; max: string }>
>;
const props = defineProps<{
show: boolean;
sections: FilterSection[];
selectedFilters: SelectedFilters;
rangeFilters: RangeFilters;
rangePresets: RangePresets;
}>();
const emit = defineEmits<{
"update:show": [value: boolean];
"update:selectedFilters": [value: SelectedFilters];
"update:rangeFilters": [value: RangeFilters];
reset: [];
}>();
const activeFilterSection = ref("price");
const filterContentRef = ref<HTMLElement | null>(null);
const filterTabsRef = ref<HTMLElement | null>(null);
const filterSectionRefs = new Map<string, HTMLElement>();
const filterTabRefs = new Map<string, HTMLElement>();
function handleShowChange(value: boolean) {
emit("update:show", value);
if (value) {
activeFilterSection.value = props.sections[0]?.key || "price";
nextTick(() => {
filterContentRef.value?.scrollTo({ top: 0 });
});
}
}
function close() {
emit("update:show", false);
}
function confirm() {
close();
}
function reset() {
emit("reset");
}
function toggleChip(sectionKey: string, value: string) {
const selected = props.selectedFilters[sectionKey] || [];
const next = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
emit("update:selectedFilters", {
...props.selectedFilters,
[sectionKey]: next,
});
}
function updateRange(sectionKey: string, side: "min" | "max", value: string) {
const current = props.rangeFilters[sectionKey] || { min: "", max: "" };
emit("update:rangeFilters", {
...props.rangeFilters,
[sectionKey]: {
...current,
[side]: value,
},
});
}
function applyRangePreset(sectionKey: string, min: string, max: string) {
emit("update:rangeFilters", {
...props.rangeFilters,
[sectionKey]: { min, max },
});
}
function isRangePresetActive(sectionKey: string, min: string, max: string) {
const range = props.rangeFilters[sectionKey];
return range?.min === min && range?.max === max;
}
function setFilterSectionRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) {
filterSectionRefs.set(key, el);
} else {
filterSectionRefs.delete(key);
}
}
function setFilterTabRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) {
filterTabRefs.set(key, el);
} else {
filterTabRefs.delete(key);
}
}
function scrollToFilterSection(key: string) {
const container = filterContentRef.value;
const target = filterSectionRefs.get(key);
if (!container || !target) return;
activeFilterSection.value = key;
container.scrollTo({
top: Math.max(target.offsetTop - 8, 0),
behavior: "smooth",
});
keepActiveTabVisible(key);
}
function handleFilterScroll() {
const container = filterContentRef.value;
if (!container) return;
const anchorTop = container.scrollTop + 16;
let activeKey = props.sections[0]?.key || "";
for (const section of props.sections) {
const el = filterSectionRefs.get(section.key);
if (el && el.offsetTop <= anchorTop) {
activeKey = section.key;
}
}
if (activeKey && activeKey !== activeFilterSection.value) {
activeFilterSection.value = activeKey;
keepActiveTabVisible(activeKey);
}
}
function keepActiveTabVisible(key: string) {
const container = filterTabsRef.value;
const tab = filterTabRefs.get(key);
if (!container || !tab) return;
const tabTop = tab.offsetTop;
const tabBottom = tabTop + tab.offsetHeight;
const visibleTop = container.scrollTop;
const visibleBottom = visibleTop + container.clientHeight;
if (tabTop < visibleTop) {
container.scrollTo({ top: tabTop, behavior: "smooth" });
} else if (tabBottom > visibleBottom) {
container.scrollTo({
top: tabBottom - container.clientHeight,
behavior: "smooth",
});
}
}
</script>
<template>
<van-popup
:show="show"
position="right"
class="filter-popup"
teleport="body"
@update:show="handleShowChange"
>
<div class="filter-sheet">
<header class="filter-header">
<button type="button" class="filter-close" @click="close">
<van-icon name="cross" :size="18" />
</button>
<h2>筛选</h2>
<span></span>
</header>
<div class="filter-body">
<aside ref="filterTabsRef" class="filter-tabs">
<button
v-for="section in sections"
:key="section.key"
:ref="(el) => setFilterTabRef(section.key, el as Element | null)"
type="button"
:class="{ active: activeFilterSection === section.key }"
@click="scrollToFilterSection(section.key)"
>
{{ section.title }}
</button>
</aside>
<section
ref="filterContentRef"
class="filter-content"
@scroll.passive="handleFilterScroll"
>
<div
v-for="section in sections"
:key="section.key"
:ref="(el) => setFilterSectionRef(section.key, el as Element | null)"
class="filter-block"
>
<h3>{{ section.title }}</h3>
<p v-if="section.type === 'range' && section.unit">
单位{{ section.unit }}
</p>
<template v-if="section.type === 'range'">
<div class="range-editor">
<input
:value="rangeFilters[section.key]?.min || ''"
type="number"
inputmode="decimal"
:placeholder="section.minPlaceholder || '最低'"
@input="updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)"
/>
<span></span>
<input
:value="rangeFilters[section.key]?.max || ''"
type="number"
inputmode="decimal"
:placeholder="section.maxPlaceholder || '最高'"
@input="updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)"
/>
</div>
<div
v-if="rangePresets[section.key]?.length"
class="sheet-chip-grid range-preset-grid"
>
<button
v-for="preset in rangePresets[section.key]"
:key="`${section.key}-${preset.label}`"
type="button"
:class="{
active: isRangePresetActive(section.key, preset.min, preset.max),
}"
@click="applyRangePreset(section.key, preset.min, preset.max)"
>
{{ preset.label }}
</button>
</div>
</template>
<div v-else class="sheet-chip-grid">
<button
v-for="option in section.options"
:key="`${section.key}-${option}`"
type="button"
:class="{
active: selectedFilters[section.key]?.includes(option),
}"
@click="toggleChip(section.key, option)"
>
{{ option }}
</button>
</div>
</div>
</section>
</div>
<footer class="filter-actions">
<button type="button" class="reset-btn" @click="reset">重置</button>
<button type="button" class="confirm-btn" @click="confirm">确定</button>
</footer>
</div>
</van-popup>
</template>
<style scoped>
.filter-popup {
width: min(86vw, 390px);
height: 100dvh;
background: #fff;
}
.filter-sheet {
display: grid;
grid-template-rows: 48px minmax(0, 1fr) 64px;
height: 100%;
background: #fff;
}
.filter-header {
position: relative;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
border-bottom: 1px solid #eef1f5;
}
.filter-header h2 {
margin: 0;
text-align: center;
font-size: 15px;
font-weight: 800;
}
.filter-close {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
background: transparent;
color: #17233d;
}
.filter-body {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
min-height: 0;
}
.filter-tabs {
overflow-y: auto;
background: #f6f7f9;
-webkit-overflow-scrolling: touch;
}
.filter-tabs button {
display: block;
width: 100%;
min-height: 42px;
border: 0;
background: transparent;
color: #2d3748;
padding: 0 8px;
text-align: center;
font-size: 12px;
}
.filter-tabs button.active {
background: #fff;
color: #ff7a00;
font-weight: 800;
}
.filter-content {
overflow-y: auto;
min-width: 0;
padding: 18px 14px 28px;
-webkit-overflow-scrolling: touch;
}
.filter-block {
scroll-margin-top: 8px;
}
.filter-block + .filter-block {
margin-top: 26px;
}
.filter-block h3 {
margin: 0 0 12px;
font-size: 17px;
font-weight: 800;
color: #17233d;
}
.filter-block p {
margin: -6px 0 12px;
color: #8a96a8;
font-size: 11px;
}
.range-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.range-editor span {
height: 1px;
background: #cfd6df;
}
.range-editor input {
min-width: 0;
height: 42px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #fff;
color: #17233d;
padding: 0 10px;
text-align: center;
font-size: 13px;
}
.sheet-chip-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.range-preset-grid {
margin-top: 10px;
}
.sheet-chip-grid button {
min-width: 0;
min-height: 38px;
border: 0;
border-radius: 7px;
background: #f4f5f7;
color: #2d3748;
padding: 6px 8px;
font-size: 12px;
line-height: 1.25;
}
.sheet-chip-grid button.active {
background: #fff3d1;
color: #cc7a00;
font-weight: 800;
box-shadow: inset 0 0 0 1px #ffd15c;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
align-items: center;
border-top: 1px solid #eef1f5;
padding: 10px 12px;
}
.filter-actions button {
height: 42px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
}
.reset-btn {
border: 1px solid #e6eaf0;
background: #fff;
color: #2d3748;
}
.confirm-btn {
border: 0;
background: #ffd21e;
color: #17233d;
}
</style>
@@ -1,587 +0,0 @@
.mobile-shell {
min-height: 100vh;
background: #f5f7fa;
color: #17233d;
padding-bottom: calc(58px + env(safe-area-inset-bottom));
}
/* ========== 顶部信息区 ========== */
.mobile-hero {
padding: 14px 14px 8px;
background: #f5f7fa;
}
.mobile-topbar,
.mobile-brand {
display: flex;
align-items: center;
}
.mobile-topbar {
gap: 10px;
justify-content: space-between;
color: #17233d;
}
.mobile-brand {
flex: 0 0 auto;
gap: 10px;
}
.mobile-logo {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 12px;
background: #ffffff;
color: #ff6a00;
box-shadow: 0 6px 16px rgba(23, 35, 61, 0.08);
font-weight: 900;
}
.mobile-brand strong,
.mobile-brand small {
display: block;
}
.mobile-brand small {
margin-top: 2px;
color: #7b8798;
font-size: 11px;
}
.mobile-brand strong {
font-size: 15px;
white-space: nowrap;
}
.mobile-service {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: #ffffff;
color: #1477ff;
padding: 7px 10px;
box-shadow: 0 6px 16px rgba(23, 35, 61, 0.08);
font-size: 13px;
font-weight: 800;
}
/* ========== van-search 覆盖样式 ========== */
.home-search {
flex: 1 1 auto;
min-width: 0;
padding: 0;
background: transparent;
}
.home-search :deep(.van-search__content) {
border-radius: 999px;
border: 1px solid #eef1f5;
background: #ffffff;
padding: 6px 10px;
box-shadow: 0 8px 20px rgba(23, 35, 61, 0.06);
}
.home-search :deep(.van-search__content .van-field__control) {
color: #17233d;
font-size: 12px;
}
/* ========== 防骗提示卡片 ========== */
.fraud-tip {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
border-radius: 12px;
border: 1px solid #ffe6bd;
background: #fffaf0;
padding: 10px 14px;
font-size: 12px;
line-height: 1.45;
color: #5c3b00;
}
.announcement-swipe {
flex: 1;
height: 18px;
min-width: 0;
}
.announcement-swipe span {
display: block;
overflow: hidden;
color: #5c3b00;
text-overflow: ellipsis;
white-space: nowrap;
}
.fraud-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #f0a500;
flex-shrink: 0;
}
/* ========== Content 区 ========== */
.mobile-content {
padding: 8px 14px 14px;
}
/* ========== Banner 轮播 ========== */
.banner-swipe {
border-radius: 16px;
}
.banner-swipe :deep(.van-swipe__indicators) {
bottom: 10px;
}
.banner-swipe :deep(.van-swipe__indicator) {
width: 5px;
height: 5px;
background: rgba(23, 35, 61, 0.24);
opacity: 1;
}
.banner-swipe :deep(.van-swipe__indicator--active) {
width: 16px;
border-radius: 999px;
background: #ff6a00;
}
.mobile-banner {
position: relative;
overflow: hidden;
display: flex;
justify-content: space-between;
gap: 14px;
min-height: 132px;
border-radius: 16px;
background: #ffffff;
padding: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.mobile-banner.has-image {
min-height: 132px;
align-items: flex-end;
background: #17233d;
color: #ffffff;
}
.banner-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.mobile-banner.has-image::after {
position: absolute;
inset: 0;
content: "";
background: linear-gradient(90deg, rgba(10, 18, 32, 0.64), rgba(10, 18, 32, 0.12));
}
.mobile-banner.has-image > div:not(.banner-badge) {
position: relative;
z-index: 1;
max-width: 78%;
}
.mobile-banner.tone-green {
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
.mobile-banner.tone-orange {
background: linear-gradient(135deg, #ffffff 0%, #fff7ed 100%);
}
.mobile-banner p,
.mobile-banner h1 {
margin: 0;
}
.mobile-banner p {
color: #1477ff;
font-size: 13px;
font-weight: 800;
}
.mobile-banner.has-image p,
.mobile-banner.has-image h1 {
color: #ffffff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
}
.mobile-banner h1 {
margin-top: 6px;
font-size: 22px;
line-height: 1.22;
}
.mobile-banner span {
display: inline-flex;
margin-top: 12px;
border-radius: 999px;
background: #ffefb5;
color: #7a4b00;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
}
.banner-badge {
position: relative;
z-index: 1;
align-self: flex-start;
border-radius: 12px;
background: #ff6a00;
color: #ffffff;
padding: 8px 9px;
font-size: 12px;
font-weight: 900;
transform: rotate(8deg);
}
.tone-green .banner-badge {
background: #16a34a;
}
.tone-orange .banner-badge {
background: #f59e0b;
}
/* ========== 列表工具栏 ========== */
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
border-radius: 14px;
background: #fff;
padding: 14px 16px;
color: #536173;
font-size: 12px;
}
.sort-entry,
.filter-entry {
border: 0;
background: transparent;
display: flex;
align-items: center;
cursor: pointer;
}
.sort-entry {
gap: 4px;
color: #17233d;
padding: 0;
font-size: 15px;
font-weight: 900;
}
.filter-entry {
position: relative;
border-radius: 999px;
color: #17233d;
padding: 0;
gap: 5px;
font-size: 14px;
font-weight: 800;
}
.filter-entry em {
min-width: 16px;
height: 16px;
border-radius: 999px;
background: #fff;
color: #1477ff;
display: grid;
place-items: center;
font-size: 10px;
font-style: normal;
}
.sort-panel {
margin-top: 6px;
border-radius: 0 0 14px 14px;
background: #fff;
padding: 4px 16px 14px;
}
.sort-panel button {
display: flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
color: #2d3748;
padding: 0;
font-size: 14px;
font-weight: 700;
}
.sort-panel button.active {
color: #ff7a00;
font-weight: 900;
}
.result-count {
margin-top: 8px;
color: #536173;
font-size: 12px;
}
.result-count strong {
margin-right: 3px;
color: #1477ff;
font-size: 15px;
font-weight: 900;
}
/* ========== 加载状态 ========== */
.state-loading {
display: flex;
justify-content: center;
padding: 20px 0;
color: #6b7a90;
font-size: 13px;
}
/* ========== 卡片列表 ========== */
.mobile-list {
display: grid;
gap: 12px;
margin-top: 12px;
}
.mobile-card {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
grid-template-rows: 132px 56px;
gap: 10px 12px;
min-height: 222px;
border-radius: 16px;
background: #ffffff;
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
text-decoration: none;
color: inherit;
}
.card-cover {
position: relative;
display: grid;
width: 132px;
height: 132px;
place-items: center;
overflow: hidden;
align-self: center;
border-radius: 14px;
background: #d8d8d8;
color: #9aa4b2;
font-size: 22px;
font-weight: 900;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.card-cover-labels {
position: absolute;
top: 7px;
right: 7px;
display: flex;
align-items: flex-end;
flex-direction: column;
gap: 5px;
}
.card-cover-labels em {
border-radius: 999px;
background: #ffd21e;
color: #1f2937;
padding: 3px 7px;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.card-main {
display: flex;
min-height: 132px;
min-width: 0;
flex-direction: column;
justify-content: center;
}
.card-title-row {
display: flex;
align-items: flex-start;
}
.card-title-row h2 {
overflow: hidden;
margin: 0;
color: #182238;
display: -webkit-box;
font-size: 15.5px;
font-weight: 800;
line-height: 1.42;
min-height: 44px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
min-width: 0;
}
.card-subtitle {
margin: 5px 0 0;
color: #6b7280;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.card-badges-row,
.card-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.card-badges-row {
margin-top: 9px;
}
.trust-badge,
.server-badge {
display: inline-flex;
align-items: center;
min-height: 25px;
border-radius: 6px;
padding: 0 7px;
font-size: 12px;
font-weight: 800;
}
.trust-badge {
background: #fff4dc;
color: #ff7900;
}
.server-badge {
background: #eaf4ff;
color: #1477ff;
}
.card-footer {
display: flex;
align-items: flex-end;
justify-content: flex-start;
gap: 10px;
margin-top: 11px;
}
.price-col {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.card-footer strong {
color: #ff1f35;
font-size: 21px;
line-height: 1;
}
.card-footer .rent-sub {
color: #8a96a8;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.card-chip-row {
grid-column: 1 / -1;
align-content: flex-start;
row-gap: 6px;
column-gap: 6px;
height: 56px;
margin-top: 0;
overflow: hidden;
}
.card-chip-row span {
display: inline-flex;
align-items: center;
max-width: 100%;
height: 25px;
min-width: 0;
border: 1px solid #ff8a1f;
border-radius: 4px;
color: #ff7900;
padding: 0 5px;
font-size: 11.5px;
font-weight: 700;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-load-state {
padding: 4px 0 18px;
color: #8a96a8;
font-size: 12px;
font-weight: 700;
text-align: center;
}
@media (max-width: 374px) {
.mobile-card {
grid-template-columns: 112px minmax(0, 1fr);
grid-template-rows: 112px 56px;
min-height: 202px;
}
.card-cover {
width: 112px;
height: 112px;
}
.card-main {
min-height: 112px;
}
.card-title-row h2 {
font-size: 14.5px;
}
.card-footer strong {
font-size: 20px;
}
}
/* ========== 响应式适配 ========== */
@media (min-width: 520px) {
.mobile-shell {
max-width: 430px;
margin: 0 auto;
box-shadow: 0 0 0 1px rgba(23, 35, 61, 0.08);
}
}
@@ -1,541 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink, useRouter } from "vue-router";
import { showToast } from "vant";
import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { ensureSupportChat } from "@/api/chats";
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/api/listingOptions";
import { fetchListingsPage, type Listing, type PublicListingQuery } from "@/api/listings";
import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type HomeBannerSlide,
} from "@/api/homeConfig";
import MobileHomeFilterSheet, {
type FilterSection,
} from "./MobileHomeFilterSheet.vue";
import {
getListingChips,
getListingDisplayPrice,
getListingSubtitle,
getListingTitle,
getLoginMethod,
getServerRegion,
hasAcceleratedSaleRatio,
hasGiftResources,
} from "@/utils/listingDisplay";
import { useSessionStore } from "@/stores/session";
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const loadingMore = ref(false);
const loadFailed = ref(false);
const listings = ref<Listing[]>([]);
const totalListings = ref(0);
const currentPage = ref(1);
const hasMoreListings = ref(true);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const sortOpen = ref(false);
const activeSort = ref("comprehensive");
const filterOpen = ref(false);
const selectedFilters = ref<Record<string, string[]>>({});
const rangeFilters = ref<Record<string, { min: string; max: string }>>({});
const refreshing = ref(false);
const searchValue = ref("");
const announcements = ref<string[]>(defaultHomeAnnouncements);
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
const supportLoading = ref(false);
const mobilePageSize = 10;
let listingRequestSeq = 0;
const sortOptions = [
{ key: "comprehensive", label: "综合排序" },
{ key: "published", label: "发布时间" },
{ key: "awmDesc", label: "AWM数量" },
{ key: "priceAsc", label: "价格最低" },
{ key: "priceDesc", label: "价格最高" },
];
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
coin: [
{ label: "50-100", min: "50", max: "100" },
{ label: "100-200", min: "100", max: "200" },
{ label: "200-300", min: "200", max: "300" },
{ label: "300-500", min: "300", max: "500" },
{ label: "500以上", min: "500", max: "" },
],
resource_awmAmmo: [
{ label: "0-20", min: "0", max: "20" },
{ label: "20-50", min: "20", max: "50" },
{ label: "50-100", min: "50", max: "100" },
{ label: "100-200", min: "100", max: "200" },
{ label: "200以上", min: "200", max: "" },
],
};
const activeSortLabel = computed(
() =>
sortOptions.find((option) => option.key === activeSort.value)?.label ||
"综合排序"
);
async function handleSupportClick() {
if (!session.isLoggedIn) {
router.push({ path: "/m/login", query: { redirect: router.currentRoute.value.fullPath } });
return;
}
if (supportLoading.value) return;
supportLoading.value = true;
try {
const chat = await ensureSupportChat();
router.push(`/m/chats/${chat.id}`);
} catch {
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" });
} finally {
supportLoading.value = false;
}
}
const serverFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.server_options
.map((item) => item.trim())
.filter(Boolean)
)
);
const loginMethodFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.login_method_options
.map((item) => item.trim())
.filter(Boolean)
)
);
const filterSections = computed<FilterSection[]>(() => [
{ key: "price", title: "价格区间", type: "range", unit: "元", minPlaceholder: "最低价", maxPlaceholder: "最高价" },
{ key: "coin", title: "哈夫币数量", type: "range", unit: "M", minPlaceholder: "最低", maxPlaceholder: "最高" },
{ key: "server", title: "区服", type: "chips", options: serverFilterOptions.value },
{ key: "login", title: "上号方式", type: "chips", options: loginMethodFilterOptions.value },
{ key: "insurance", title: "保险", type: "chips", options: publishOptions.value.insurance_options },
{ key: "stamina", title: "体力", type: "chips", options: publishOptions.value.level_options },
{ key: "load", title: "负重", type: "chips", options: publishOptions.value.level_options },
...publishOptions.value.quantity_items.map((item) => ({
key: `resource_${item.key}`,
title: item.label,
type: "range" as const,
unit: parseQuantityUnit(item.price),
minPlaceholder: "最低",
maxPlaceholder: "最高",
})),
...publishOptions.value.skin_groups.map((group) => ({
key: group.key,
title: group.title,
type: "chips" as const,
options: group.options,
})),
{ key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" },
{ key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options },
{ key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" },
]);
const activeFilterCount = computed(() => {
const chipCount = Object.values(selectedFilters.value).reduce(
(sum, values) => sum + values.length,
0
);
const rangeCount = Object.values(rangeFilters.value).filter(
(range) => range.min || range.max
).length;
return chipCount + rangeCount;
});
const displayListings = computed(() => {
return listings.value;
});
onMounted(() => {
loadListings();
loadHomeConfig();
window.addEventListener("scroll", handleWindowScroll, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", handleWindowScroll);
});
watch(
() => listingQuerySignature(),
() => {
loadListings(true);
}
);
async function loadListings(reset = true) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return;
const requestSeq = ++listingRequestSeq;
if (reset) {
loading.value = true;
currentPage.value = 1;
hasMoreListings.value = true;
}
loadingMore.value = true;
loadFailed.value = false;
try {
const page = await fetchListingsPage(buildListingQuery(currentPage.value));
if (requestSeq !== listingRequestSeq) return;
listings.value = reset ? page.items : [...listings.value, ...page.items];
totalListings.value = page.total;
hasMoreListings.value = listings.value.length < page.total;
currentPage.value = page.page + 1;
requestAnimationFrame(handleWindowScroll);
} catch {
if (reset) {
listings.value = [];
totalListings.value = 0;
hasMoreListings.value = false;
loadFailed.value = true;
}
} finally {
if (requestSeq === listingRequestSeq) {
loading.value = false;
loadingMore.value = false;
}
}
}
async function loadHomeConfig() {
try {
const config = await fetchMobileHomeConfig();
announcements.value = config.announcements;
bannerSlides.value = config.banners;
publishOptions.value = config.publish_options;
} catch {
announcements.value = defaultHomeAnnouncements;
bannerSlides.value = defaultHomeBanners;
publishOptions.value = emptyListingPublishOptions;
}
}
async function onRefresh() {
refreshing.value = true;
try {
const [, nextHomeConfig] = await Promise.all([
loadListings(true),
fetchMobileHomeConfig(),
]);
announcements.value = nextHomeConfig.announcements;
bannerSlides.value = nextHomeConfig.banners;
publishOptions.value = nextHomeConfig.publish_options;
showToast({ message: "刷新成功", icon: "passed" });
} catch {
// 静默处理
} finally {
refreshing.value = false;
}
}
function openFilters() {
sortOpen.value = false;
filterOpen.value = true;
}
function toggleSortPanel() {
sortOpen.value = !sortOpen.value;
}
function selectSort(sortKey: string) {
activeSort.value = sortKey;
sortOpen.value = false;
}
function clearFilters() {
selectedFilters.value = {};
rangeFilters.value = {};
searchValue.value = "";
}
function buildListingQuery(page: number): PublicListingQuery {
const query: PublicListingQuery = {
page,
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
sort: activeSort.value,
};
const skinGroups: string[] = [];
const skinNames: string[] = [];
for (const [key, values] of Object.entries(selectedFilters.value)) {
const value = values.filter(Boolean).join(",");
if (!value) continue;
if (key === "server") query.server = value;
else if (key === "login") query.login_method = value;
else if (key === "insurance") query.insurance = value;
else if (key === "stamina") query.stamina = value;
else if (key === "load") query.load = value;
else if (key === "rank") query.rank = value;
else if (isSkinGroupKey(key)) {
skinGroups.push(key);
skinNames.push(...values);
}
}
if (skinGroups.length) query.skin_group = skinGroups.join(",");
if (skinNames.length) query.skin_name = skinNames.join(",");
for (const [key, range] of Object.entries(rangeFilters.value)) {
if (!range.min && !range.max) continue;
const min = parseOptionalNumber(range.min);
const max = parseOptionalNumber(range.max);
if (key === "price") {
query.min_price = min;
query.max_price = max;
} else if (key === "coin") {
query.min_coin = min;
query.max_coin = max;
} else if (key === "secretKd") {
query.min_secret_kd = min;
query.max_secret_kd = max;
} else if (key === "deposit") {
query.min_deposit = min;
query.max_deposit = max;
} else if (key.startsWith("resource_")) {
const resourceKey = key.replace("resource_", "");
query[`resource_${resourceKey}_min`] = min;
query[`resource_${resourceKey}_max`] = max;
}
}
return query;
}
function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1));
}
function parseOptionalNumber(value: string) {
if (value === "") return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function handleWindowScroll() {
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return;
loadListings(false);
}
function isSkinGroupKey(key: string) {
return publishOptions.value.skin_groups.some((group) => group.key === key);
}
function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim();
return unit || undefined;
}
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
}
</script>
<template>
<main class="mobile-shell">
<!-- ========== 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>
<van-search
v-model="searchValue"
shape="round"
placeholder="搜区服 / 段位"
class="home-search"
/>
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
{{ supportLoading ? "接入中" : "客服" }}
</button>
</div>
<!-- 防骗提示卡片不用 van-notice-bar -->
<div class="fraud-tip">
<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>
</div>
</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>
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
<h1 v-if="slide.title">{{ slide.title }}</h1>
<span v-if="slide.pill">{{ slide.pill }}</span>
</div>
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
</div>
</van-swipe-item>
</van-swipe>
<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"
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>
<p class="card-subtitle">{{ getListingSubtitle(item) }}</p>
<div class="card-badges-row">
<span class="trust-badge">押金秒退</span>
<span class="server-badge">{{ getServerRegion(item) }}</span>
<span v-if="getLoginMethod(item)" class="server-badge">
{{ getLoginMethod(item) }}
</span>
</div>
<div class="card-footer">
<div class="price-col">
<strong>¥{{ getListingDisplayPrice(item) }}</strong>
<span class="rent-sub">押金¥{{ item.deposit_amount }}</span>
</div>
</div>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${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>
<MobileHomeFilterSheet
v-model:show="filterOpen"
v-model:selected-filters="selectedFilters"
v-model:range-filters="rangeFilters"
:sections="filterSections"
:range-presets="rangePresets"
/>
<MobileBottomNav />
</main>
</template>
<style scoped src="./MobileHomeView.css"></style>
@@ -1,733 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import { fetchListing, type Listing } from "@/api/listings";
import { createOrder } from "@/api/orders";
import { useSessionStore } from "@/stores/session";
import {
assetRegions,
formatHafCoinM,
formatRatio,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingChips,
getListingDisplayPrice,
getListingRentPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getLoginMethod,
getServerRegion,
readAssetString,
} from "@/utils/listingDisplay";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const ordering = ref(false);
const listing = ref<Listing | null>(null);
onMounted(async () => {
loading.value = true;
try {
listing.value = await fetchListing(String(route.params.id));
} catch {
showToast({ message: "加载失败", icon: "warning-o" });
} finally {
loading.value = false;
}
});
const orderTotal = computed(() => {
if (!listing.value) return "0";
return `${Math.round(getListingDisplayPrice(listing.value))}`;
});
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
return {
rent: 0,
consumable: 0,
};
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
};
});
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: `¥${getListingDisplayPrice(listing.value)}`, tone: "price" },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
];
});
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
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);
});
/* 下单 */
async function handleCreateOrder() {
if (!listing.value) return;
if (!session.token) {
showDialog({
title: "请先登录",
message: "下单需要登录账号,是否前往登录?",
confirmButtonText: "去登录",
cancelButtonText: "取消",
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/login", query: { redirect: route.fullPath } });
});
return;
}
if (session.realnameStatus !== "verified") {
try {
await session.loadMe();
} catch {
// 401 会由全局拦截器处理。
}
}
if (session.realnameStatus !== "verified") {
showDialog({
title: "请先实名认证",
message: "租号下单前需要完成实名认证。",
confirmButtonText: "去认证",
cancelButtonText: "取消",
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/realname", query: { redirect: route.fullPath } });
});
return;
}
ordering.value = true;
try {
await createOrder(listing.value.id);
showToast({ message: "订单已创建,请完成支付", icon: "passed" });
await router.push(`/m/orders`);
} catch (error) {
showToast({ message: readError(error, "下单失败"), icon: "cross" });
} finally {
ordering.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
/** 判断当前底部导航是否激活 */
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
return route.path.startsWith(path);
}
</script>
<template>
<main class="mobile-detail">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>账号详情</h1>
<span class="header-spacer"></span>
</header>
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
加载中...
</van-loading>
<template v-else-if="listing">
<!-- 防骗提示 -->
<div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" />
<span
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
</div>
<!-- 封面图 -->
<div class="cover-area">
<img
v-if="detailScreenshots[0]?.url"
:src="detailScreenshots[0].url"
:alt="getListingTitle(listing)"
class="cover-img"
decoding="async"
/>
<div v-else class="cover-placeholder">
<van-icon name="photo-o" :size="40" color="#ccc" />
<span>暂无截图</span>
</div>
<!-- 截图指示器 -->
<div
v-if="detailScreenshots.length > 1"
class="cover-count"
>
{{ detailScreenshots.length }}
</div>
</div>
<!-- 标题信息 -->
<div class="info-card">
<div class="info-tag-row">
<van-tag plain type="primary" size="medium">{{
getServerRegion(listing)
}}</van-tag>
<van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium">
{{ getLoginMethod(listing) }}
</van-tag>
<van-tag v-if="listing.rank_level" plain size="medium">{{
listing.rank_level
}}</van-tag>
</div>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
</div>
<!-- 资产指标 -->
<div class="metric-row">
<div v-for="metric in detailMetrics" :key="metric.label" class="metric-item">
<span class="metric-label">{{ metric.label }}</span>
<strong class="metric-value" :class="metric.tone">{{ metric.value }}</strong>
</div>
</div>
<!-- 基础信息 -->
<div class="info-card">
<h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row">
<span
v-for="chip in getListingChips(listing)"
:key="chip.label"
>
{{ chip.label }}:{{ chip.value }}
</span>
</div>
<div class="info-line">
<span class="info-label">M单价</span>
<span class="info-text">{{ formatRatio(listing) }}</span>
</div>
<div class="info-line">
<span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join("、") || "--" }}</span>
</div>
<div v-if="readAssetString(listing, 'ban_record')" class="info-line">
<span class="info-label">封禁记录</span>
<span class="info-text">{{ readAssetString(listing, "ban_record") }}</span>
</div>
</div>
<div v-if="getListingResources(listing).length" class="info-card">
<h3 class="card-subtitle">额外消耗品</h3>
<div class="resource-grid">
<div
v-for="resource in getListingResources(listing)"
:key="resource.key"
class="resource-pill"
>
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || "--" }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || "¥0" }}</small>
<small v-else>无额外收费</small>
</em>
</div>
</div>
</div>
<div v-if="detailSkinGroups.length" class="info-card">
<h3 class="card-subtitle">皮肤</h3>
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-detail-group">
<p>{{ group.title }}</p>
<div class="detail-chip-row">
<span v-for="skin in group.options" :key="skin">{{ skin }}</span>
</div>
</div>
</div>
<div v-if="listing.description" class="info-card">
<h3 class="card-subtitle">备注</h3>
<p class="detail-desc">{{ listing.description }}</p>
</div>
<!-- 截图列表 -->
<div v-if="detailScreenshots.length" class="info-card">
<h3 class="card-subtitle">账号截图</h3>
<div class="screenshot-grid">
<figure v-for="shot in detailScreenshots" :key="shot.url" class="screenshot-item">
<img :src="shot.url" class="screenshot-thumb" loading="lazy" decoding="async" />
<figcaption>{{ shot.label }}</figcaption>
</figure>
</div>
</div>
<!-- 留白给底部下单栏 -->
<div class="bottom-spacer"></div>
<!-- 底部下单栏固定 -->
<div class="order-bar">
<div class="order-bar-left">
<div class="order-price">
<span class="price-label">价格</span>
<span class="price-amount">¥{{ orderTotal }}</span>
</div>
<div class="order-price-detail">
<span>租金 ¥{{ orderPriceBreakdown.rent }}</span>
<span>额外 ¥{{ orderPriceBreakdown.consumable }}</span>
</div>
</div>
<van-button
type="primary"
round
class="order-btn"
:loading="ordering"
:disabled="listing.in_transaction"
loading-text="下单中..."
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
</van-button>
</div>
</template>
<!-- 空状态 -->
<div v-else class="empty-state">
<van-icon name="info-o" :size="48" color="#ccc" />
<p>未找到该账号信息</p>
</div>
</main>
</template>
<style scoped>
.mobile-detail {
min-height: 100dvh;
background: #f5f7fa;
padding-bottom: calc(80px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #eee;
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #333;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
/* ========== 防骗提示 ========== */
.fraud-tip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #fff8e1;
font-size: 11px;
color: #e65100;
line-height: 1.4;
}
/* ========== 封面图 ========== */
.cover-area {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #e8e8e8;
overflow: hidden;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 12px;
}
.cover-count {
position: absolute;
right: 10px;
bottom: 10px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
}
/* ========== 信息卡片 ========== */
.info-card {
margin: 10px 12px;
padding: 14px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.info-tag-row {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.detail-title {
margin: 0 0 6px;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.35;
}
.detail-desc {
margin: 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
/* ========== 资产指标 ========== */
.metric-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 0 12px;
}
.metric-item {
background: #fff;
border-radius: 10px;
padding: 10px 6px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.metric-label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.metric-value {
display: block;
overflow: hidden;
font-size: 15px;
color: #1a1a1a;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-value.coin {
color: #1477ff;
}
.metric-value.price {
color: #ff5f00;
}
/* ========== 账号资料 ========== */
.info-line {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
}
.info-line:last-child {
border-bottom: none;
}
.info-label {
color: #999;
}
.info-text {
color: #333;
font-weight: 500;
}
/* ========== 截图列表 ========== */
.card-subtitle {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
}
.detail-chip-row {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 10px;
}
.detail-chip-row span {
display: inline-flex;
align-items: center;
min-height: 28px;
border: 1px solid #ff8a1f;
border-radius: 5px;
color: #ff7900;
padding: 0 7px;
font-size: 12px;
font-weight: 800;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.resource-pill {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 8px;
border-radius: 8px;
background: #f7f9fc;
padding: 10px;
}
.resource-pill span {
min-width: 0;
color: #5f6b7a;
font-size: 12px;
font-weight: 700;
}
.resource-pill strong {
color: #17233d;
font-size: 14px;
}
.resource-pill em {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
grid-column: 1 / -1;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.resource-pill em b {
color: #ff7900;
}
.resource-pill em small {
min-width: 0;
color: #17233d;
font-size: 11px;
font-weight: 800;
text-align: right;
}
.skin-detail-group + .skin-detail-group {
margin-top: 12px;
}
.skin-detail-group p {
margin: 0 0 8px;
color: #5f6b7a;
font-size: 12px;
font-weight: 900;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.screenshot-item {
margin: 0;
}
.screenshot-thumb {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
}
.screenshot-item figcaption {
margin-top: 4px;
color: #6b7280;
font-size: 11px;
font-weight: 700;
text-align: center;
}
/* ========== 底部留白 ========== */
.bottom-spacer {
height: 20px;
}
/* ========== 底部下单栏 ========== */
.order-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eee;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
.order-bar-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.order-price {
display: flex;
align-items: baseline;
gap: 4px;
}
.price-label {
font-size: 11px;
color: #999;
}
.price-amount {
font-size: 18px;
font-weight: 900;
color: #ff5f00;
}
.order-price-detail {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
color: #8a5a12;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.order-btn {
flex-shrink: 0;
padding: 0 20px;
height: 40px;
font-size: 14px;
font-weight: 700;
background: #ff6a00 !important;
border: none !important;
}
/* ========== 空状态 ========== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 80px 0;
color: #999;
font-size: 14px;
}
</style>
@@ -1,343 +0,0 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import { useSessionStore } from "@/stores/session";
import { useSmsCountdown } from "@/composables/useSmsCountdown";
const router = useRouter();
const route = useRoute();
const session = useSessionStore();
const loading = ref(false);
const agreed = ref(false);
const form = reactive({
phone: "",
code: "",
});
const { countDown, sending, handleSendCode, readError } = useSmsCountdown();
async function handleLogin() {
if (!agreed.value) {
showDialog({
title: "提示",
message: "请先阅读并同意用户协议和隐私政策",
});
return;
}
loading.value = true;
try {
await session.login(form.phone, form.code);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/m/profile";
await router.replace(redirect);
} catch {
showToast({ message: "登录失败,请检查手机号和验证码", icon: "cross" });
} finally {
loading.value = false;
}
}
</script>
<template>
<main class="mobile-login-shell">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
</header>
<section class="auth-body">
<div class="brand-lockup">
<div class="brand-logo"></div>
<strong>大锤商行</strong>
</div>
<div class="form-section">
<h1>登录</h1>
<label class="auth-input-row">
<input
v-model="form.phone"
type="tel"
maxlength="11"
placeholder="手机号"
/>
</label>
<label class="auth-input-row code-row">
<input
v-model="form.code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="验证码"
/>
<span class="code-action">
<van-button
size="small"
plain
:disabled="sending || countDown > 0"
:loading="sending"
class="code-btn"
@click="handleSendCode(form.phone)"
>
{{
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
</van-button>
</span>
</label>
<div class="assist-row">
<span>验证码登录</span>
<RouterLink to="/m/register">没有账号<b>立即注册</b></RouterLink>
</div>
<div class="agreement-row">
<van-checkbox v-model="agreed" shape="square" icon-size="16px">
我已阅读并同意<b>用户协议</b><b>隐私政策</b>
</van-checkbox>
</div>
<van-button
type="primary"
block
class="primary-button"
:loading="loading"
loading-text="登录中..."
@click="handleLogin"
>
登录
</van-button>
<RouterLink to="/m/register" class="secondary-entry">
还没有账号创建一个
</RouterLink>
</div>
</section>
</main>
</template>
<style scoped>
.mobile-login-shell {
min-height: 100dvh;
background:
radial-gradient(circle at 50% 12%, rgba(20, 119, 255, 0.1), transparent 34%),
linear-gradient(180deg, #f8fbff 0%, #ffffff 70%, #f7fafc 100%);
color: #111827;
}
.page-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
margin: 0;
padding: 0 18px;
background: transparent;
}
.back-btn {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: none;
border-radius: 999px;
background: none;
color: #111827;
}
.auth-body {
box-sizing: border-box;
display: flex;
min-height: 100dvh;
flex-direction: column;
justify-content: center;
padding: 54px 34px 28px;
}
.brand-lockup {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 0 auto clamp(34px, 6vh, 54px);
}
.brand-logo {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
box-shadow: 0 14px 32px rgba(20, 119, 255, 0.18);
color: #ffffff;
font-size: 19px;
font-weight: 900;
}
.brand-lockup strong {
display: block;
color: #111827;
font-size: 18px;
font-weight: 900;
letter-spacing: 0;
}
.form-section {
width: min(100%, 420px);
margin: 0 auto;
}
.form-section h1 {
margin: 0 0 22px;
color: #05070a;
font-size: 22px;
font-weight: 900;
line-height: 1.2;
}
.auth-input-row {
display: flex;
align-items: center;
min-height: 48px;
margin-bottom: 10px;
padding: 0 16px;
border: 1px solid #e1e5eb;
border-radius: 10px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.03);
}
.auth-input-row.code-row {
gap: 8px;
}
.auth-input-row input {
width: 100%;
min-width: 0;
border: none;
background: transparent;
color: #0f172a;
font-size: 16px;
outline: none;
}
.auth-input-row input::placeholder {
color: #b6beca;
}
.code-action {
display: flex;
justify-content: flex-end;
}
.code-btn {
min-width: 82px;
height: 32px;
border-color: #1477ff !important;
border-radius: 8px;
color: #1477ff !important;
font-weight: 700;
}
.assist-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 14px 0 24px;
color: #111827;
font-size: 13px;
}
.assist-row span {
min-width: 0;
color: #1477ff;
}
.assist-row a {
flex-shrink: 0;
color: #22252b;
text-decoration: none;
}
.assist-row b {
color: #05070a;
font-weight: 900;
}
.agreement-row {
display: flex;
align-items: center;
margin: 0 0 20px;
}
.agreement-row :deep(.van-checkbox__label) {
margin-left: 8px;
color: #252b36;
font-size: 12px;
line-height: 1.4;
}
.agreement-row :deep(.van-checkbox__label b) {
color: #1477ff;
font-weight: 700;
}
.primary-button {
height: 48px;
border: none !important;
border-radius: 10px !important;
background: #1477ff !important;
box-shadow: 0 12px 24px rgba(20, 119, 255, 0.18);
font-size: 17px;
font-weight: 900;
}
.secondary-entry {
display: grid;
min-height: 46px;
margin-top: 12px;
place-items: center;
border: 1px solid #e4e8ee;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
color: #1477ff;
font-size: 15px;
font-weight: 900;
text-align: center;
text-decoration: none;
}
@media (max-height: 700px) {
.auth-body {
justify-content: flex-start;
padding-top: 52px;
}
.brand-lockup {
margin-bottom: 26px;
}
.form-section h1 {
margin-bottom: 18px;
}
.assist-row {
margin-bottom: 18px;
}
}
</style>
@@ -1,317 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
onMounted(() => {
onRefresh()
})
async function loadChats(isRefresh = false) {
if (isRefresh) {
page.value = 1
finished.value = false
}
loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length >= res.total || res.items.length === 0) {
finished.value = true
} else {
page.value += 1
}
} catch {
showToast({ message: '获取会话失败', icon: 'cross' })
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
function onRefresh() {
refreshing.value = true
loadChats(true)
}
function onLoad() {
if (loading.value || finished.value) return
loadChats(false)
}
function openConversation(item: ChatConversation) {
router.push(`/m/chats/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
}
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
}
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
</script>
<template>
<main class="mobile-messages">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>消息</h1>
<span class="header-count">{{ unreadTotal > 0 ? `${unreadTotal} 未读` : '' }}</span>
</header>
<van-pull-refresh v-model="refreshing" class="scroll-container" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多会话了"
:immediate-check="false"
@load="onLoad"
>
<van-empty
v-if="!loading && conversations.length === 0"
description="暂无会话"
class="empty-state"
/>
<div v-else class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<div class="conversation-main">
<div class="conversation-title-row">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span>{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
</div>
<p>{{ previewText(item) }}</p>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
</van-list>
</van-pull-refresh>
<MobileBottomNav />
</main>
</template>
<style scoped>
.mobile-messages {
min-height: 100dvh;
background: #f5f7fb;
padding-bottom: calc(62px + env(safe-area-inset-bottom));
}
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: grid;
grid-template-columns: 44px 1fr 72px;
align-items: center;
height: 48px;
padding: 0 8px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f5;
backdrop-filter: blur(10px);
}
.page-header h1 {
margin: 0;
color: #111827;
font-size: 17px;
font-weight: 800;
text-align: center;
}
.back-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
background: transparent;
color: #374151;
}
.header-count {
color: #ef4444;
font-size: 12px;
font-weight: 700;
text-align: right;
}
.scroll-container {
min-height: calc(100dvh - 48px - 62px - env(safe-area-inset-bottom));
}
.empty-state {
padding-top: 90px;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
}
.conversation-item {
position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 10px;
width: 100%;
padding: 12px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
text-align: left;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.conversation-item:active {
transform: scale(0.99);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
}
.avatar {
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
font-weight: 800;
}
.avatar.main {
width: 44px;
height: 44px;
background: #1477ff;
font-size: 16px;
}
.avatar.support {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
border: 2px solid #fff;
background: #10b981;
font-size: 11px;
}
.conversation-main {
min-width: 0;
}
.conversation-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.conversation-title-row h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 11px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
color: #6b7280;
font-size: 11px;
}
.role-chip {
padding: 1px 6px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-weight: 700;
}
.conversation-main p {
margin: 7px 0 0;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-badge {
position: absolute;
right: 10px;
bottom: 10px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 18px;
text-align: center;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,465 +0,0 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { showDialog, showToast } from "vant";
import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { fetchOrders, startOrderPayment, type Order, type PaymentOrder } from "@/api/orders";
import { useSessionStore } from "@/stores/session";
import { formatDateMinute } from "@/utils/time";
const router = useRouter();
const route = useRoute();
const session = useSessionStore();
const loading = ref(false);
const orders = ref<Order[]>([]);
const activeTab = ref("all");
const payingOrderId = ref<number | null>(null);
onMounted(() => {
loadOrders();
if (route.query.tab) {
activeTab.value = String(route.query.tab);
}
});
async function loadOrders() {
loading.value = true;
try {
orders.value = await fetchOrders();
} catch {
showToast({ message: "订单加载失败,请稍后重试", icon: "warning-o" });
} finally {
loading.value = false;
}
}
/* 状态筛选 */
const statusTabs = [
{ key: "all", label: "全部" },
{ key: "pending_payment", label: "待支付" },
{ key: "pending_handoff", label: "待交接" },
{ key: "renting", label: "使用中" },
{ key: "pending_checkout_confirm", label: "待结账" },
{ key: "completed", label: "已完成" },
];
const displayOrders = computed(() => {
if (activeTab.value === "all") return orders.value;
return orders.value.filter((o) => o.status === activeTab.value);
});
function statusLabel(status: string) {
const map: Record<string, string> = {
pending_payment: "待支付",
pending_handoff: "待交接",
renting: "使用中",
overdue: "已逾期",
pending_return_confirm: "待结账",
pending_checkout_confirm: "待号主确认",
pending_checkout_accept: "待租客确认",
checkout_disputing: "结账争议中",
completed: "已完成",
cancelled: "已取消",
disputing: "申诉中",
abnormal: "异常",
closed: "已关闭",
};
return map[status] || status;
}
function goDetail(id: number) {
router.push(`/m/orders/${id}`);
}
async function handlePay(order: Order) {
payingOrderId.value = order.id;
try {
const payment = await startOrderPayment(order.id);
if (payment.paid) {
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
await loadOrders();
} else {
openPaymentCashier(payment);
}
} catch (error) {
showToast({ message: readError(error, "支付失败"), icon: "cross" });
} finally {
payingOrderId.value = null;
}
}
function openPaymentCashier(payment: PaymentOrder) {
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || "";
if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL;
return;
}
showDialog({
title: "订单支付",
message: payURL || "支付单已创建,请在订单详情页刷新支付状态。",
confirmButtonText: "查看详情",
}).then(() => {
router.push(`/m/orders/${payment.order_id}`);
});
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
function money(value: unknown) {
return Math.round(Number(value || 0));
}
</script>
<template>
<main class="mobile-orders">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>我的订单</h1>
<span class="header-spacer"></span>
</header>
<!-- 状态筛选条 - Vant 滑动标签页 -->
<van-tabs
v-model:active="activeTab"
class="custom-tabs"
line-width="20px"
line-height="3px"
color="#1477ff"
title-active-color="#1477ff"
title-inactive-color="#6b7280"
:border="false"
swipeable
animated
>
<van-tab
v-for="tab in statusTabs"
:key="tab.key"
:title="tab.label"
:name="tab.key"
/>
</van-tabs>
<!-- 订单列表 -->
<section class="order-list">
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
加载中...
</van-loading>
<div v-else-if="displayOrders.length === 0" class="empty-state-wrap">
<van-empty description="暂无相关订单" image="search" />
</div>
<div
v-else
v-for="order in displayOrders"
:key="order.id"
class="order-card"
@click="goDetail(order.id)"
>
<!-- 卡片头订单号 + 状态 -->
<div class="card-header">
<div class="header-left">
<span class="order-no">{{ order.order_no }}</span>
</div>
<span class="status-badge" :class="'badge-' + order.status">
{{ statusLabel(order.status) }}
</span>
</div>
<!-- 卡片体关键信息 -->
<div class="card-body">
<h3 class="order-title">{{ order.title }}</h3>
<div class="tag-row">
<span class="info-tag">{{ order.server_region }}</span>
<span class="info-tag">{{ order.login_platform }}</span>
</div>
<div class="price-row">
<div class="price-item">
<span class="price-label">我的金额</span>
<span class="price-val">¥{{ money(order.display_amount) }}</span>
</div>
<div class="price-item">
<span class="price-label">押金金额</span>
<span class="price-val deposit">¥{{ money(order.deposit_amount) }}</span>
</div>
</div>
</div>
<!-- 卡片底时间 + 操作 -->
<div class="card-footer">
<div class="time-box">
<van-icon name="clock-o" class="clock-icon" />
<span class="order-time">{{ formatDateMinute(order.created_at) }}</span>
</div>
<div class="footer-action">
<van-button
v-if="order.status === 'pending_payment' && order.renter_id === session.userId"
size="small"
type="warning"
round
class="pay-btn"
:loading="payingOrderId === order.id"
@click.stop="handlePay(order)"
>
去支付
</van-button>
<span v-else class="detail-link">
查看详情 <van-icon name="arrow" :size="10" />
</span>
</div>
</div>
</div>
</section>
<!-- 底部导航 -->
<MobileBottomNav />
</main>
</template>
<style scoped>
.mobile-orders {
min-height: 100dvh;
background: #f6f8fa;
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.8);
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
color: #111827;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #374151;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
/* ========== Vant Tabs 自定义样式 ========== */
.custom-tabs {
position: sticky;
top: 48px;
z-index: 99;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.6);
}
:deep(.van-tabs__nav) {
background: transparent;
padding-bottom: 4px;
}
:deep(.van-tab) {
font-size: 13px;
font-weight: 600;
}
/* ========== 订单列表 ========== */
.order-list {
padding: 14px 16px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
.empty-state-wrap {
padding: 40px 0;
}
/* ========== 订单卡片 ========== */
.order-card {
background: #ffffff;
border-radius: 16px;
margin-bottom: 14px;
padding: 16px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02);
transition: transform 0.1s ease, box-shadow 0.1s ease;
border: 1px solid rgba(243, 244, 246, 0.9);
cursor: pointer;
}
.order-card:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px dashed #f3f4f6;
}
.order-no {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
}
.status-badge {
padding: 4px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
/* 状态徽章颜色 - 现代轻量化配色 */
.badge-pending_payment { color: #ff6a00; background: rgba(255, 106, 0, 0.08); }
.badge-pending_handoff { color: #d97706; background: rgba(217, 119, 6, 0.08); }
.badge-renting { color: #1477ff; background: rgba(20, 119, 255, 0.08); }
.badge-overdue { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
.badge-pending_return_confirm { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); }
.badge-pending_checkout_confirm { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); }
.badge-pending_checkout_accept { color: #a855f7; background: rgba(168, 85, 247, 0.08); }
.badge-checkout_disputing { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
.badge-completed { color: #10b981; background: rgba(16, 185, 129, 0.08); }
.badge-cancelled { color: #9ca3af; background: rgba(156, 163, 175, 0.08); }
.badge-disputing { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
.badge-abnormal { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
.badge-closed { color: #6b7280; background: rgba(107, 114, 128, 0.08); }
.card-body {
padding: 12px 0 0;
}
.order-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 700;
color: #111827;
line-height: 1.4;
}
.tag-row {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.info-tag {
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
padding: 3px 8px;
border-radius: 6px;
font-weight: 500;
}
.price-row {
display: flex;
background: #f9fafb;
border-radius: 12px;
padding: 10px 14px;
justify-content: space-between;
gap: 12px;
}
.price-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.price-label {
font-size: 10px;
color: #9ca3af;
}
.price-val {
font-size: 15px;
font-weight: 800;
color: #ff5f00;
}
.price-val.deposit {
color: #374151;
}
/* ========== 卡片底部 ========== */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.time-box {
display: flex;
align-items: center;
gap: 4px;
color: #9ca3af;
}
.clock-icon {
font-size: 13px;
}
.order-time {
font-size: 11px;
}
.detail-link {
font-size: 12px;
color: #1477ff;
font-weight: 600;
display: flex;
align-items: center;
gap: 2px;
}
.pay-btn {
height: 28px !important;
padding: 0 16px !important;
font-size: 12px !important;
font-weight: 700 !important;
background: linear-gradient(135deg, #ff8c00, #ff5f00) !important;
border: none !important;
box-shadow: 0 4px 10px rgba(255, 95, 0, 0.2) !important;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,266 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import {
getRealnameStatus,
startRealname,
type RealnameStatus,
} from "@/api/realname";
import { realnameStatusLabel } from "@/utils/statusLabels";
import { useSessionStore } from "@/stores/session";
import { formatDateTime } from "@/utils/time";
const session = useSessionStore();
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const status = ref<RealnameStatus | null>(null);
const form = reactive({
name: "",
idNo: "",
});
onMounted(loadStatus);
function redirectAfterVerified() {
const redirect =
typeof route.query.redirect === "string" ? route.query.redirect : "";
if (redirect) {
router.replace(redirect);
}
}
async function loadStatus() {
if (!session.token) return;
try {
status.value = await getRealnameStatus();
if (status.value.status === "verified") {
await session.loadMe();
redirectAfterVerified();
}
} catch {
status.value = null;
}
}
async function handleSubmit() {
if (!form.name.trim()) {
showToast({ message: "请输入真实姓名", icon: "warning-o" });
return;
}
if (!form.idNo.trim() || form.idNo.length < 15) {
showToast({ message: "请输入有效的证件号", icon: "warning-o" });
return;
}
loading.value = true;
try {
status.value = await startRealname(form.name, form.idNo);
await session.loadMe();
showDialog({
title: "认证成功",
message: "实名认证已通过",
confirmButtonText: "好的",
}).then(() => {
redirectAfterVerified();
});
} catch (error: unknown) {
const msg =
typeof error === "object" && error && "response" in error
? (
error as {
response?: { data?: { message?: string } };
}
).response?.data?.message || "实名认证失败"
: "实名认证失败";
showToast({ message: msg, icon: "cross" });
} finally {
loading.value = false;
}
}
</script>
<template>
<main class="mobile-realname">
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>实名认证</h1>
<span class="header-spacer"></span>
</header>
<!-- 未登录提示 -->
<van-empty
v-if="!session.token"
description="请先登录后再实名认证"
image="search"
/>
<template v-else>
<!-- 认证状态 -->
<section v-if="status" class="status-card">
<div class="status-row">
<span class="status-label">当前状态</span>
<van-tag
:type="status.status === 'verified' ? 'success' : 'warning'"
size="medium"
round
>
{{ realnameStatusLabel(status.status) }}
</van-tag>
</div>
<p v-if="status.masked_name" class="status-detail">
姓名{{ status.masked_name }}
</p>
<p v-if="status.masked_id_no" class="status-detail">
证件号{{ status.masked_id_no }}
</p>
<p v-if="status.verified_at" class="status-detail">
通过时间{{ formatDateTime(status.verified_at) }}
</p>
</section>
<!-- 认证表单 -->
<section v-if="status?.status !== 'verified'" class="form-card">
<h2>提交认证</h2>
<p class="form-hint">
号主发布前必须完成实名认证提交合法姓名和身份证号即可认证
</p>
<van-field
v-model="form.name"
label="姓名"
placeholder="请输入真实姓名"
clearable
class="realname-field"
/>
<van-field
v-model="form.idNo"
label="身份证号"
placeholder="请输入18位身份证号"
maxlength="18"
clearable
class="realname-field"
/>
<van-button
type="primary"
block
round
:loading="loading"
class="submit-btn"
@click="handleSubmit"
>
提交认证
</van-button>
</section>
</template>
</main>
</template>
<style scoped>
.mobile-realname {
min-height: 100dvh;
background: #f5f7fa;
padding-bottom: 20px;
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #eee;
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #333;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
/* ========== 认证状态 ========== */
.status-card {
margin: 16px 12px;
padding: 16px;
border-radius: 16px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-label {
font-size: 15px;
font-weight: 700;
color: #1a202c;
}
.status-detail {
margin: 8px 0 0;
font-size: 13px;
color: #666;
}
/* ========== 认证表单 ========== */
.form-card {
margin: 0 12px 16px;
padding: 18px 16px;
border-radius: 16px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.form-card h2 {
margin: 0 0 6px;
font-size: 17px;
font-weight: 700;
}
.form-hint {
margin: 0 0 16px;
font-size: 13px;
color: #999;
line-height: 1.5;
}
.realname-field {
margin-bottom: 12px;
border-radius: 10px;
overflow: hidden;
}
.submit-btn {
margin-top: 8px;
background: #1477ff;
border-color: transparent;
font-weight: 800;
height: 44px;
font-size: 16px;
}
</style>
@@ -1,353 +0,0 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { useSessionStore } from "@/stores/session";
import { useSmsCountdown } from "@/composables/useSmsCountdown";
const router = useRouter();
const route = useRoute();
const session = useSessionStore();
const loading = ref(false);
const agreed = ref(false);
const form = reactive({
phone: "",
code: "",
inviteCode: "",
});
const { countDown, sending, handleSendCode, readError } = useSmsCountdown();
async function handleRegister() {
if (!agreed.value) {
showToast({
message: "请先阅读并同意用户协议和隐私政策",
icon: "warning-o",
});
return;
}
loading.value = true;
try {
await session.login(form.phone, form.code);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/m/profile";
await router.replace(redirect);
} catch {
showToast({ message: "注册失败,请检查手机号和验证码", icon: "cross" });
} finally {
loading.value = false;
}
}
</script>
<template>
<main class="mobile-register-shell">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
</header>
<section class="auth-body">
<div class="brand-lockup">
<div class="brand-logo"></div>
<strong>大锤商行</strong>
</div>
<div class="form-section">
<h1>注册</h1>
<label class="auth-input-row">
<input
v-model="form.phone"
type="tel"
maxlength="11"
inputmode="tel"
placeholder="手机号"
/>
</label>
<label class="auth-input-row code-row">
<input
v-model="form.code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="验证码"
/>
<span class="code-action">
<van-button
size="small"
plain
:disabled="sending || countDown > 0"
:loading="sending"
class="code-btn"
@click="handleSendCode(form.phone)"
>
{{
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
</van-button>
</span>
</label>
<label class="auth-input-row">
<input
v-model="form.inviteCode"
maxlength="16"
placeholder="邀请码(选填)"
/>
</label>
<div class="assist-row">
<span>验证码注册</span>
<RouterLink to="/m/login">已有账号<b>去登录</b></RouterLink>
</div>
<div class="agreement-row">
<van-checkbox v-model="agreed" shape="square" icon-size="16px">
我已阅读并同意<b>用户协议</b><b>隐私政策</b>
</van-checkbox>
</div>
<van-button
type="primary"
block
class="primary-button"
:loading="loading"
loading-text="注册中..."
@click="handleRegister"
>
注册并登录
</van-button>
<RouterLink to="/m/login" class="secondary-entry">
返回登录
</RouterLink>
</div>
</section>
</main>
</template>
<style scoped>
.mobile-register-shell {
min-height: 100dvh;
background:
radial-gradient(circle at 50% 12%, rgba(20, 119, 255, 0.1), transparent 34%),
linear-gradient(180deg, #f8fbff 0%, #ffffff 70%, #f7fafc 100%);
color: #111827;
}
.page-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
margin: 0;
padding: 0 18px;
background: transparent;
}
.back-btn {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: none;
border-radius: 999px;
background: none;
color: #111827;
}
.auth-body {
box-sizing: border-box;
display: flex;
min-height: 100dvh;
flex-direction: column;
justify-content: center;
padding: 50px 34px 22px;
}
.brand-lockup {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 0 auto clamp(24px, 4vh, 38px);
}
.brand-logo {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
box-shadow: 0 14px 32px rgba(20, 119, 255, 0.18);
color: #ffffff;
font-size: 19px;
font-weight: 900;
}
.brand-lockup strong {
display: block;
color: #111827;
font-size: 18px;
font-weight: 900;
letter-spacing: 0;
}
.form-section {
width: min(100%, 420px);
margin: 0 auto;
}
.form-section h1 {
margin: 0 0 20px;
color: #05070a;
font-size: 22px;
font-weight: 900;
line-height: 1.2;
}
.auth-input-row {
display: flex;
align-items: center;
min-height: 48px;
margin-bottom: 10px;
padding: 0 16px;
border: 1px solid #e1e5eb;
border-radius: 10px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.03);
}
.auth-input-row.code-row {
gap: 8px;
}
.auth-input-row input {
width: 100%;
min-width: 0;
border: none;
background: transparent;
color: #0f172a;
font-size: 16px;
outline: none;
}
.auth-input-row input::placeholder {
color: #b6beca;
}
.code-action {
display: flex;
justify-content: flex-end;
}
.code-btn {
min-width: 82px;
height: 32px;
border-color: #1477ff !important;
border-radius: 8px;
color: #1477ff !important;
font-weight: 700;
}
.assist-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 12px 0 18px;
color: #111827;
font-size: 13px;
}
.assist-row span {
min-width: 0;
color: #1477ff;
}
.assist-row a {
flex-shrink: 0;
color: #22252b;
text-decoration: none;
}
.assist-row b {
color: #05070a;
font-weight: 900;
}
.agreement-row {
display: flex;
align-items: center;
margin: 0 0 18px;
}
.agreement-row :deep(.van-checkbox__label) {
margin-left: 8px;
color: #252b36;
font-size: 12px;
line-height: 1.4;
}
.agreement-row :deep(.van-checkbox__label b) {
color: #1477ff;
font-weight: 700;
}
.primary-button {
height: 48px;
border: none !important;
border-radius: 10px !important;
background: #1477ff !important;
box-shadow: 0 12px 24px rgba(20, 119, 255, 0.18);
font-size: 17px;
font-weight: 900;
}
.secondary-entry {
display: grid;
min-height: 44px;
margin-top: 10px;
place-items: center;
border: 1px solid #e4e8ee;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
color: #1477ff;
font-size: 15px;
font-weight: 900;
text-align: center;
text-decoration: none;
}
@media (max-height: 760px) {
.auth-body {
justify-content: flex-start;
padding-top: 48px;
}
.brand-lockup {
margin-bottom: 20px;
}
.form-section h1 {
margin-bottom: 16px;
}
.assist-row {
margin-bottom: 14px;
}
}
</style>
@@ -1,673 +0,0 @@
.mobile-publish {
width: 100%;
min-width: 0;
min-height: 100dvh;
background: #f4f6f8;
padding-bottom: calc(56px + env(safe-area-inset-bottom));
overflow-x: hidden;
}
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #eceff3;
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #25282d;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
.form-body {
padding: 12px 14px 24px;
}
.form-section {
padding: 14px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #edf0f4;
border-radius: 8px;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: #171a1f;
}
.field-hint {
margin: -2px 0 8px;
color: #858c96;
font-size: 11px;
line-height: 1.5;
}
.publish-field {
margin-bottom: 8px;
border-radius: 8px;
background: #f8fafc;
overflow: visible;
}
.publish-field :deep(.van-field__label) {
width: 88px;
color: #30343a;
font-size: 13px;
font-weight: 600;
}
.publish-field :deep(.van-field__control) {
color: #20242a;
font-size: 14px;
}
.hint-label,
.backend-hint-target.has-hint,
.upload-title.has-hint {
position: relative;
display: inline-flex;
align-items: center;
max-width: 100%;
line-height: 1.35;
cursor: help;
outline: none;
}
.hint-label::before,
.backend-hint-target.has-hint::before,
.upload-title.has-hint::before,
.hint-label::after,
.backend-hint-target.has-hint::after,
.upload-title.has-hint::after {
position: absolute;
z-index: 20;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.16s ease, visibility 0.16s ease;
}
.hint-label::before,
.backend-hint-target.has-hint::before,
.upload-title.has-hint::before {
content: "";
top: 50%;
left: calc(100% + 2px);
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: rgba(32, 36, 42, 0.96);
}
.hint-label::after,
.backend-hint-target.has-hint::after,
.upload-title.has-hint::after {
content: attr(data-hint);
top: 50%;
left: calc(100% + 14px);
width: min(242px, calc(100vw - 136px));
max-height: 156px;
overflow: auto;
transform: translateY(-50%);
padding: 8px 10px;
border-radius: 8px;
background: rgba(32, 36, 42, 0.96);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
color: #fff;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
white-space: normal;
}
.hint-label:hover::before,
.hint-label:focus-visible::before,
.backend-hint-target.has-hint:hover::before,
.backend-hint-target.has-hint:focus-visible::before,
.upload-title.has-hint:hover::before,
.upload-title.has-hint:focus-visible::before,
.hint-label:hover::after,
.hint-label:focus-visible::after,
.backend-hint-target.has-hint:hover::after,
.backend-hint-target.has-hint:focus-visible::after,
.upload-title.has-hint:hover::after,
.upload-title.has-hint:focus-visible::after {
opacity: 1;
visibility: visible;
}
.radio-group,
.multi-chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.radio-group.small {
gap: 6px;
}
.radio-btn {
min-height: 28px;
padding: 5px 12px;
border: 1px solid #d7dce3;
border-radius: 14px;
background: #fff;
color: #5c6470;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.radio-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.time-row,
.result-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.level-panel {
display: grid;
gap: 8px;
}
.level-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.level-label {
color: #30343a;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.level-label span {
margin-right: 2px;
color: #ee0a24;
}
.level-options {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.level-btn {
min-width: 0;
height: 30px;
padding: 0;
border: 1px solid #d7dce3;
border-radius: 15px;
background: #fff;
color: #5c6470;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.level-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.quantity-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 92px;
gap: 8px;
align-items: stretch;
margin-bottom: 8px;
}
.quantity-row.disabled {
opacity: 0.62;
}
.quantity-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
min-height: 72px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.quantity-meta {
display: grid;
min-width: 0;
gap: 5px;
}
.quantity-meta strong {
overflow: hidden;
color: #30343a;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.quantity-meta span {
margin-right: 2px;
color: #ee0a24;
}
.quantity-meta small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.quantity-input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 8px;
border: 1px solid #e0e5ec;
border-radius: 8px;
background: #fff;
color: #20242a;
font-size: 16px;
font-weight: 700;
text-align: center;
outline: none;
}
.quantity-input:focus {
border-color: #1477ff;
}
.quantity-input:disabled {
cursor: not-allowed;
}
.mode-toggle {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
min-height: 72px;
}
.mode-toggle button {
border: 1px solid #d7dce3;
border-radius: 8px;
background: #fff;
color: #606873;
font-size: 12px;
font-weight: 700;
}
.mode-toggle button.active {
border-color: #ff6a00;
background: #fff3ea;
color: #e65f00;
}
.mode-toggle button:disabled {
cursor: not-allowed;
}
.skin-groups {
display: grid;
gap: 10px;
margin-bottom: 8px;
}
.skin-group {
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.skin-group-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.skin-group-title small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.region-panel {
display: grid;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.region-title {
display: flex;
align-items: center;
justify-content: space-between;
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.region-title small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.region-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.region-btn {
min-width: 0;
height: 28px;
padding: 0 2px;
border: 1px solid #d7dce3;
border-radius: 14px;
background: #fff;
color: #5c6470;
font-size: 11px;
font-weight: 700;
}
.region-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.upload-list {
display: grid;
gap: 10px;
}
.upload-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
gap: 10px;
align-items: center;
min-height: 76px;
padding: 10px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #f8fafc;
}
.upload-meta {
display: grid;
gap: 4px;
}
.upload-meta strong {
color: #242830;
font-size: 13px;
}
.upload-meta span {
color: #f04438;
}
.upload-meta small {
color: #858c96;
font-size: 11px;
line-height: 1.4;
}
.upload-add,
.upload-preview {
width: 76px;
height: 76px;
border-radius: 8px;
}
.upload-add {
display: grid;
place-items: center;
border: 1px dashed #c7cdd6;
background: #fff;
color: #7b8490;
font-size: 11px;
}
.upload-add:disabled {
opacity: 0.5;
}
.upload-preview {
position: relative;
overflow: hidden;
background: #e8ecf1;
}
.upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-preview button {
position: absolute;
right: 0;
bottom: 0;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 8px 0 0;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 11px;
}
.result-field :deep(.van-field__control) {
color: #ff6a00;
font-weight: 800;
}
.ratio-panel {
display: grid;
gap: 10px;
margin-bottom: 8px;
}
.deposit-recommend-btn {
min-height: 32px;
margin: -2px 0 10px 88px;
padding: 0 12px;
border: 1px solid #ffba86;
border-radius: 16px;
background: #fff7f0;
color: #e65f00;
font-size: 12px;
font-weight: 800;
}
.ratio-reference {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 48px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.ratio-reference span {
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.ratio-reference strong {
color: #ff6a00;
font-size: 18px;
font-weight: 900;
line-height: 1;
}
.ratio-range {
margin: 0;
color: #858c96;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
}
.ratio-mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ratio-mode-btn {
display: grid;
gap: 5px;
min-width: 0;
min-height: 74px;
padding: 12px 8px;
border: 1px solid #d7dce3;
border-radius: 8px;
background: #fff;
color: #30343a;
text-align: center;
}
.ratio-mode-btn strong,
.ratio-mode-btn span {
min-width: 0;
overflow-wrap: anywhere;
}
.ratio-mode-btn strong {
font-size: 14px;
font-weight: 800;
}
.ratio-mode-btn span {
color: #858c96;
font-size: 12px;
font-weight: 700;
line-height: 1.25;
}
.ratio-mode-btn.active {
border-color: #ff6a00;
background: #fff7f0;
color: #ff6a00;
}
.ratio-mode-btn.active span {
color: #ff6a00;
}
.ratio-mode-btn:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.ratio-input-field {
margin-bottom: 0;
}
.ratio-input-hint {
margin: -2px 0 0;
}
.total-price-field :deep(.van-field__control) {
font-size: 16px;
}
.price-breakdown-hint {
margin: -2px 0 10px;
color: #858c96;
font-size: 11px;
line-height: 1.5;
}
.publish-actions {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
margin-top: 8px;
}
.reset-btn,
.submit-btn {
height: 44px;
width: 100%;
border: none !important;
font-size: 15px;
font-weight: 700;
}
.reset-btn {
border: 1px solid #d7dce3 !important;
background: #fff !important;
color: #4b5563 !important;
}
.submit-btn {
background: #ff6a00 !important;
}
@media (min-width: 430px) {
.time-row {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 370px) {
.level-options,
.region-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@@ -1,597 +0,0 @@
<script setup lang="ts">
import { showDialog, showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { usePublishForm } from '@/composables/usePublishForm'
const {
commonOnlineTimes,
dailyLossOptions,
formatNumber,
router,
loading,
uploading,
fileInput,
activeUploadKey,
form,
quantityValues,
quantityModes,
screenshotFiles,
selectedSkins,
serverOptions,
faceOptions,
rankOptions,
insuranceOptions,
levelOptions,
loginMethodOptions,
regionOptions,
banRecordOptions,
skinGroups,
quantityItems,
screenshotSlots,
priceConfig,
fireLevelPlaceholder,
coinMAmount,
dailyLossMAmount,
calculatedDefaultSaleRatio,
maxAcceleratedSaleRatio,
calculatedCoinBasePrice,
calculatedConsumablePrice,
calculatedSellerPrice,
calculatedRatioText,
calculatedDefaultSaleRatioText,
saleRatioRangeText,
acceleratedSaleRatioPlaceholder,
recommendedDepositAmount,
hasAcceleratedSaleRatioInput,
isQuantityItemDisabled,
handleResetDraft,
toggleSkin,
toggleRegion,
triggerUpload,
handleScreenshotUpload,
removeScreenshot,
getScreenshotPreviewURL,
handleFireLevelInput,
handleAcceleratedSaleRatioInput,
useRecommendedDeposit,
setQuantityMode,
clampAcceleratedSaleRatioInput,
useReferenceSaleRatio,
useMaxAcceleratedSaleRatio,
handleSubmit,
isScreenshotRequired,
} = usePublishForm({
draftKey: 'hfb.mobile.publish.draft',
submitSuccessPath: '/m/profile',
async confirmReset() {
await showDialog({
title: '重置发布内容',
message: '将清空当前填写内容和本地草稿。',
confirmButtonText: '重置',
cancelButtonText: '取消',
showCancelButton: true,
})
},
notifySuccess: (message) => showToast({ message, icon: 'passed' }),
notifyWarning: (message) => showToast({ message, icon: 'warning-o' }),
notifyError: (message) => showToast({ message, icon: 'cross' }),
})
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
setter(value)
}
</script>
<template>
<main class="mobile-publish">
<header class="page-header">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>发布账号</h1>
<span class="header-spacer"></span>
</header>
<section class="form-body">
<div class="form-section">
<h2 class="section-title">基础资料</h2>
<van-field label="区服" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in serverOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.server_region === opt }"
@click="selectRadio(opt, (value) => (form.server_region = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field label="是否本人人脸" class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in faceOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.face_owner === opt }"
@click="selectRadio(opt, (value) => (form.face_owner = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field
v-model="form.haf_coin_amount"
label="哈夫币/M"
type="digit"
required
placeholder="只填写仓库右上角纯币数额,总资产不计算在内;100M 写 100"
class="publish-field"
/>
<van-field label="段位" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in rankOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.rank_level === opt }"
@click="selectRadio(opt, (value) => (form.rank_level = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<van-field
v-model="form.secret_kd"
label="绝密KD"
type="number"
placeholder="填写绝密KD,如果数据对不上买家可以申请退款"
class="publish-field"
/>
<van-field
:model-value="form.fire_level"
label="烽火等级"
type="digit"
required
:placeholder="fireLevelPlaceholder"
class="publish-field"
@update:model-value="handleFireLevelInput"
/>
<van-field label="赛季保险" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in insuranceOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.season_insurance === opt }"
@click="selectRadio(opt, (value) => (form.season_insurance = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<div class="level-panel">
<div class="level-row">
<div class="level-label"><span>*</span>体力</div>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`stamina-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.stamina_level === opt }"
@click="selectRadio(opt, (value) => (form.stamina_level = value))"
>
{{ opt }}
</button>
</div>
</div>
<div class="level-row">
<div class="level-label"><span>*</span>负重</div>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`load-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.load_level === opt }"
@click="selectRadio(opt, (value) => (form.load_level = value))"
>
{{ opt }}
</button>
</div>
</div>
</div>
</div>
<div class="form-section">
<h2 class="section-title">额外消耗品</h2>
<div
v-for="item in quantityItems"
:key="item.key"
class="quantity-row"
:class="{ disabled: isQuantityItemDisabled(item) }"
>
<div class="quantity-card">
<div class="quantity-meta">
<strong
class="backend-hint-target"
:class="{ 'has-hint': Boolean(item.placeholder) }"
:data-hint="item.placeholder"
:tabindex="item.placeholder ? 0 : -1"
>
<span>*</span>{{ item.label }}
</strong>
<small>{{ isQuantityItemDisabled(item) ? "3*3 已包含,不能填写" : item.price }}</small>
</div>
<input
v-model="quantityValues[item.key]"
type="number"
inputmode="numeric"
min="0"
placeholder="0"
class="quantity-input"
:disabled="isQuantityItemDisabled(item)"
/>
</div>
<div class="mode-toggle">
<button
type="button"
:class="{ active: quantityModes[item.key] === '赠送' }"
:disabled="isQuantityItemDisabled(item)"
@click="setQuantityMode(item, '赠送')"
>
赠送
</button>
<button
type="button"
:class="{ active: (quantityModes[item.key] || '收费') === '收费' }"
:disabled="isQuantityItemDisabled(item)"
@click="setQuantityMode(item, '收费')"
>
收费
</button>
</div>
</div>
</div>
<div class="form-section">
<h2 class="section-title">皮肤与交接</h2>
<div class="skin-groups">
<div v-for="group in skinGroups" :key="group.key" class="skin-group">
<div class="skin-group-title">
<span>{{ group.title }}</span>
<small>
{{
group.options.filter((skin) => selectedSkins.includes(skin))
.length
}}
</small>
</div>
<div class="multi-chip-group">
<button
v-for="skin in group.options"
:key="skin"
type="button"
class="radio-btn"
:class="{ active: selectedSkins.includes(skin) }"
@click="toggleSkin(skin)"
>
{{ skin }}
</button>
</div>
</div>
</div>
<van-field label="上号方式" class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in loginMethodOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.login_method === opt }"
@click="selectRadio(opt, (value) => (form.login_method = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<p class="field-hint">
优先推荐账密登录出售速度约为扫码的数倍两种方式安全性一致账密登录更便捷请自行衡量
</p>
<div class="time-row">
<van-field
v-model="form.online_start"
label="在线开始"
type="time"
class="publish-field time-field"
/>
<van-field
v-model="form.online_end"
label="在线结束"
type="time"
class="publish-field time-field"
/>
</div>
<p class="field-hint">
此在线时间指的是百分百能够联系上您的时间若是在此期间联系不上您导致无法上号会扣除您的部分订单金额或上架押金在线时长太短可能无法上架请预留充足时间用于扫码以及冻结人脸请谨慎填写
</p>
<van-field label="封禁记录" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="opt in banRecordOptions"
:key="opt"
type="button"
class="radio-btn"
:class="{ active: form.ban_record === opt }"
@click="selectRadio(opt, (value) => (form.ban_record = value))"
>
{{ opt }}
</button>
</div>
</template>
</van-field>
<div class="region-panel">
<div class="region-title">
<span>常用登录地区</span>
<small>已选 {{ form.common_regions.length }}</small>
</div>
<div class="region-grid">
<button
v-for="region in regionOptions"
:key="region"
type="button"
class="region-btn"
:class="{ active: form.common_regions.includes(region) }"
@click="toggleRegion(region)"
>
{{ region }}
</button>
</div>
</div>
<p class="field-hint">
推荐勾选账号常用登录地便于同地区玩家租用可大幅减少账号触发异地登录保护
</p>
</div>
<div class="form-section">
<h2 class="section-title">截图材料</h2>
<div class="upload-list">
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-line">
<div class="upload-meta">
<strong
class="upload-title"
:class="{ 'has-hint': Boolean(slot.hint) }"
:data-hint="slot.hint"
:tabindex="slot.hint ? 0 : -1"
>
{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span>
</strong>
<small>{{ slot.hint }}</small>
</div>
<div v-if="screenshotFiles[slot.key]" class="upload-preview">
<img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
<button type="button" @click="removeScreenshot(slot.key)">移除</button>
</div>
<button
v-else
type="button"
class="upload-add"
:disabled="uploading"
@click="triggerUpload(slot.key)"
>
<van-icon name="photograph" :size="22" color="#999" />
<span>{{ uploading && activeUploadKey === slot.key ? "上传中" : "上传" }}</span>
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
style="display: none"
@change="handleScreenshotUpload"
/>
</div>
<div class="form-section">
<h2 class="section-title">押金与价格</h2>
<van-field
v-model="form.deposit_amount"
label="押金"
type="number"
required
:placeholder="priceConfig.deposit_placeholder"
suffix="元"
class="publish-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.deposit_placeholder" tabindex="0">押金</span>
</template>
</van-field>
<button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit">
推荐押金 ¥{{ recommendedDepositAmount }}
</button>
<van-field label="每日损耗" required class="publish-field">
<template #input>
<div class="radio-group">
<button
v-for="loss in dailyLossOptions"
:key="loss"
type="button"
class="radio-btn"
:class="{ active: dailyLossMAmount === loss }"
@click="form.daily_loss_m = loss"
>
{{ loss }}M/
</button>
</div>
</template>
</van-field>
<p class="field-hint">
比例调整在默认10M/每增加10M出租比例相应+1请根据实际需求合理设置感谢您的配合
</p>
<div class="ratio-panel">
<div class="ratio-reference">
<span>参考比例</span>
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
</div>
<p class="ratio-range">{{ saleRatioRangeText }}比例越高出租速度越快自行选择</p>
<div class="ratio-mode-grid">
<button
type="button"
class="ratio-mode-btn"
:class="{ active: !hasAcceleratedSaleRatioInput() }"
:disabled="calculatedDefaultSaleRatio <= 0"
@click="useReferenceSaleRatio"
>
<strong>参考比例</strong>
<span>
{{
calculatedDefaultSaleRatio > 0
? `1元=${formatNumber(calculatedDefaultSaleRatio)}`
: "自动计算"
}}
</span>
</button>
<button
type="button"
class="ratio-mode-btn"
:class="{ active: hasAcceleratedSaleRatioInput() }"
:disabled="maxAcceleratedSaleRatio <= 0"
@click="useMaxAcceleratedSaleRatio"
>
<strong>加速比例</strong>
<span>
{{
maxAcceleratedSaleRatio > 0
? `1元=${formatNumber(calculatedDefaultSaleRatio)}~${formatNumber(maxAcceleratedSaleRatio)}`
: "自动计算"
}}
</span>
</button>
</div>
<van-field
:model-value="form.accelerated_sale_ratio"
label="自定比例"
type="number"
:placeholder="acceleratedSaleRatioPlaceholder"
class="publish-field ratio-input-field"
@update:model-value="handleAcceleratedSaleRatioInput"
@blur="clampAcceleratedSaleRatioInput"
/>
<p class="field-hint ratio-input-hint">
留空使用参考比例填写后范围为参考比例到参考比例+10请根据实际需求合理设置
</p>
</div>
<div class="result-grid">
<van-field
:model-value="calculatedRatioText"
label="卖家比例"
readonly
:placeholder="priceConfig.ratio_description"
class="publish-field result-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.ratio_description" tabindex="0">卖家比例</span>
</template>
</van-field>
<van-field
:model-value="calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : ''"
label="纯币基础价"
readonly
required
:placeholder="priceConfig.price_placeholder"
class="publish-field result-field"
>
<template #label>
<span class="hint-label" :data-hint="priceConfig.price_placeholder" tabindex="0">纯币基础价</span>
</template>
</van-field>
<van-field
:model-value="`¥${calculatedConsumablePrice}`"
label="额外消耗品"
readonly
class="publish-field result-field"
/>
<van-field
:model-value="calculatedSellerPrice ? `¥${calculatedSellerPrice}` : ''"
label="卖家价格"
readonly
required
:placeholder="priceConfig.price_placeholder"
class="publish-field result-field total-price-field"
/>
</div>
<p class="price-breakdown-hint">
卖家价格 = 纯币基础价 + 额外消耗品
</p>
<van-field
v-model="form.remark"
label="备注"
type="textarea"
rows="3"
autosize
placeholder="如有一些不可使用的物资,请在此备注,并且在买家下单后,主动在群聊处,再次提醒买家"
class="publish-field"
/>
</div>
<div class="publish-actions">
<van-button
round
class="reset-btn"
:disabled="loading"
@click="handleResetDraft"
>
重置
</van-button>
<van-button
type="primary"
round
class="submit-btn"
:loading="loading"
loading-text="发布中..."
@click="handleSubmit"
>
保存发布
</van-button>
</div>
</section>
<MobileBottomNav />
</main>
</template>
<style scoped src="./MobileSellerListingCreateView.css"></style>
@@ -1,506 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { fetchSellerListings, offlineListing, submitListingReview, type Listing } from '@/api/listings'
import { fetchFileBlobByURL } from '@/api/files'
import { listingStatusLabel } from '@/utils/statusLabels'
import { getListingSellerPrice } from '@/utils/listingDisplay'
const router = useRouter()
const loading = ref(false)
const activeTab = ref('all')
interface DisplayListing extends Listing {
resolvedCover: string
updating: boolean
}
const displayListings = ref<DisplayListing[]>([])
onMounted(() => {
loadListings()
})
async function loadListings() {
loading.value = true
try {
const items = await fetchSellerListings()
displayListings.value = items.map(item => ({
...item,
resolvedCover: '',
updating: false
}))
// 异步解析图片,私有图片使用 Blob 加载,公有图片直接加载
displayListings.value.forEach((dl) => {
const coverUrl = dl.cover_url
if (!coverUrl) return
if (!coverUrl.includes('/api/files/object')) {
dl.resolvedCover = coverUrl
return
}
fetchFileBlobByURL(coverUrl)
.then(blob => {
dl.resolvedCover = URL.createObjectURL(blob)
})
.catch(() => {
dl.resolvedCover = '' // 失败则不展示
})
})
} catch {
showToast({ message: '获取商品列表失败', icon: 'cross' })
} finally {
loading.value = false
}
}
// 状态过滤逻辑
const filteredListings = computed(() => {
const tab = activeTab.value
if (tab === 'all') return displayListings.value
if (tab === 'reviewing') {
return displayListings.value.filter(item => item.review_status === 'pending')
}
if (tab === 'active') {
return displayListings.value.filter(item => item.status === 'published' && item.review_status === 'approved')
}
if (tab === 'offline') {
return displayListings.value.filter(item => item.status === 'offline' || item.review_status === 'rejected')
}
return displayListings.value
})
// 提审操作
async function handleSubmitReview(item: DisplayListing) {
item.updating = true
try {
const res = await submitListingReview(item.id)
showToast({
message: res.status === 'published' && res.review_status === 'approved' ? '已成功上架' : '已提交审核',
icon: 'passed'
})
await loadListings()
} catch (error) {
showToast({ message: '提审失败,请稍后重试', icon: 'cross' })
} finally {
item.updating = false
}
}
// 下架操作
async function handleOffline(item: DisplayListing) {
showDialog({
title: '下架确认',
message: '确定要下架该商品吗?下架后买家将无法搜索或租用该商品。',
showCancelButton: true
})
.then(async () => {
item.updating = true
try {
await offlineListing(item.id)
showToast({ message: '商品已下架', icon: 'passed' })
await loadListings()
} catch {
showToast({ message: '下架失败,请稍后重试', icon: 'cross' })
} finally {
item.updating = false
}
})
.catch(() => {})
}
function getStatusBadgeClass(item: Listing) {
if (item.review_status === 'pending') return 'badge-reviewing'
if (item.review_status === 'rejected') return 'badge-rejected'
if (item.status === 'published') return 'badge-active'
if (item.status === 'offline') return 'badge-offline'
if (item.status === 'rented') return 'badge-rented'
return 'badge-offline'
}
function getStatusText(item: Listing) {
if (item.review_status === 'pending') return '审核中'
if (item.review_status === 'rejected') return '审核被拒'
if (item.status === 'published') return '已上架'
if (item.status === 'offline') return '已下架'
if (item.status === 'rented') return '使用中'
return listingStatusLabel(item.status)
}
function listingPrice(item: Listing) {
return `${Math.round(getListingSellerPrice(item))}`
}
function goBack() {
router.push('/m/profile')
}
</script>
<template>
<main class="mobile-seller-listings">
<!-- 顶部导航栏 -->
<header class="page-header">
<button class="back-btn" @click="goBack">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>我的商品</h1>
<button class="header-action-btn" @click="router.push('/m/seller/listings/create')">
<van-icon name="plus" :size="18" />
<span>发布</span>
</button>
</header>
<!-- 状态切换 Tab -->
<van-tabs v-model:active="activeTab" class="custom-tabs" sticky>
<van-tab title="全部" name="all" />
<van-tab title="审核中" name="reviewing" />
<van-tab title="展示中" name="active" />
<van-tab title="已下架" name="offline" />
</van-tabs>
<!-- 商品列表 -->
<section class="listing-container">
<van-loading v-if="loading" class="center-loading" vertical>加载列表中...</van-loading>
<van-empty
v-else-if="filteredListings.length === 0"
description="暂无发布的商品记录"
class="empty-state"
/>
<div v-else class="listings-list">
<div
v-for="item in filteredListings"
:key="item.id"
class="listing-card"
>
<div class="card-content">
<!-- 封面图 -->
<div class="cover-wrap">
<img v-if="item.resolvedCover" :src="item.resolvedCover" alt="" class="cover-img" />
<div v-else class="cover-placeholder">
<van-icon name="photo-o" :size="24" />
</div>
</div>
<!-- 右侧详情 -->
<div class="info-wrap">
<div class="title-row">
<span class="game-tag">三角洲行动</span>
<span class="status-badge" :class="getStatusBadgeClass(item)">
{{ getStatusText(item) }}
</span>
</div>
<h3 class="listing-title">{{ item.title }}</h3>
<p class="listing-meta">{{ item.server_region }} · {{ item.login_platform }}</p>
<div class="price-row">
<span class="price-val">¥{{ listingPrice(item) }}</span>
<span class="deposit-val">押金: ¥{{ Math.round(Number(item.deposit_amount || 0)) }}</span>
</div>
</div>
</div>
<!-- 驳回原因展示 -->
<div v-if="item.review_status === 'rejected' && item.review_reason" class="reason-alert">
<van-icon name="warning-o" />
<span>拒绝原因{{ item.review_reason }}</span>
</div>
<!-- 操作栏 -->
<div class="card-actions" @click.stop>
<div class="time-box">
<span>更新时间: {{ item.updated_at.split('T')[0] }}</span>
</div>
<div class="btns-box">
<van-button
v-if="item.status === 'published'"
size="small"
type="danger"
plain
round
class="action-btn"
:loading="item.updating"
@click="handleOffline(item)"
>
下架
</van-button>
<van-button
v-if="item.status === 'offline' || item.review_status === 'rejected'"
size="small"
type="primary"
round
class="action-btn"
:loading="item.updating"
@click="handleSubmitReview(item)"
>
重新上架
</van-button>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
.mobile-seller-listings {
min-height: 100dvh;
background: #f6f8fa;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.8);
}
.page-header h1 {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #111827;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #374151;
cursor: pointer;
}
.header-action-btn {
display: flex;
align-items: center;
gap: 2px;
border: none;
background: none;
color: #ff6a00;
font-size: 13px;
font-weight: 700;
cursor: pointer;
padding: 0 8px;
}
/* ========== Tab 栏样式 ========== */
.custom-tabs {
position: sticky;
top: 48px;
z-index: 99;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.6);
}
:deep(.van-tabs__nav) {
background: transparent;
padding-bottom: 4px;
}
:deep(.van-tab) {
font-size: 13px;
font-weight: 600;
}
/* ========== 列表内容 ========== */
.listing-container {
padding: 14px 16px;
}
.center-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 60px 0;
}
.empty-state {
padding: 40px 0;
}
.listings-list {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ========== 商品卡片 ========== */
.listing-card {
background: #ffffff;
border-radius: 16px;
padding: 14px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(243, 244, 246, 0.9);
display: flex;
flex-direction: column;
}
.card-content {
display: flex;
gap: 12px;
}
.cover-wrap {
position: relative;
width: 80px;
height: 80px;
border-radius: 12px;
overflow: hidden;
flex-shrink: 0;
background: #f3f4f6;
border: 1px solid #f3f4f6;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: #9ca3af;
}
.info-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.game-tag {
font-size: 10px;
color: #1477ff;
background: rgba(20, 119, 255, 0.08);
padding: 2px 6px;
border-radius: 6px;
font-weight: 700;
}
.status-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
font-weight: 700;
}
/* 状态颜色 */
.badge-reviewing { color: #f59e0b; background: rgba(245, 158, 11, 0.08); }
.badge-rejected { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
.badge-active { color: #10b981; background: rgba(16, 185, 129, 0.08); }
.badge-offline { color: #6b7280; background: rgba(107, 114, 128, 0.08); }
.badge-rented { color: #2563eb; background: rgba(37, 99, 235, 0.08); }
.listing-title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.listing-meta {
margin: 0 0 6px;
font-size: 11px;
color: #6b7280;
}
.price-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.price-val {
font-size: 16px;
font-weight: 800;
color: #ff5f00;
}
.price-val small {
font-size: 10px;
font-weight: 500;
}
.deposit-val {
font-size: 10px;
color: #9ca3af;
}
/* ========== 驳回警告栏 ========== */
.reason-alert {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #fef2f2;
border: 1px solid #fee2e2;
color: #dc2626;
font-size: 11px;
}
.reason-alert :deep(.van-icon) {
font-size: 13px;
}
/* ========== 操作栏 ========== */
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #f3f4f6;
}
.time-box {
font-size: 10px;
color: #9ca3af;
}
.btns-box {
display: flex;
gap: 8px;
}
.action-btn {
height: 26px !important;
padding: 0 12px !important;
font-size: 11px !important;
font-weight: 700 !important;
}
</style>
-262
View File
@@ -1,262 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElEmpty } from 'element-plus'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type HomeBannerSlide,
} from '@/api/homeConfig'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from '@/api/listingOptions'
import { useHomeFilters } from '@/composables/home/useHomeFilters'
import { useListingQuery } from '@/composables/home/useListingQuery'
import HomeAnnouncement from './components/HomeAnnouncement.vue'
import HomeBanner from './components/HomeBanner.vue'
import HomeStats from './components/HomeStats.vue'
import HomeFilters from './components/HomeFilters.vue'
import HomeZonesAndSort from './components/HomeZonesAndSort.vue'
import ListingCard from './components/ListingCard.vue'
const announcements = ref<string[]>(defaultHomeAnnouncements)
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortBy = ref('recommended')
const activeZone = ref('all')
const {
filters,
activeFilterPopover,
regionOptions,
loginMethodOptions,
skinFilterGroups,
skinChipLabel,
resetFilters,
setFilterPopover,
closeFilterPopover,
} = useHomeFilters(publishOptions, computed(() => listings.value))
const {
loading,
loadingMore,
listings,
totalListings,
zoneCounts,
hasMoreListings,
loadListingsPage,
zoneCount,
} = useListingQuery(filters, sortBy, activeZone)
const statCards = computed(() => [
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
{
label: '高哈夫币',
value: `${zoneCount('highCoin')}`,
hint: '100M 以上',
},
{
label: '账密登录',
value: `${zoneCount('password')}`,
hint: '交接更快',
},
])
const zoneOptions = computed(() => [
{
key: 'all',
label: '全部专区',
hint: '当前可租账号',
count: zoneCount('all'),
},
{
key: 'sale',
label: '特惠专区',
hint: '价格更划算',
count: zoneCount('sale'),
},
{
key: 'gift',
label: '赠送专区',
hint: '含赠送物品',
count: zoneCount('gift'),
},
{
key: 'night',
label: '夜间专区',
hint: '夜间也好上号',
count: zoneCount('night'),
},
{
key: 'password',
label: '账密专区',
hint: '交接更快',
count: zoneCount('password'),
},
{
key: 'highCoin',
label: '高币专区',
hint: '100M 以上',
count: zoneCount('highCoin'),
},
])
async function loadHome() {
loading.value = true
try {
const [, config] = await Promise.all([
loadListingsPage(true),
fetchMobileHomeConfig(),
])
announcements.value = config.announcements
banners.value = config.banners
publishOptions.value = config.publish_options
} catch {
announcements.value = defaultHomeAnnouncements
banners.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions
} finally {
loading.value = false
}
}
function updateFilters(partial: Partial<typeof filters>) {
Object.assign(filters, partial)
}
function handleResetFilters() {
resetFilters()
activeZone.value = 'all'
sortBy.value = 'recommended'
}
loadHome()
</script>
<template>
<section class="pc-home-redesign">
<HomeAnnouncement :announcements="announcements" />
<main class="home-content">
<div class="hero-section">
<HomeBanner :banners="banners" />
<HomeStats :stats="statCards" />
</div>
<HomeFilters
:filters="filters"
:total-listings="totalListings"
:publish-options="publishOptions"
:region-options="regionOptions"
:login-method-options="loginMethodOptions"
:skin-filter-groups="skinFilterGroups"
:skin-chip-label="skinChipLabel"
:active-filter-popover="activeFilterPopover"
@update:filters="updateFilters"
@reset="handleResetFilters"
@set-filter-popover="setFilterPopover"
@close-filter-popover="closeFilterPopover"
/>
<div class="list-head">
<div class="zone-head">
<p class="eyebrow">Account Zone</p>
<h2>账号专区</h2>
</div>
</div>
<HomeZonesAndSort
:zones="zoneOptions"
:active-zone="activeZone"
:sort-by="sortBy"
@update:active-zone="activeZone = $event"
@update:sort-by="sortBy = $event"
/>
<el-empty
v-if="!loading && listings.length === 0"
description="没有符合条件的账号"
/>
<div v-else v-loading="loading" class="enhanced-desktop-list">
<ListingCard
v-for="item in listings"
:key="item.id"
:listing="item"
/>
</div>
<div v-if="!loading && listings.length" class="infinite-load-state">
<span v-if="loadingMore">正在加载更多账号...</span>
<span v-else-if="!hasMoreListings">已经到底了</span>
</div>
</main>
</section>
</template>
<style scoped>
.pc-home-redesign {
display: grid;
gap: 20px;
width: 100%;
max-width: 1720px;
min-width: 0;
margin: 0 auto;
overflow-x: clip;
}
.home-content {
display: grid;
gap: 20px;
min-width: 0;
}
.infinite-load-state {
padding: 6px 0 18px;
color: #94a3b8;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.hero-section {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
min-width: 0;
}
.list-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.zone-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.eyebrow {
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #ff6a00;
}
.zone-head h2 {
margin: 0;
font-size: 28px;
font-weight: 900;
color: #17233d;
}
.enhanced-desktop-list {
display: grid;
gap: 16px;
min-width: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,749 +0,0 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { fetchListing, type Listing } from "@/api/listings";
import { createOrder } from "@/api/orders";
import { useSessionStore } from "@/stores/session";
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatRatio,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingChips,
getListingDisplayPrice,
getListingRentPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getOnlineTimeText,
getLoginMethod,
getServerRegion,
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const ordering = ref(false);
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";
return `${Math.round(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: Math.round(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: `¥${listing.value.deposit_amount}`, tone: "" },
];
});
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
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;
}
ordering.value = true;
try {
const order = await createOrder(listing.value.id);
ElMessage.success("订单已创建,请完成支付");
await router.push(`/orders/${order.id}`);
} catch (error) {
ElMessage.error(readError(error, "下单失败"));
} finally {
ordering.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
function listingPrice(item: Listing) {
return `${Math.round(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">
<img
v-if="coverURL"
:src="coverURL"
:alt="getListingTitle(listing)"
/>
<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>
<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">¥{{ 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">
<img :src="shot.url" :alt="shot.label" loading="lazy" decoding="async" />
<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>¥{{ orderPriceBreakdown.rent }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>¥{{ orderPriceBreakdown.consumable }}</strong>
</div>
</div>
<em>押金另付 ¥{{ listing.deposit_amount }}</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"
:disabled="listing.in_transaction"
class="full-control"
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
</el-button>
</el-form>
</aside>
</div>
</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-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;
}
@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;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,45 +0,0 @@
<script setup lang="ts">
import { Bell } from '@element-plus/icons-vue'
import { ElCarousel, ElCarouselItem, ElIcon } from 'element-plus'
interface Props {
announcements: string[]
}
defineProps<Props>()
</script>
<template>
<div class="home-announcement">
<el-icon><Bell /></el-icon>
<el-carousel
height="22px"
direction="vertical"
indicator-position="none"
:autoplay="true"
:interval="3200"
>
<el-carousel-item v-for="item in announcements" :key="item">
<span>{{ item }}</span>
</el-carousel-item>
</el-carousel>
</div>
</template>
<style scoped>
.home-announcement {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
color: #854d0e;
background: #fefce8;
border: 1px solid #fef08a;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(23, 35, 61, 0.03);
}
.home-announcement :deep(.el-carousel) {
flex: 1;
}
</style>
@@ -1,107 +0,0 @@
<script setup lang="ts">
import { ElCarousel, ElCarouselItem } from 'element-plus'
interface HomeBannerSlide {
title?: string
eyebrow?: string
pill?: string
badge?: string
tone?: string
image_url?: string
}
interface Props {
banners: HomeBannerSlide[]
}
defineProps<Props>()
</script>
<template>
<section class="hero-board">
<el-carousel height="240px" indicator-position="outside" :interval="3600">
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
<div
class="hero-slide"
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
>
<img
v-if="slide.image_url"
:src="slide.image_url"
:alt="slide.title || slide.eyebrow || '首页轮播'"
/>
<div class="hero-copy">
<span v-if="slide.eyebrow">{{ slide.eyebrow }}</span>
<h1 v-if="slide.title">{{ slide.title }}</h1>
<p v-if="slide.pill">{{ slide.pill }}</p>
</div>
<em v-if="slide.badge">{{ slide.badge }}</em>
</div>
</el-carousel-item>
</el-carousel>
</section>
</template>
<style scoped>
.hero-board {
min-width: 0;
border-radius: 16px;
overflow: hidden;
}
.hero-slide {
position: relative;
height: 100%;
display: flex;
align-items: center;
padding: 40px;
background: #f8fafc;
}
.hero-slide.tone-orange {
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
}
.hero-slide.tone-green {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
}
.hero-slide img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-slide.has-image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.6) 0%, transparent 60%);
}
.hero-copy {
position: relative;
z-index: 1;
max-width: 480px;
}
.hero-slide.has-image .hero-copy {
color: #ffffff;
}
.hero-copy h1 {
margin: 12px 0;
font-size: 32px;
line-height: 1.2;
}
.hero-copy p {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
font-size: 14px;
}
</style>
@@ -1,207 +0,0 @@
<script setup lang="ts">
import { Filter, Refresh, ArrowDown } from '@element-plus/icons-vue'
import { ElButton, ElIcon } from 'element-plus'
import RangeFilter from './RangeFilter.vue'
import StringFilter from './StringFilter.vue'
import SkinFilter from './SkinFilter.vue'
import {
coinRangeOptions,
moneyRangeOptions,
totalRangeOptions,
fireLevelRangeOptions,
} from '@/composables/home/useFilterOptions'
import type { ListingPublishOptions } from '@/api/listingOptions'
import type { FilterPopoverKey, HomeFilters } from '@/composables/home/useHomeFilters'
interface Props {
filters: HomeFilters
totalListings: number
publishOptions: ListingPublishOptions
regionOptions: string[]
loginMethodOptions: string[]
skinFilterGroups: Array<{ key: string; title: string; options: string[] }>
skinChipLabel: string
activeFilterPopover: FilterPopoverKey | ''
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:filters': [filters: Partial<HomeFilters>]
'reset': []
'setFilterPopover': [key: FilterPopoverKey, visible: boolean]
'closeFilterPopover': []
}>()
const filterPopoverBaseProps = {
placement: 'bottom-start',
popperClass: 'home-filter-popover',
trigger: 'click',
showAfter: 0,
hideAfter: 0,
transition: 'none',
} as const
function filterPopoverProps(key: FilterPopoverKey) {
return {
...filterPopoverBaseProps,
visible: props.activeFilterPopover === key,
'onUpdate:visible': (visible: boolean) => emit('setFilterPopover', key, visible),
}
}
function updateFilter(key: keyof HomeFilters, value: any) {
emit('update:filters', { [key]: value })
}
function closePopover() {
emit('closeFilterPopover')
}
</script>
<template>
<section class="horizontal-filter-card">
<div class="filter-header">
<div class="filter-title">
<el-icon><Filter /></el-icon>
<strong>筛选大厅</strong>
<span>{{ totalListings }} 个结果</span>
</div>
<el-button :icon="Refresh" link @click="emit('reset')">
重置全部条件
</el-button>
</div>
<div class="filter-chip-row">
<StringFilter
label="保险"
placeholder="保险"
:model-value="filters.insurance"
:options="publishOptions.insurance_options"
:popover-props="filterPopoverProps('insurance')"
@update:model-value="updateFilter('insurance', $event)"
@close="closePopover"
/>
<StringFilter
label="体力"
placeholder="体力"
:model-value="filters.stamina"
:options="publishOptions.level_options"
:popover-props="filterPopoverProps('stamina')"
@update:model-value="updateFilter('stamina', $event)"
@close="closePopover"
/>
<StringFilter
label="负载"
placeholder="负载"
:model-value="filters.load"
:options="publishOptions.level_options"
:popover-props="filterPopoverProps('load')"
@update:model-value="updateFilter('load', $event)"
@close="closePopover"
/>
<StringFilter
label="登录方式"
placeholder="登录方式"
:model-value="filters.loginMethod"
:options="loginMethodOptions"
:popover-props="filterPopoverProps('loginMethod')"
@update:model-value="updateFilter('loginMethod', $event)"
@close="closePopover"
/>
<StringFilter
label="地区"
placeholder="地区选择"
:model-value="filters.region"
:options="regionOptions"
:popover-props="filterPopoverProps('region')"
:wide="true"
@update:model-value="updateFilter('region', $event)"
@close="closePopover"
/>
<RangeFilter
label="哈夫币(M)"
:model-min="filters.minCoin"
:model-max="filters.maxCoin"
:options="coinRangeOptions"
:popover-props="filterPopoverProps('coin')"
:wide="true"
@update:model-min="updateFilter('minCoin', $event)"
@update:model-max="updateFilter('maxCoin', $event)"
@close="closePopover"
/>
<RangeFilter
label="租金"
:model-min="filters.minPrice"
:model-max="filters.maxPrice"
:options="moneyRangeOptions"
:popover-props="filterPopoverProps('price')"
@update:model-min="updateFilter('minPrice', $event)"
@update:model-max="updateFilter('maxPrice', $event)"
@close="closePopover"
/>
<RangeFilter
label="押金"
:model-min="filters.minDeposit"
:model-max="filters.maxDeposit"
:options="moneyRangeOptions"
:popover-props="filterPopoverProps('deposit')"
@update:model-min="updateFilter('minDeposit', $event)"
@update:model-max="updateFilter('maxDeposit', $event)"
@close="closePopover"
/>
<RangeFilter
label="合计金额"
:model-min="filters.minTotal"
:model-max="filters.maxTotal"
:options="totalRangeOptions"
:popover-props="filterPopoverProps('total')"
:wide="true"
@update:model-min="updateFilter('minTotal', $event)"
@update:model-max="updateFilter('maxTotal', $event)"
@close="closePopover"
/>
<SkinFilter
:skin-group="filters.skinGroup"
:skin-name="filters.skinName"
:groups="skinFilterGroups"
:popover-props="filterPopoverProps('skin')"
@update:skin-group="updateFilter('skinGroup', $event)"
@update:skin-name="updateFilter('skinName', $event)"
@close="closePopover"
/>
<StringFilter
label="段位"
placeholder="段位"
:model-value="filters.rank"
:options="publishOptions.rank_options"
:popover-props="filterPopoverProps('rank')"
@update:model-value="updateFilter('rank', $event)"
@close="closePopover"
/>
<RangeFilter
label="等级"
:model-min="filters.minFireLevel"
:model-max="filters.maxFireLevel"
:options="fireLevelRangeOptions"
:popover-props="filterPopoverProps('fireLevel')"
@update:model-min="updateFilter('minFireLevel', $event)"
@update:model-max="updateFilter('maxFireLevel', $event)"
@close="closePopover"
/>
</div>
</section>
</template>
<style src="@/styles/home-filters.css"></style>
@@ -1,66 +0,0 @@
<script setup lang="ts">
interface StatCard {
label: string
value: string
hint: string
}
interface Props {
stats: StatCard[]
}
defineProps<Props>()
</script>
<template>
<div class="home-stats-v2">
<div v-for="item in stats" :key="item.label" class="stat-item">
<div class="stat-main">
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
<small>{{ item.hint }}</small>
</div>
</div>
</template>
<style scoped>
.home-stats-v2 {
display: grid;
grid-template-rows: repeat(3, 1fr);
gap: 12px;
min-width: 0;
}
.stat-item {
display: flex;
flex-direction: column;
justify-content: center;
padding: 16px;
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
}
.stat-main {
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-item strong {
font-size: 24px;
color: #ff6a00;
}
.stat-item span {
font-weight: 700;
color: #17233d;
}
.stat-item small {
margin-top: 4px;
color: #7b8798;
font-size: 12px;
}
</style>
@@ -1,122 +0,0 @@
<script setup lang="ts">
import { ElRadioGroup, ElRadioButton } from 'element-plus'
interface ZoneOption {
key: string
label: string
hint: string
count: number
}
interface Props {
zones: ZoneOption[]
activeZone: string
sortBy: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:activeZone': [value: string]
'update:sortBy': [value: string]
}>()
function selectZone(key: string) {
emit('update:activeZone', key)
}
</script>
<template>
<div class="zones-and-sort">
<div class="zone-tabs">
<button
v-for="zone in zones"
:key="zone.key"
type="button"
class="zone-tab"
:class="{ active: activeZone === zone.key }"
@click="selectZone(zone.key)"
>
<div class="zone-main">
<strong>{{ zone.label }}</strong>
<span class="zone-count">{{ zone.count }}</span>
</div>
<small>{{ zone.hint }}</small>
</button>
</div>
<div class="list-actions">
<el-radio-group :model-value="sortBy" size="small" @update:model-value="(val) => emit('update:sortBy', val as string)">
<el-radio-button label="recommended">综合推荐</el-radio-button>
<el-radio-button label="coinDesc">哈夫币</el-radio-button>
<el-radio-button label="awmDesc">AWM数量</el-radio-button>
<el-radio-button label="priceAsc">价格最低</el-radio-button>
<el-radio-button label="priceDesc">价格最高</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<style scoped>
.zones-and-sort {
display: flex;
flex-direction: column;
gap: 16px;
}
.zone-tabs {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
.zone-tab {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 2px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
cursor: pointer;
transition: all 0.2s;
}
.zone-tab:hover {
border-color: #ff6a00;
background: #fff7ed;
}
.zone-tab.active {
border-color: #ff6a00;
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
}
.zone-main {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.zone-tab strong {
font-size: 15px;
font-weight: 700;
color: #17233d;
}
.zone-count {
font-size: 16px;
font-weight: 900;
color: #ff6a00;
}
.zone-tab small {
font-size: 11px;
color: #7b8798;
}
.list-actions {
display: flex;
justify-content: flex-end;
}
</style>

Some files were not shown because too many files have changed in this diff Show More