chore: 删除旧的 api/, views/, composables/ 目录
所有文件已迁移到 features/ 架构: - 删除 111 个旧文件 - api/ → features/*/api/ - views/ → features/*/views/ - composables/ → features/*/composables/ 清理完成,架构迁移进入最后阶段。
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user