增加前端格式检查配置

This commit is contained in:
yml2213
2026-06-08 06:40:33 +08:00
parent 79ea3a476c
commit 500f511185
150 changed files with 7254 additions and 4013 deletions
+6
View File
@@ -0,0 +1,6 @@
dist
node_modules
coverage
auto-imports.d.ts
components.d.ts
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 100,
"trailingComma": "es5",
"arrowParens": "avoid",
"vueIndentScriptAndStyle": false
}
+50
View File
@@ -0,0 +1,50 @@
import js from '@eslint/js'
import prettier from 'eslint-config-prettier'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import vue from 'eslint-plugin-vue'
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'auto-imports.d.ts', 'components.d.ts', 'coverage/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
...vue.configs['flat/recommended'],
prettier,
{
files: ['**/*.{ts,tsx,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2024,
},
parserOptions: {
parser: tseslint.parser,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'vue/attributes-order': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
},
},
{
files: ['vite.config.ts'],
languageOptions: {
globals: globals.node,
},
}
)
+1282
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -7,7 +7,11 @@
"dev": "vue-tsc -b --noEmit --watch & vite --host 0.0.0.0",
"build": "vue-tsc -b --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "vue-tsc -b --noEmit"
"typecheck": "vue-tsc -b --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -20,12 +24,19 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@vant/auto-import-resolver": "^1.3.0",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~5.9.3",
"typescript-eslint": "^8.60.1",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vant": "^4.9.24",
+10 -10
View File
@@ -1,19 +1,19 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import { computed } from "vue";
import { useRoute } from "vue-router";
import { RouterView } from 'vue-router'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AdminLayout from "./layouts/AdminLayout.vue";
import AppLayout from "./layouts/AppLayout.vue";
import AdminLayout from './layouts/AdminLayout.vue'
import AppLayout from './layouts/AppLayout.vue'
const route = useRoute();
const route = useRoute()
const layout = computed(() => {
if (route.path === "/m" || route.path.startsWith("/m/")) {
return "blank";
if (route.path === '/m' || route.path.startsWith('/m/')) {
return 'blank'
}
return route.meta.layout || "app";
});
return route.meta.layout || 'app'
})
</script>
<template>
@@ -34,9 +34,8 @@ async function loadImage() {
}
try {
const key = extractObjectKey(props.source)
const blob = props.admin && key
? await fetchAdminFileBlob(key)
: await fetchFileBlobByURL(props.source)
const blob =
props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(props.source)
objectURL.value = URL.createObjectURL(blob)
} catch {
failed.value = true
@@ -55,7 +54,7 @@ onBeforeUnmount(revokeCurrentURL)
<template>
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async">
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async" />
</button>
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</template>
+4 -20
View File
@@ -11,19 +11,11 @@ function isNavActive(path: string) {
<template>
<nav class="bottom-nav">
<RouterLink
to="/m"
class="nav-item"
:class="{ active: isNavActive('/m') }"
>
<RouterLink to="/m" class="nav-item" :class="{ active: isNavActive('/m') }">
<van-icon name="home-o" :size="22" />
<span>首页</span>
</RouterLink>
<RouterLink
to="/m/messages"
class="nav-item"
:class="{ active: isNavActive('/m/messages') }"
>
<RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
<van-icon name="chat-o" :size="22" />
<span>消息</span>
</RouterLink>
@@ -31,19 +23,11 @@ function isNavActive(path: string) {
<div class="publish-pill">+</div>
<span>发布</span>
</RouterLink>
<RouterLink
to="/m/orders"
class="nav-item"
:class="{ active: isNavActive('/m/orders') }"
>
<RouterLink to="/m/orders" class="nav-item" :class="{ active: isNavActive('/m/orders') }">
<van-icon name="orders-o" :size="22" />
<span>订单</span>
</RouterLink>
<RouterLink
to="/m/profile"
class="nav-item"
:class="{ active: isNavActive('/m/profile') }"
>
<RouterLink to="/m/profile" class="nav-item" :class="{ active: isNavActive('/m/profile') }">
<van-icon name="manager-o" :size="22" />
<span>我的</span>
</RouterLink>
@@ -26,7 +26,10 @@ export async function fetchAdminAnnouncements(params: {
page?: number
page_size?: number
}): Promise<PaginatedResult<Announcement>> {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>('/admin/announcements', { params })
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>(
'/admin/announcements',
{ params }
)
return data.data
}
@@ -40,7 +43,10 @@ export async function createAnnouncement(req: CreateAnnouncementRequest): Promis
return data.data
}
export async function updateAnnouncement(id: number, req: UpdateAnnouncementRequest): Promise<Announcement> {
export async function updateAnnouncement(
id: number,
req: UpdateAnnouncementRequest
): Promise<Announcement> {
const { data } = await apiClient.put<ApiResponse<Announcement>>(`/admin/announcements/${id}`, req)
return data.data
}
@@ -26,7 +26,12 @@ export interface AdminAuditQuery {
}
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 })
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
}
+16 -4
View File
@@ -45,7 +45,12 @@ export async function fetchAdminCaptcha() {
return data.data
}
export async function loginAdmin(username: string, password: string, captchaId: string, captchaCode: string) {
export async function loginAdmin(
username: string,
password: string,
captchaId: string,
captchaCode: string
) {
const { data } = await apiClient.post<ApiResponse<AdminLoginData>>('/admin/auth/login', {
username,
password,
@@ -66,9 +71,12 @@ export async function logoutAdmin() {
}
export async function updateSupportStatus(status: 'online' | 'offline' | 'busy') {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean; support_status: string }>>('/admin/me/support-status', {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean; support_status: string }>>(
'/admin/me/support-status',
{
status,
})
}
)
return data.data
}
@@ -76,7 +84,11 @@ export async function updateSupportStatus(status: 'online' | 'offline' | 'busy')
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 })
const { data } = await axios.post<ApiResponse<AdminTokenPair>>(
'/api/admin/auth/refresh',
{ refresh_token: refreshToken },
{ timeout: 10000 }
)
setAuthTokens('admin', data.data)
return data.data
}
+17 -6
View File
@@ -37,9 +37,12 @@ export interface ChangePasswordRequest {
}
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>('/admin/admin-users', {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>(
'/admin/admin-users',
{
params: { page, page_size: pageSize },
})
}
)
return data.data
}
@@ -59,18 +62,26 @@ export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) {
}
export async function deleteAdminMgrUser(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/admin-users/${id}`)
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`, {
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)
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
`/admin/admin-users/${id}/password`,
req
)
return data.data
}
@@ -58,9 +58,12 @@ export async function deleteRole(id: number) {
}
export async function assignRolePermissions(roleId: number, permissionIds: number[]) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/roles/${roleId}/permissions`, {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
`/admin/roles/${roleId}/permissions`,
{
permission_ids: permissionIds,
})
}
)
return data.data
}
@@ -20,14 +20,19 @@ export interface AdminUserItem {
}
export async function fetchAdminUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
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 })
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/freeze`, {
reason,
})
return data.data
}
@@ -30,8 +30,13 @@ export interface AdminWalletLedgerQuery {
}
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 })
const params = Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>(
'/admin/wallet/ledger',
{ params }
)
const result = data.data
return {
items: Array.isArray(result?.items) ? result.items : [],
@@ -53,9 +53,12 @@ export async function fetchAdminWithdrawals(params: {
page?: number
page_size?: number
}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalDetail>>>('/admin/withdrawals', {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalDetail>>>(
'/admin/withdrawals',
{
params,
})
}
)
return data.data
}
@@ -65,11 +68,17 @@ export async function fetchAdminWithdrawal(id: number) {
}
export async function reviewWithdrawal(id: number, req: ReviewWithdrawalRequest) {
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(`/admin/withdrawals/${id}/review`, req)
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(
`/admin/withdrawals/${id}/review`,
req
)
return data.data
}
export async function confirmPayment(id: number, req: ConfirmPaymentRequest) {
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(`/admin/withdrawals/${id}/confirm-payment`, req)
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(
`/admin/withdrawals/${id}/confirm-payment`,
req
)
return data.data
}
@@ -85,9 +85,12 @@ export async function fetchPaymentConfigs(params?: {
page?: number
page_size?: number
}) {
const { data } = await apiClient.get<ApiResponse<PaymentConfigListResponse>>('/admin/payment-configs', {
const { data } = await apiClient.get<ApiResponse<PaymentConfigListResponse>>(
'/admin/payment-configs',
{
params,
})
}
)
return data.data
}
@@ -104,27 +107,39 @@ export async function exportPaymentConfigBackup() {
})
return {
blob: response.data,
filename: readDownloadFilename(response.headers['content-disposition']) || fallbackBackupFilename(),
filename:
readDownloadFilename(response.headers['content-disposition']) || fallbackBackupFilename(),
}
}
export async function importPaymentConfigBackup(payload: unknown) {
const { data } = await apiClient.post<ApiResponse<PaymentConfigImportResult>>('/admin/payment-configs/import', payload)
const { data } = await apiClient.post<ApiResponse<PaymentConfigImportResult>>(
'/admin/payment-configs/import',
payload
)
return data.data
}
export async function createPaymentConfig(payload: CreatePaymentConfigRequest) {
const { data } = await apiClient.post<ApiResponse<PaymentConfig>>('/admin/payment-configs', payload)
const { data } = await apiClient.post<ApiResponse<PaymentConfig>>(
'/admin/payment-configs',
payload
)
return data.data
}
export async function updatePaymentConfig(id: number, payload: UpdatePaymentConfigRequest) {
const { data } = await apiClient.put<ApiResponse<PaymentConfig>>(`/admin/payment-configs/${id}`, payload)
const { data } = await apiClient.put<ApiResponse<PaymentConfig>>(
`/admin/payment-configs/${id}`,
payload
)
return data.data
}
export async function deletePaymentConfig(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ message: string }>>(`/admin/payment-configs/${id}`)
const { data } = await apiClient.delete<ApiResponse<{ message: string }>>(
`/admin/payment-configs/${id}`
)
return data.data
}
@@ -136,6 +151,9 @@ function readDownloadFilename(contentDisposition: unknown) {
}
function fallbackBackupFilename() {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '')
const stamp = new Date()
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, '')
return `payment-config-backup-${stamp}.json`
}
@@ -12,11 +12,18 @@ export interface SystemConfig {
}
export async function fetchSystemConfigs() {
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
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)
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
}
@@ -60,8 +60,12 @@ function nextPage() {
<el-option :value="100" label="100条" />
</el-select>
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span>
<el-button size="small" :disabled="currentPage <= 1 || loading" @click="prevPage">上页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages || loading" @click="nextPage">下页</el-button>
<el-button size="small" :disabled="currentPage <= 1 || loading" @click="prevPage"
>上页</el-button
>
<el-button size="small" :disabled="currentPage >= totalPages || loading" @click="nextPage"
>下页</el-button
>
</div>
</div>
</template>
@@ -2,7 +2,13 @@
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { createAdminMgrUser, updateAdminMgrUser, type AdminMgrUser, type CreateAdminRequest, type UpdateAdminRequest } from '@/features/admin/api/adminMgr'
import {
createAdminMgrUser,
updateAdminMgrUser,
type AdminMgrUser,
type CreateAdminRequest,
type UpdateAdminRequest,
} from '@/features/admin/api/adminMgr'
const props = defineProps<{
modelValue: boolean
@@ -26,7 +32,7 @@ const isEdit = ref(false)
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
if (props.admin) {
isEdit.value = true
@@ -42,7 +48,7 @@ watch(
}
}
},
{ immediate: true },
{ immediate: true }
)
async function handleSave() {
@@ -94,7 +100,12 @@ function readError(error: unknown, fallback: string) {
<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-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="请输入昵称" />
@@ -3,7 +3,10 @@ import { computed, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue'
import type { Announcement } from '@/features/announcement'
import type { CreateAnnouncementRequest, UpdateAnnouncementRequest } from '@/features/admin/api/adminAnnouncements'
import type {
CreateAnnouncementRequest,
UpdateAnnouncementRequest,
} from '@/features/admin/api/adminAnnouncements'
interface Props {
visible: boolean
@@ -39,19 +42,17 @@ const rules: FormRules = {
{ required: true, message: '请输入公告标题', trigger: 'blur' },
{ max: 255, message: '标题不能超过255个字符', trigger: 'blur' },
],
content: [
{ required: true, message: '请输入公告内容', trigger: 'blur' },
],
category: [
{ required: true, message: '请选择公告分类', trigger: 'change' },
],
content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
category: [{ required: true, message: '请选择公告分类', trigger: 'change' }],
}
const dialogTitle = computed(() => {
return props.mode === 'create' ? '新建公告' : '编辑公告'
})
watch(() => props.visible, (val) => {
watch(
() => props.visible,
val => {
if (val) {
if (props.mode === 'edit' && props.announcement) {
formData.value = {
@@ -74,7 +75,8 @@ watch(() => props.visible, (val) => {
}
formRef.value?.clearValidate()
}
})
}
)
function handleClose() {
emit('update:visible', false)
@@ -93,18 +95,8 @@ async function handleSave() {
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="800px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-dialog :model-value="visible" :title="dialogTitle" width="800px" @close="handleClose">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="公告标题" prop="title">
<el-input
v-model="formData.title"
@@ -144,7 +136,9 @@ async function handleSave() {
:max="999"
placeholder="数值越大越靠前"
/>
<span style="margin-left: 12px; color: #909399; font-size: 13px">数值越大越靠前默认为0</span>
<span style="margin-left: 12px; color: #909399; font-size: 13px"
>数值越大越靠前默认为0</span
>
</el-form-item>
<el-form-item label="标记">
@@ -2,7 +2,13 @@
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { fetchPermissions, fetchRole, assignRolePermissions, type Permission, type Role } from '@/features/admin/api/adminRoles'
import {
fetchPermissions,
fetchRole,
assignRolePermissions,
type Permission,
type Role,
} from '@/features/admin/api/adminRoles'
const props = defineProps<{
modelValue: boolean
@@ -24,13 +30,16 @@ const groupedPermissions = ref<Record<string, Permission[]>>({})
watch(
() => props.modelValue,
async (val) => {
async val => {
if (val && props.role) {
loading.value = true
try {
const [perms, roleDetail] = await Promise.all([fetchPermissions(), fetchRole(props.role.id)])
const [perms, roleDetail] = await Promise.all([
fetchPermissions(),
fetchRole(props.role.id),
])
allPermissions.value = perms
selectedPermIds.value = (roleDetail.permissions || []).map((p) => p.id)
selectedPermIds.value = (roleDetail.permissions || []).map(p => p.id)
// 按 resource 分组
const grouped: Record<string, Permission[]> = {}
@@ -45,7 +54,7 @@ watch(
loading.value = false
}
}
},
}
)
async function handleSave() {
@@ -64,10 +73,10 @@ async function handleSave() {
}
function toggleGroup(perms: Permission[]) {
const ids = perms.map((p) => p.id)
const allSelected = ids.every((id) => selectedPermIds.value.includes(id))
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))
selectedPermIds.value = selectedPermIds.value.filter(id => !ids.includes(id))
} else {
const newIds = [...selectedPermIds.value]
for (const id of ids) {
@@ -78,11 +87,11 @@ function toggleGroup(perms: Permission[]) {
}
function isGroupAllSelected(perms: Permission[]) {
return perms.length > 0 && perms.every((p) => selectedPermIds.value.includes(p.id))
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)
return perms.some(p => selectedPermIds.value.includes(p.id)) && !isGroupAllSelected(perms)
}
function readError(error: unknown, fallback: string) {
@@ -22,19 +22,19 @@ const selectedRoleIds = ref<number[]>([])
watch(
() => props.modelValue,
async (val) => {
async val => {
if (val && props.admin) {
loading.value = true
try {
allRoles.value = await fetchRoles()
selectedRoleIds.value = props.admin.roles.map((r) => r.id)
selectedRoleIds.value = props.admin.roles.map(r => r.id)
} catch {
ElMessage.error('加载角色列表失败')
} finally {
loading.value = false
}
}
},
}
)
async function handleSave() {
@@ -21,7 +21,7 @@ const description = ref('')
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
value.value = props.config.value || ''
description.value = props.config.description || ''
@@ -38,7 +38,7 @@ const isStructuredConfig = computed(() => {
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)) {
if (!value.value || options.some(item => item.value === value.value)) {
return options
}
return [{ label: `当前值:${value.value}`, value: value.value }, ...options]
@@ -78,7 +78,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<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" />
@@ -22,7 +22,7 @@ const homeAnnouncementLines = ref('')
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(props.config.value))
description.value = props.config.description || ''
@@ -39,7 +39,7 @@ function parseHomeAnnouncements(raw: string) {
function linesToItems(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.map(item => item.trim())
.filter(Boolean)
}
@@ -86,7 +86,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<div class="home-config-editor">
<div class="editor-toolbar">
@@ -3,7 +3,11 @@ import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { uploadAdminFile } from '@/shared/api/files'
import { defaultHomeBanners, mergeHomeConfig, type HomeBannerSlide } from '@/features/listings/api/homeConfig'
import {
defaultHomeBanners,
mergeHomeConfig,
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
import { safeParseJSON } from '@/utils/json'
@@ -24,7 +28,7 @@ const uploadingBannerIndex = ref<number | null>(null)
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
homeBannersDraft.value = parseHomeBanners(props.config.value)
description.value = props.config.description || ''
@@ -88,7 +92,7 @@ async function handleSave() {
submitting.value = true
try {
const value = JSON.stringify(
homeBannersDraft.value.filter((item) => item.title.trim() || item.image_url?.trim()),
homeBannersDraft.value.filter(item => item.title.trim() || item.image_url?.trim()),
null,
2
)
@@ -123,7 +127,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<div class="home-config-editor">
<div class="editor-toolbar">
@@ -154,16 +160,24 @@ function readError(error: unknown, fallback: string) {
</template>
</el-table-column>
<el-table-column label="眉标" min-width="160">
<template #default="{ row }"><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区" /></template>
<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>
<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>
<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>
<template #default="{ row }"
><el-input v-model="row.pill" placeholder="底部补充文案"
/></template>
</el-table-column>
<el-table-column label="色调" width="130">
<template #default="{ row }">
@@ -176,7 +190,9 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)">删除</el-button>
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -63,25 +63,37 @@ const draft = ref<ListingPublishAgreements>(cloneAgreements(defaultListingPublis
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
draft.value = parseListingPublishAgreements(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true },
{ immediate: true }
)
function parseListingPublishAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
return cloneAgreements({
virtual_asset_sale: {
title: readText(parsed?.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title),
content: readText(parsed?.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content),
title: readText(
parsed?.virtual_asset_sale?.title,
defaultListingPublishAgreements.virtual_asset_sale.title
),
content: readText(
parsed?.virtual_asset_sale?.content,
defaultListingPublishAgreements.virtual_asset_sale.content
),
},
seller_agreement: {
title: readText(parsed?.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title),
content: readText(parsed?.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content),
title: readText(
parsed?.seller_agreement?.title,
defaultListingPublishAgreements.seller_agreement.title
),
content: readText(
parsed?.seller_agreement?.content,
defaultListingPublishAgreements.seller_agreement.content
),
},
})
}
@@ -133,7 +145,9 @@ function readError(error: unknown, fallback: string) {
>
<div class="dialog-body">
<div class="dialog-header">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div>
@@ -63,25 +63,37 @@ const draft = ref<OrderAgreements>(cloneAgreements(defaultOrderAgreements))
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
draft.value = parseOrderAgreements(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true },
{ immediate: true }
)
function parseOrderAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultOrderAgreements)
return cloneAgreements({
virtual_asset_purchase: {
title: readText(parsed?.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title),
content: readText(parsed?.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content),
title: readText(
parsed?.virtual_asset_purchase?.title,
defaultOrderAgreements.virtual_asset_purchase.title
),
content: readText(
parsed?.virtual_asset_purchase?.content,
defaultOrderAgreements.virtual_asset_purchase.content
),
},
renter_agreement: {
title: readText(parsed?.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title),
content: readText(parsed?.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content),
title: readText(
parsed?.renter_agreement?.title,
defaultOrderAgreements.renter_agreement.title
),
content: readText(
parsed?.renter_agreement?.content,
defaultOrderAgreements.renter_agreement.content
),
},
})
}
@@ -134,7 +146,9 @@ function readError(error: unknown, fallback: string) {
>
<div class="dialog-body">
<div class="dialog-header">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div>
@@ -86,12 +86,7 @@ const isLakala = computed(() => formData.value.provider === 'lakala')
const isMock = computed(() => formData.value.provider === 'mock')
const signKeyLabel = computed(() => (isLakala.value ? '商户私钥' : '签名密钥'))
const notifyKeyLabel = computed(() => (isLakala.value ? '通知证书' : '通知密钥'))
const extraConfig = computed<Record<string, any>>(() => {
if (!formData.value.extra_config) {
formData.value.extra_config = {}
}
return formData.value.extra_config
})
const extraConfig = computed<Record<string, any>>(() => formData.value.extra_config || {})
function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any> | null) {
const extra = {
@@ -106,7 +101,7 @@ function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any
watch(
() => props.modelValue,
(val) => {
val => {
if (val && props.config) {
formData.value = {
name: props.config.name,
@@ -218,7 +213,11 @@ function handleClose() {
</el-form-item>
<el-form-item label="支付服务商" required>
<el-select v-model="formData.provider" :disabled="mode !== 'create'" @change="handleProviderChange">
<el-select
v-model="formData.provider"
:disabled="mode !== 'create'"
@change="handleProviderChange"
>
<el-option label="乐刷支付" value="leshua" />
<el-option label="拉卡拉支付" value="lakala" />
<el-option label="模拟支付" value="mock" />
@@ -234,11 +233,19 @@ function handleClose() {
</el-form-item>
<el-form-item v-if="isLakala" label="App ID" required>
<el-input v-model="extraConfig.app_id" placeholder="拉卡拉开放平台 appId" :disabled="isReadonly" />
<el-input
v-model="extraConfig.app_id"
placeholder="拉卡拉开放平台 appId"
:disabled="isReadonly"
/>
</el-form-item>
<el-form-item v-if="isLakala" label="证书序列号" required>
<el-input v-model="extraConfig.serial_no" placeholder="商户证书序列号" :disabled="isReadonly" />
<el-input
v-model="extraConfig.serial_no"
placeholder="商户证书序列号"
:disabled="isReadonly"
/>
</el-form-item>
<el-form-item v-if="isLakala" label="终端号" required>
@@ -251,7 +258,9 @@ function handleClose() {
:type="isLakala ? 'textarea' : 'password'"
:rows="isLakala ? 5 : undefined"
:show-password="!isLakala"
:placeholder="mode === 'edit' ? '留空则不修改' : isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥'"
:placeholder="
mode === 'edit' ? '留空则不修改' : isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥'
"
:disabled="isReadonly"
/>
</el-form-item>
@@ -262,7 +271,13 @@ function handleClose() {
:type="isLakala ? 'textarea' : 'password'"
:rows="isLakala ? 5 : undefined"
:show-password="!isLakala"
:placeholder="mode === 'edit' ? '留空则不修改' : isLakala ? '请输入拉卡拉通知验签证书 PEM' : '请输入通知密钥'"
:placeholder="
mode === 'edit'
? '留空则不修改'
: isLakala
? '请输入拉卡拉通知验签证书 PEM'
: '请输入通知密钥'
"
:disabled="isReadonly"
/>
</el-form-item>
@@ -282,7 +297,11 @@ function handleClose() {
</el-form-item>
<el-form-item v-if="!isMock" label="回调地址" required>
<el-input v-model="formData.notify_url" placeholder="异步通知回调地址" :disabled="isReadonly" />
<el-input
v-model="formData.notify_url"
placeholder="异步通知回调地址"
:disabled="isReadonly"
/>
</el-form-item>
<el-form-item label="是否默认">
@@ -312,7 +331,11 @@ function handleClose() {
</template>
<el-form-item label="跳转地址">
<el-input v-model="formData.jump_url" placeholder="支付完成跳转地址" :disabled="isReadonly" />
<el-input
v-model="formData.jump_url"
placeholder="支付完成跳转地址"
:disabled="isReadonly"
/>
</el-form-item>
<el-form-item label="支付方式">
@@ -332,7 +355,12 @@ function handleClose() {
</el-form-item>
<el-form-item v-if="isLakala" label="有效期分钟">
<el-input-number v-model="extraConfig.order_expire_minutes" :min="1" :max="1440" :disabled="isReadonly" />
<el-input-number
v-model="extraConfig.order_expire_minutes"
:min="1"
:max="1440"
:disabled="isReadonly"
/>
</el-form-item>
<el-form-item label="支付形态">
@@ -63,13 +63,13 @@ const draft = ref<PostRentalNotice>(cloneNotice(defaultPostRentalNotice))
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
draft.value = parsePostRentalNotice(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true },
{ immediate: true }
)
function parsePostRentalNotice(raw: string) {
@@ -128,7 +128,9 @@ function readError(error: unknown, fallback: string) {
>
<div class="dialog-body">
<div class="dialog-header">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div>
@@ -26,7 +26,7 @@ const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListing
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
publishOptionsDraft.value = parsePublishOptions(props.config.value)
description.value = props.config.description || ''
@@ -47,7 +47,7 @@ function cloneOptions(options: ListingPublishOptions) {
function linesToItems(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.map(item => item.trim())
.filter(Boolean)
}
@@ -200,7 +200,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<div class="publish-options-editor">
<div class="editor-toolbar">
@@ -300,7 +302,12 @@ function readError(error: unknown, fallback: string) {
<strong>发布规则</strong>
</div>
<el-form-item label="最低烽火等级">
<el-input-number v-model="publishOptionsDraft.fire_level_min" :min="1" :step="1" class="full-control" />
<el-input-number
v-model="publishOptionsDraft.fire_level_min"
:min="1"
:step="1"
class="full-control"
/>
</el-form-item>
</div>
@@ -309,7 +316,11 @@ function readError(error: unknown, fallback: string) {
<strong>押金与价格提示</strong>
</div>
<el-form-item label="押金提示">
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
<el-input
v-model="publishOptionsDraft.price_config.deposit_placeholder"
type="textarea"
:rows="3"
/>
</el-form-item>
<el-form-item label="押金后缀">
<el-input
@@ -319,10 +330,16 @@ function readError(error: unknown, fallback: string) {
/>
</el-form-item>
<el-form-item label="价格提示">
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" class="full-control" />
<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-input
v-model="publishOptionsDraft.price_config.ratio_description"
class="full-control"
/>
</el-form-item>
</div>
@@ -339,19 +356,35 @@ function readError(error: unknown, fallback: string) {
class="full-control"
/>
</el-form-item>
<el-table :data="publishOptionsDraft.deposit_recommend_config.skin_group_rules" size="small" border>
<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>
<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>
<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>
<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>
<el-button
size="small"
type="danger"
plain
@click="removeDepositSkinGroupRule($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -365,16 +398,30 @@ function readError(error: unknown, fallback: string) {
<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
: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>
<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>
<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>
<el-button
size="small"
type="danger"
plain
@click="removeInsuranceBaseRatio($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -403,11 +450,15 @@ function readError(error: unknown, fallback: string) {
<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>
<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>
<el-button size="small" type="danger" plain @click="removeRatioConfigItem($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -418,14 +469,20 @@ function readError(error: unknown, fallback: string) {
</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>
<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>
<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>
<el-button size="small" type="danger" plain @click="removeCoinCorrection($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -436,7 +493,11 @@ function readError(error: unknown, fallback: string) {
<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">
<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
@@ -470,7 +531,9 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)">删除</el-button>
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -496,7 +559,9 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)">删除</el-button>
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -29,14 +29,17 @@ const form = ref({
is_global: false,
})
watch(() => props.modelValue, (val) => {
watch(
() => props.modelValue,
val => {
visible.value = val
if (val) {
loadReplies()
}
})
}
)
watch(visible, (val) => {
watch(visible, val => {
emit('update:modelValue', val)
})
@@ -76,7 +79,12 @@ async function handleSubmit() {
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)
await createQuickReply(
form.value.title,
form.value.content,
form.value.sort_order,
form.value.is_global
)
ElMessage.success('创建成功')
}
resetForm()
@@ -100,7 +108,9 @@ async function handleDelete(reply: QuickReply) {
ElMessage.success('删除成功')
await loadReplies()
emit('success')
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
function handleCancel() {
@@ -2,7 +2,13 @@
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { createRole, updateRole, type Role, type CreateRoleRequest, type UpdateRoleRequest } from '@/features/admin/api/adminRoles'
import {
createRole,
updateRole,
type Role,
type CreateRoleRequest,
type UpdateRoleRequest,
} from '@/features/admin/api/adminRoles'
const props = defineProps<{
modelValue: boolean
@@ -25,7 +31,7 @@ const isEdit = ref(false)
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
if (props.role) {
isEdit.value = true
@@ -40,7 +46,7 @@ watch(
}
}
},
{ immediate: true },
{ immediate: true }
)
async function handleSave() {
@@ -22,11 +22,13 @@ const emit = defineEmits<{
const submitting = ref(false)
const description = ref('')
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig))
const salePriceConfigDraft = ref<PublishSalePriceConfig>(
cloneSalePriceConfig(emptyListingSalePriceConfig)
)
watch(
() => props.modelValue,
(val) => {
val => {
if (val) {
salePriceConfigDraft.value = parseSalePriceConfig(props.config.value)
description.value = props.config.description || ''
@@ -103,7 +105,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<p class="config-key-label">
<strong>{{ config.key }}</strong>
</p>
<div class="publish-options-editor">
<div class="editor-toolbar">
@@ -117,17 +121,29 @@ function readError(error: unknown, fallback: string) {
</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>
<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>
<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>
<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>
<el-button
size="small"
type="danger"
plain
@click="removeSaleFixedMarkupRule($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -138,17 +154,29 @@ function readError(error: unknown, fallback: string) {
</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>
<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>
<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>
<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>
<el-button
size="small"
type="danger"
plain
@click="removeSaleRatioAdjustmentRule($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -51,14 +51,17 @@ function getSupportStatusColor(status: string) {
return colors[status] || '#9ca3af'
}
watch(() => props.modelValue, (val) => {
watch(
() => props.modelValue,
val => {
visible.value = val
if (val) {
loadAdmins()
}
})
}
)
watch(visible, (val) => {
watch(visible, val => {
emit('update:modelValue', val)
})
@@ -112,7 +115,10 @@ function getStatusTag(count: number) {
<div class="admin-info">
<div class="admin-avatar-wrap">
<el-avatar :size="36" :icon="User" />
<span class="status-indicator" :style="{ backgroundColor: getSupportStatusColor(admin.support_status) }"></span>
<span
class="status-indicator"
:style="{ backgroundColor: getSupportStatusColor(admin.support_status) }"
></span>
</div>
<div class="admin-detail">
<span class="admin-name">{{ admin.nickname }}</span>
@@ -120,7 +126,10 @@ function getStatusTag(count: number) {
</div>
</div>
<div class="admin-status">
<span class="support-status" :style="{ color: getSupportStatusColor(admin.support_status) }">
<span
class="support-status"
:style="{ color: getSupportStatusColor(admin.support_status) }"
>
{{ getSupportStatusLabel(admin.support_status) }}
</span>
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
@@ -131,7 +140,12 @@ function getStatusTag(count: number) {
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!selectedAdminId" @click="handleSubmit">
<el-button
type="primary"
:loading="submitting"
:disabled="!selectedAdminId"
@click="handleSubmit"
>
确认转接
</el-button>
</template>
@@ -62,7 +62,7 @@ async function handleConfirmPayment() {
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '输入打款备注...',
inputValidator: (value) => {
inputValidator: value => {
return value && value.trim().length > 0
},
inputErrorMessage: '请输入打款备注',
@@ -97,10 +97,15 @@ function statusLabel(status: string) {
}
function statusType(status: string) {
return status === 'completed' ? 'success' :
status === 'pending' ? 'warning' :
status === 'processing' ? 'primary' :
status === 'rejected' ? 'danger' : 'info'
return status === 'completed'
? 'success'
: status === 'pending'
? 'warning'
: status === 'processing'
? 'primary'
: status === 'rejected'
? 'danger'
: 'info'
}
function accountTypeLabel(type: string) {
@@ -188,7 +193,11 @@ function accountTypeLabel(type: string) {
<el-descriptions-item v-if="withdrawal.bank_branch" label="开户支行">
{{ withdrawal.bank_branch }}
</el-descriptions-item>
<el-descriptions-item v-if="withdrawal.certificate_urls && withdrawal.certificate_urls.length > 0" label="收款二维码" :span="2">
<el-descriptions-item
v-if="withdrawal.certificate_urls && withdrawal.certificate_urls.length > 0"
label="收款二维码"
:span="2"
>
<div style="display: flex; gap: 8px; flex-wrap: wrap">
<el-image
v-for="(url, idx) in withdrawal.certificate_urls"
@@ -299,9 +308,7 @@ function accountTypeLabel(type: string) {
确认打款
</el-button>
</div>
<el-button @click="emit('update:modelValue', false)">
关闭
</el-button>
<el-button @click="emit('update:modelValue', false)"> 关闭 </el-button>
</div>
</template>
</el-dialog>
@@ -21,7 +21,9 @@ interface AdminPaginatedTableResult<T> {
handleSizeChange: () => void
}
export function useAdminPaginatedTable<T>(options: AdminPaginatedTableOptions<T>): AdminPaginatedTableResult<T> {
export function useAdminPaginatedTable<T>(
options: AdminPaginatedTableOptions<T>
): AdminPaginatedTableResult<T> {
const loading = ref(false)
const data = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
+2 -2
View File
@@ -19,7 +19,7 @@ export {
type AdminUser,
type AdminTokenPair,
type AdminLoginData,
type AdminCaptcha
type AdminCaptcha,
} from './api/adminAuth'
// adminRoles 导出
@@ -31,5 +31,5 @@ export {
deleteRole,
fetchPermissions,
type Role,
type Permission
type Permission,
} from './api/adminRoles'
@@ -1,7 +1,17 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, Delete, Document, Edit, Plus, QuestionFilled, Refresh, Top, Warning } from '@element-plus/icons-vue'
import {
Bell,
Delete,
Document,
Edit,
Plus,
QuestionFilled,
Refresh,
Top,
Warning,
} from '@element-plus/icons-vue'
import {
fetchAdminAnnouncements,
createAnnouncement,
@@ -199,12 +209,34 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</div>
<div class="filter-bar">
<el-select v-model="statusFilter" placeholder="筛选状态" clearable @change="handleFilterChange" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-select
v-model="statusFilter"
placeholder="筛选状态"
clearable
@change="handleFilterChange"
style="width: 150px"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select v-model="categoryFilter" placeholder="筛选分类" clearable @change="handleFilterChange" style="width: 150px">
<el-select
v-model="categoryFilter"
placeholder="筛选分类"
clearable
@change="handleFilterChange"
style="width: 150px"
>
<el-option value="" label="全部分类" />
<el-option v-for="cat in categories" :key="cat.value" :label="cat.label" :value="cat.value" />
<el-option
v-for="cat in categories"
:key="cat.value"
:label="cat.label"
:value="cat.value"
/>
</el-select>
</div>
@@ -220,14 +252,23 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
<el-icon><Top /></el-icon>
置顶
</el-tag>
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain">重要</el-tag>
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain"
>重要</el-tag
>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag :style="{ borderColor: getCategoryColor(row.category), color: getCategoryColor(row.category) }" effect="plain" size="small">
<el-tag
:style="{
borderColor: getCategoryColor(row.category),
color: getCategoryColor(row.category),
}"
effect="plain"
size="small"
>
{{ getCategoryLabel(row.category) }}
</el-tag>
</template>
@@ -248,17 +289,43 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)" text style="color: #1d6fd6; font-weight: 700;">
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
text
style="color: #1d6fd6; font-weight: 700"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button v-if="row.status === 'draft'" type="success" size="small" @click="handlePublish(row)" text style="color: #4caf50; font-weight: 700;">
<el-button
v-if="row.status === 'draft'"
type="success"
size="small"
@click="handlePublish(row)"
text
style="color: #4caf50; font-weight: 700"
>
发布
</el-button>
<el-button v-if="row.status === 'published'" type="warning" size="small" @click="handleArchive(row)" text style="color: #d97706; font-weight: 700;">
<el-button
v-if="row.status === 'published'"
type="warning"
size="small"
@click="handleArchive(row)"
text
style="color: #d97706; font-weight: 700"
>
归档
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)" text style="color: #dc2626; font-weight: 700;">
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
text
style="color: #dc2626; font-weight: 700"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
@@ -18,7 +18,11 @@ const filters = reactive({
biz_type: '',
})
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
const highRiskCount = computed(
() =>
logs.value.filter(item => item.action.includes('freeze') || item.action.includes('update'))
.length
)
onMounted(loadLogs)
@@ -81,7 +85,9 @@ function actionType(action: string) {
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs">查询</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs"
>查询</el-button
>
</div>
</div>
@@ -101,7 +107,13 @@ function actionType(action: string) {
<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-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" />
@@ -155,9 +167,18 @@ function actionType(action: string) {
@page-change="handlePageChange"
/>
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
<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>
<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">
@@ -13,7 +13,7 @@ import {
Tickets,
User,
Wallet,
Warning
Warning,
} from '@element-plus/icons-vue'
import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
@@ -23,7 +23,12 @@ import { formatDateTime } from '@/utils/time'
const money = useMoney()
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({
const {
loading,
error,
data: dashboard,
load: loadDashboard,
} = useAdminTable<AdminDashboard>({
fetchFn: fetchAdminDashboard,
})
</script>
@@ -108,7 +113,13 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
待处理事项
</h2>
<el-tag type="warning" effect="dark" round>
{{ dashboard.pending.disputes + dashboard.pending.listing_reviews + dashboard.pending.pending_handoffs + dashboard.pending.pending_return_confirms }}
{{
dashboard.pending.disputes +
dashboard.pending.listing_reviews +
dashboard.pending.pending_handoffs +
dashboard.pending.pending_return_confirms
}}
</el-tag>
</div>
<div class="pending-list">
@@ -117,28 +128,36 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
<span class="pending-label">待仲裁申诉</span>
<span class="pending-desc">需要处理的用户争议</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{ dashboard.pending.disputes }}</strong>
<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>
<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>
<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>
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{
dashboard.pending.pending_return_confirms
}}</strong>
</div>
</div>
</div>
@@ -190,7 +209,17 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
<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">
<el-tag
:type="
row.status === 'completed'
? 'success'
: row.status === 'renting'
? 'primary'
: 'warning'
"
size="small"
effect="plain"
>
{{ orderStatusLabel(row.status) }}
</el-tag>
</template>
@@ -218,7 +247,11 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
<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">
<el-tag
:type="row.status === 'resolved' ? 'success' : 'danger'"
size="small"
effect="plain"
>
{{ disputeStatusLabel(row.status) }}
</el-tag>
</template>
@@ -128,11 +128,26 @@ function readError(error: unknown, fallback: string) {
<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
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>
<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>
@@ -146,9 +161,16 @@ function readError(error: unknown, fallback: string) {
@page-change="handlePageChange"
/>
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
<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>
<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" />
@@ -168,19 +190,38 @@ function readError(error: unknown, fallback: string) {
: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="填写客服裁决说明" />
<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>
<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">
<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>
<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>
@@ -4,7 +4,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/features/listings'
import {
adminMarkListingAbnormal,
adminOfflineListing,
fetchAdminListing,
type Listing,
} from '@/features/listings'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -15,8 +20,15 @@ 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))
const actionTitle = computed(() =>
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
)
const canOperate = computed(
() =>
!!listing.value &&
listing.value.status !== 'rented' &&
!['offline', 'abnormal'].includes(listing.value.status)
)
onMounted(loadListing)
@@ -100,8 +112,12 @@ function readError(error: unknown, fallback: string) {
<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>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
>强制下架</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
</div>
</div>
@@ -164,10 +180,22 @@ function readError(error: unknown, fallback: string) {
<p v-else>暂无截图</p>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<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="填写后台操作原因,会写入审计日志并通知号主" />
<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>
@@ -4,7 +4,13 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adjustListingReviewPrice, approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
import {
adjustListingReviewPrice,
approveListing,
fetchPendingReviewListings,
rejectListing,
type Listing,
} from '@/features/listings'
import {
assetRegions,
formatHafCoinM,
@@ -26,7 +32,13 @@ interface RiskItem {
}
const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常']
const rejectReasonOptions = [
'默认截图,需补充真实截图',
'账号资产信息不完整',
'价格或押金异常',
'封禁记录需补充说明',
'联系方式异常',
]
const loading = ref(false)
const submitting = ref(false)
@@ -50,10 +62,12 @@ const filters = reactive({
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 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) => {
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
@@ -62,26 +76,33 @@ const filteredListings = computed(() => {
})
})
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
const priceAdjustPreview = computed(() => (priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null))
const selectedResources = computed(() =>
selectedListing.value ? getListingResources(selectedListing.value) : []
)
const selectedSkins = computed(() =>
selectedListing.value ? getSkinNames(selectedListing.value) : []
)
const priceAdjustPreview = computed(() =>
priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null
)
watch(
() => selectedListing.value,
async (listing) => {
async listing => {
if (!listing) return
selectedID.value = listing.id
await nextTick()
loadScreenshotPreviews(listing)
},
{ immediate: true },
{ immediate: true }
)
watch(priceAdjustMode, (mode, previousMode) => {
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
if (mode === 'price') {
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
priceAdjustForm.buyer_total_price =
preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
} else {
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
}
@@ -89,14 +110,14 @@ watch(priceAdjustMode, (mode, previousMode) => {
onMounted(loadListings)
onBeforeUnmount(() => {
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
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)) {
if (!selectedID.value || !listings.value.some(item => item.id === selectedID.value)) {
selectedID.value = listings.value[0]?.id || null
}
} finally {
@@ -109,7 +130,7 @@ function selectListing(row: Listing) {
}
function replaceListing(next: Listing) {
const index = listings.value.findIndex((item) => item.id === next.id)
const index = listings.value.findIndex(item => item.id === next.id)
if (index >= 0) {
listings.value.splice(index, 1, next)
} else {
@@ -157,8 +178,14 @@ async function handleSavePriceAdjust() {
if (!priceAdjustListing.value) return
const payload =
priceAdjustMode.value === 'ratio'
? { buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0), reason: priceAdjustForm.reason.trim() }
: { buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0), reason: priceAdjustForm.reason.trim() }
? {
buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0),
reason: priceAdjustForm.reason.trim(),
}
: {
buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0),
reason: priceAdjustForm.reason.trim(),
}
if ((payload.buyer_ratio || payload.buyer_total_price || 0) <= 0) {
ElMessage.warning('请填写有效的加价后比例或价格')
return
@@ -233,7 +260,9 @@ function assetNumberText(row: Listing, key: string) {
function priceBreakdown(row: Listing) {
const breakdown = row.asset_summary?.price_breakdown
return typeof breakdown === 'object' && breakdown !== null ? (breakdown as Record<string, unknown>) : {}
return typeof breakdown === 'object' && breakdown !== null
? (breakdown as Record<string, unknown>)
: {}
}
function breakdownNumber(row: Listing, key: string) {
@@ -278,7 +307,8 @@ function sellerCoinBasePrice(row: Listing) {
}
function sellerRatio(row: Listing) {
const value = breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
const value =
breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
if (value > 0) return value
const base = sellerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0
@@ -302,7 +332,10 @@ function buyerRatio(row: Listing) {
return base > 0 ? getCoinWan(row) / base : 0
}
function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = priceAdjustMode.value) {
function calculatePriceAdjustPreview(
row: Listing,
mode: 'ratio' | 'price' = priceAdjustMode.value
) {
const consumablePrice = getListingConsumablePrice(row)
const coinWan = getCoinWan(row)
if (mode === 'price') {
@@ -313,7 +346,8 @@ function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = pri
}
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 0
const buyerTotalPrice = buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
const buyerTotalPrice =
buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
}
@@ -412,7 +446,7 @@ function isExternalUpload(row: Listing) {
}
function hasDefaultScreenshot(row: Listing) {
return row.screenshot_urls?.some((url) => isDefaultScreenshot(url)) || false
return row.screenshot_urls?.some(url => isDefaultScreenshot(url)) || false
}
function isDefaultScreenshot(url: string) {
@@ -425,12 +459,18 @@ function riskItems(row: Listing): RiskItem[] {
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)) {
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 (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
}
@@ -525,19 +565,35 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="review-summary">
<button class="summary-card" :class="{ active: filters.risk === 'all' }" @click="filters.risk = 'all'">
<button
class="summary-card"
:class="{ active: filters.risk === 'all' }"
@click="filters.risk = 'all'"
>
<span>待审核</span>
<strong>{{ listings.length }}</strong>
</button>
<button class="summary-card" :class="{ active: filters.risk === 'external' }" @click="filters.risk = 'external'">
<button
class="summary-card"
:class="{ active: filters.risk === 'external' }"
@click="filters.risk = 'external'"
>
<span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
</button>
<button class="summary-card" :class="{ active: filters.risk === 'defaultImage' }" @click="filters.risk = 'defaultImage'">
<button
class="summary-card"
:class="{ active: filters.risk === 'defaultImage' }"
@click="filters.risk = 'defaultImage'"
>
<span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</button>
<button class="summary-card" :class="{ active: filters.risk === 'ban' }" @click="filters.risk = 'ban'">
<button
class="summary-card"
:class="{ active: filters.risk === 'ban' }"
@click="filters.risk = 'ban'"
>
<span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
</button>
@@ -546,7 +602,12 @@ function readError(error: unknown, fallback: string) {
<div class="review-workbench">
<aside class="review-queue">
<div class="queue-toolbar">
<el-input v-model="filters.keyword" :prefix-icon="Search" clearable placeholder="搜索标题、客服、段位、皮肤" />
<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" />
@@ -571,16 +632,23 @@ function readError(error: unknown, fallback: string) {
<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
>{{ 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="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="暂无符合条件的待审核商品" />
<el-empty
v-if="!loading && !filteredListings.length"
description="暂无符合条件的待审核商品"
/>
</div>
</aside>
@@ -595,12 +663,29 @@ function readError(error: unknown, fallback: string) {
<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>
<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">
<el-tag
v-for="risk in activeRisks"
:key="risk.label"
:type="riskTagType(risk.level)"
effect="light"
>
{{ risk.label }}
</el-tag>
</div>
@@ -612,14 +697,19 @@ function readError(error: unknown, fallback: string) {
<h3>价格审核</h3>
<p>核对卖家提交价格平台加价规则和最终买家展示价</p>
</div>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)">调整价格</el-button>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)"
>调整价格</el-button
>
</div>
<div class="price-decision-grid">
<div class="price-decision-card seller">
<span>卖家发布</span>
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
<small>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }}</small>
<small
>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品
{{ money(getListingConsumablePrice(selectedListing)) }}</small
>
</div>
<div class="price-decision-card rule">
<span>当前加价规则</span>
@@ -648,29 +738,82 @@ function readError(error: unknown, fallback: string) {
<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>
<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>
<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>
<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>
@@ -681,9 +824,16 @@ function readError(error: unknown, fallback: string) {
<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>
<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">
@@ -693,7 +843,9 @@ function readError(error: unknown, fallback: string) {
</div>
</div>
<div class="skin-list">
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{ skin }}</el-tag>
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{
skin
}}</el-tag>
<span v-if="!selectedSkins.length">暂无皮肤数据</span>
</div>
</section>
@@ -701,10 +853,18 @@ function readError(error: unknown, fallback: string) {
<section class="review-panel">
<div class="panel-title">
<h3>账号截图</h3>
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)">查看全部</el-button>
<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)">
<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>
@@ -721,15 +881,32 @@ function readError(error: unknown, fallback: string) {
</main>
</div>
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="620px" @update:model-value="activeListing = null">
<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>
<p>
<strong>{{ activeListing.title }}</strong>
</p>
<div class="reject-reasons">
<el-button v-for="reason in rejectReasonOptions" :key="reason" size="small" @click="appendRejectReason(reason)">
<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="填写拒绝原因,号主会在通知中看到审核结果" />
<el-input
v-model="rejectReason"
type="textarea"
:rows="5"
placeholder="填写拒绝原因,号主会在通知中看到审核结果"
/>
</div>
<template #footer>
<el-button @click="activeListing = null">取消</el-button>
@@ -737,7 +914,12 @@ function readError(error: unknown, fallback: string) {
</template>
</el-dialog>
<el-dialog :model-value="!!priceAdjustListing" title="调整审核价格" width="560px" @update:model-value="priceAdjustListing = null">
<el-dialog
:model-value="!!priceAdjustListing"
title="调整审核价格"
width="560px"
@update:model-value="priceAdjustListing = null"
>
<div v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
<div class="adjust-current">
<div>
@@ -746,11 +928,15 @@ function readError(error: unknown, fallback: string) {
</div>
<div>
<span>调整后买家价</span>
<strong class="preview-value">{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}</strong>
<strong class="preview-value">{{
previewMoney(priceAdjustPreview?.buyerTotalPrice || 0)
}}</strong>
</div>
<div>
<span>调整后买家比例</span>
<strong class="preview-value">{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}</strong>
<strong class="preview-value">{{
ratioText(priceAdjustPreview?.buyerRatio || 0)
}}</strong>
</div>
</div>
<el-radio-group v-model="priceAdjustMode" class="adjust-mode">
@@ -759,28 +945,61 @@ function readError(error: unknown, fallback: string) {
</el-radio-group>
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
<span>加价后比例</span>
<el-input-number v-model="priceAdjustForm.buyer_ratio" :min="0" :step="0.1" :controls="false" />
<el-input-number
v-model="priceAdjustForm.buyer_ratio"
:min="0"
:step="0.1"
:controls="false"
/>
</label>
<label v-else class="adjust-field">
<span>加价后价格</span>
<el-input-number v-model="priceAdjustForm.buyer_total_price" :min="0" :step="1" :controls="false" />
<el-input-number
v-model="priceAdjustForm.buyer_total_price"
:min="0"
:step="1"
:controls="false"
/>
</label>
<label class="adjust-field">
<span>调价原因</span>
<el-input v-model="priceAdjustForm.reason" type="textarea" :rows="3" placeholder="可填写调价原因,便于审计追踪" />
<el-input
v-model="priceAdjustForm.reason"
type="textarea"
:rows="3"
placeholder="可填写调价原因,便于审计追踪"
/>
</label>
</div>
<template #footer>
<el-button @click="priceAdjustListing = null">取消</el-button>
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust">保存调整</el-button>
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust"
>保存调整</el-button
>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
<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)">
<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>
@@ -911,7 +1130,10 @@ function readError(error: unknown, fallback: string) {
padding: 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
.queue-item:hover,
@@ -3,7 +3,12 @@ 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 '@/features/listings'
import {
fetchAdminListings,
type AdminListingPage,
type AdminListingQuery,
type Listing,
} from '@/features/listings'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { useMoney } from '@/shared/composables/useMoney'
import {
@@ -29,7 +34,11 @@ const filters = reactive<AdminListingQuery>({
const pageSize = ref(10)
const currentPage = ref(1)
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
const {
loading,
data: listingPage,
load: loadListings,
} = useAdminTable<AdminListingPage>({
fetchFn: () =>
fetchAdminListings({
...filters,
@@ -46,9 +55,13 @@ const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminLi
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 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 = [
@@ -65,10 +78,18 @@ const screenshotColumns = [
{ 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: 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)}` },
{
label: '租期',
width: 92,
read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}`,
},
] as const
async function queryListings() {
@@ -90,7 +111,9 @@ function setStatusFilter(status: string) {
}
function setReviewStatusFilter(reviewStatus: string) {
filters.review_status = (filters.review_status === reviewStatus ? '' : reviewStatus) as AdminListingQuery['review_status']
filters.review_status = (
filters.review_status === reviewStatus ? '' : reviewStatus
) as AdminListingQuery['review_status']
filters.status = ''
void queryListings()
}
@@ -143,7 +166,7 @@ async function downloadTableScreenshot() {
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'))
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('截图生成失败')
return blob
}
@@ -165,14 +188,17 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
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 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)
)
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 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)
@@ -189,7 +215,11 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
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)
ctx.fillText(
`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value}`,
paddingX,
paddingY + 40
)
let y = paddingY + titleHeight
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
@@ -211,7 +241,10 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
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'
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
}
@@ -240,18 +273,37 @@ function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: n
return lines
}
function drawCellText(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number) {
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) {
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) {
function drawLine(
ctx: CanvasRenderingContext2D,
startX: number,
startY: number,
endX: number,
endY: number
) {
ctx.strokeStyle = '#e6eaf2'
ctx.lineWidth = 1
ctx.beginPath()
@@ -311,7 +363,7 @@ function characterAndWeaponSkinText(row: Listing) {
{ label: '武器', key: 'weapon' },
]
const parts = groups
.map((group) => {
.map(group => {
const names = getSkinGroup(row, group.key)
return names.length ? `${group.label}${names.join('、')}` : ''
})
@@ -421,9 +473,13 @@ function formatQuantity(value: number) {
</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 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>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot"
>下载截图</el-button
>
</div>
</div>
</div>
@@ -504,7 +560,9 @@ function formatQuantity(value: number) {
</el-table>
<div class="table-pagination">
<span class="pagination-summary">当前页 {{ listings.length }} {{ totalListings }} </span>
<span class="pagination-summary"
>当前页 {{ listings.length }} {{ totalListings }} </span
>
<div class="pagination-controls">
<span class="pagination-size-label">每页</span>
<el-select
@@ -520,7 +578,9 @@ function formatQuantity(value: number) {
</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>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage"
>下页</el-button
>
</div>
</div>
</section>
@@ -1,63 +1,62 @@
<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 { 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 "@/features/admin";
import { useAdminSessionStore } from "@/stores/adminSession";
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
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 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: "",
});
username: 'admin',
password: 'admin123456',
captchaCode: '',
})
onMounted(loadCaptcha);
onMounted(loadCaptcha)
async function loadCaptcha() {
captchaLoading.value = true;
captchaLoading.value = true
try {
captcha.value = await fetchAdminCaptcha();
form.captchaCode = "";
captcha.value = await fetchAdminCaptcha()
form.captchaCode = ''
} catch (error) {
ElMessage.error(readError(error, "验证码加载失败"));
ElMessage.error(readError(error, '验证码加载失败'))
} finally {
captchaLoading.value = false;
captchaLoading.value = false
}
}
async function handleLogin() {
loading.value = true;
loading.value = true
try {
await adminSession.login(
form.username,
form.password,
captcha.value?.captcha_id || "",
captcha.value?.captcha_id || '',
form.captchaCode
);
ElMessage.success("后台登录成功");
await router.push("/admin/dashboard");
)
ElMessage.success('后台登录成功')
await router.push('/admin/dashboard')
} catch (error) {
ElMessage.error(readError(error, "后台登录失败"));
await loadCaptcha();
ElMessage.error(readError(error, '后台登录失败'))
await loadCaptcha()
} finally {
loading.value = false;
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;
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 fallback
}
</script>
@@ -151,16 +150,9 @@ function readError(error: unknown, fallback: string) {
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%
),
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;
}
@@ -172,11 +164,7 @@ function readError(error: unknown, fallback: string) {
width: 50vw;
height: 50vw;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(20, 119, 255, 0.22),
transparent 65%
);
background: radial-gradient(circle, rgba(20, 119, 255, 0.22), transparent 65%);
filter: blur(80px);
pointer-events: none;
}
@@ -185,11 +173,7 @@ function readError(error: unknown, fallback: string) {
left: auto;
right: -10%;
bottom: -10%;
background: radial-gradient(
circle,
rgba(109, 40, 217, 0.18),
transparent 65%
);
background: radial-gradient(circle, rgba(109, 40, 217, 0.18), transparent 65%);
}
.admin-login-card {
@@ -203,7 +187,8 @@ function readError(error: unknown, fallback: string) {
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),
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
@@ -214,11 +199,8 @@ function readError(error: unknown, fallback: string) {
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%
),
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));
}
@@ -312,14 +294,17 @@ function readError(error: unknown, fallback: string) {
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;
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,
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) {
@@ -352,7 +337,9 @@ function readError(error: unknown, fallback: string) {
padding: 0;
cursor: pointer;
overflow: hidden;
transition: background 0.2s, border-color 0.2s;
transition:
background 0.2s,
border-color 0.2s;
}
.captcha-image-button:hover {
background: rgba(255, 255, 255, 0.07);
@@ -383,7 +370,9 @@ function readError(error: unknown, fallback: string) {
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;
transition:
transform 0.15s,
box-shadow 0.2s;
}
.admin-login-btn:hover {
transform: translateY(-1px);
@@ -2,7 +2,12 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminMgrUsers, deleteAdminMgrUser, changeAdminPassword, type AdminMgrUser } from '@/features/admin/api/adminMgr'
import {
fetchAdminMgrUsers,
deleteAdminMgrUser,
changeAdminPassword,
type AdminMgrUser,
} from '@/features/admin/api/adminMgr'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { formatDateTime } from '@/utils/time'
@@ -19,7 +24,15 @@ 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>({
const {
loading,
data: admins,
total,
currentPage,
currentPageSize,
load: loadAdmins,
handleSizeChange,
} = useAdminPaginatedTable<AdminMgrUser>({
fetchFn: fetchAdminMgrUsers,
})
@@ -46,11 +59,15 @@ function openPassword(row: AdminMgrUser) {
async function handleDelete(row: AdminMgrUser) {
try {
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', {
await ElMessageBox.confirm(
`确定要删除管理员「${row.username}」吗?此操作不可撤销。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
}
)
await deleteAdminMgrUser(row.id)
ElMessage.success('管理员已删除')
await loadAdmins()
@@ -130,7 +147,9 @@ const statusLabel: Record<string, string> = {
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ row.last_login_at ? formatDateTime(row.last_login_at) : '-' }}</template>
<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 }">
@@ -152,18 +171,10 @@ const statusLabel: Record<string, string> = {
/>
<!-- 新建/编辑对话框 -->
<AdminUserDialog
v-model="showDialog"
:admin="editingAdmin"
@saved="loadAdmins"
/>
<AdminUserDialog v-model="showDialog" :admin="editingAdmin" @saved="loadAdmins" />
<!-- 角色分配对话框 -->
<AssignRolesDialog
v-model="showRolesDialog"
:admin="rolesAdmin"
@saved="loadAdmins"
/>
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
<!-- 修改密码对话框 -->
<el-dialog
@@ -174,15 +185,27 @@ const statusLabel: Record<string, string> = {
>
<div class="dialog-body">
<el-form-item label="原密码" class="full-control">
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入原密码" />
<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-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>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
>确认修改</el-button
>
</template>
</el-dialog>
</section>
@@ -3,7 +3,17 @@ 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 '@/features/orders'
import {
adminCloseOrder,
adminMarkOrderAbnormal,
adminRefundOrder,
adminRefundStatus,
fetchAdminHandoffRecords,
fetchAdminOrder,
type HandoffRecord,
type Order,
type RefundStatus,
} from '@/features/orders'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -19,7 +29,9 @@ 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))
const canOperate = computed(
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
)
onMounted(loadOrder)
@@ -138,9 +150,18 @@ function formatHandoffRecordType(type: string) {
<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">
<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>
@@ -170,7 +191,9 @@ function formatHandoffRecordType(type: string) {
<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>
<small v-if="refundStatus.refund_amount_cent > 0"
>¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small
>
</div>
</div>
@@ -199,10 +222,22 @@ function formatHandoffRecordType(type: string) {
<pre>{{ snapshotText }}</pre>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<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="填写客服操作原因,会写入审计日志并通知双方" />
<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>
@@ -32,7 +32,7 @@ async function handlePageChange() {
const filteredOrders = computed(() => {
if (!status.value) return orders.value
return orders.value.filter((item) => item.status === status.value)
return orders.value.filter(item => item.status === status.value)
})
</script>
@@ -117,11 +117,15 @@ async function handleDelete(row: PaymentConfig) {
async function handleExportBackup() {
try {
await ElMessageBox.confirm('备份文件会包含支付密钥明文,请妥善保管。确定导出吗?', '导出支付配置备份', {
await ElMessageBox.confirm(
'备份文件会包含支付密钥明文,请妥善保管。确定导出吗?',
'导出支付配置备份',
{
type: 'warning',
confirmButtonText: '导出',
cancelButtonText: '取消',
})
}
)
exporting.value = true
const { blob, filename } = await exportPaymentConfigBackup()
@@ -162,7 +166,7 @@ async function handleImportFile(event: Event) {
type: 'warning',
confirmButtonText: '导入',
cancelButtonText: '取消',
},
}
)
importing.value = true
@@ -256,18 +260,52 @@ function deleteDisabledReason(row: PaymentConfig) {
</div>
<div class="filter-bar">
<el-select v-model="filterProvider" placeholder="支付服务商" style="width: 150px" @change="loadConfigs">
<el-option v-for="opt in providerOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="filterProvider"
placeholder="支付服务商"
style="width: 150px"
@change="loadConfigs"
>
<el-option
v-for="opt in providerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterStatus" placeholder="状态" style="width: 120px" @change="loadConfigs">
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="filterStatus"
placeholder="状态"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterEnvironment" placeholder="环境" style="width: 120px" @change="loadConfigs">
<el-option v-for="opt in environmentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="filterEnvironment"
placeholder="环境"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in environmentOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button :icon="Refresh" @click="loadConfigs">刷新</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup">导入备份</el-button>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup">导出备份</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup"
>导入备份</el-button
>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup"
>导出备份</el-button
>
<el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button>
</div>
@@ -279,12 +317,7 @@ function deleteDisabledReason(row: PaymentConfig) {
@change="handleImportFile"
/>
<el-table
:data="filteredConfigs"
v-loading="loading"
class="payment-config-table"
stripe
>
<el-table :data="filteredConfigs" v-loading="loading" class="payment-config-table" stripe>
<el-table-column prop="name" label="配置名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="provider" label="服务商" width="120">
<template #default="{ row }">
@@ -327,10 +360,20 @@ function deleteDisabledReason(row: PaymentConfig) {
<el-table-column label="操作" width="226" class-name="operation-column">
<template #default="{ row }">
<div class="action-buttons">
<el-button class="action-button view" size="small" :icon="View" @click="handleView(row)">
<el-button
class="action-button view"
size="small"
:icon="View"
@click="handleView(row)"
>
查看
</el-button>
<el-button class="action-button edit" size="small" :icon="Edit" @click="handleEdit(row)">
<el-button
class="action-button edit"
size="small"
:icon="Edit"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-tooltip :content="deleteDisabledReason(row)" placement="top">
@@ -13,7 +13,11 @@ const editingRole = ref<Role | null>(null)
const showPermsDialog = ref(false)
const permsRole = ref<Role | null>(null)
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({
const {
loading,
data: roles,
load: loadRoles,
} = useAdminTable<Role[]>({
fetchFn: fetchRoles,
})
@@ -34,11 +38,15 @@ function openPerms(row: Role) {
async function handleDelete(row: Role) {
try {
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', {
await ElMessageBox.confirm(
`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
}
)
await deleteRole(row.id)
ElMessage.success('角色已删除')
await loadRoles()
@@ -85,18 +93,10 @@ async function handleDelete(row: Role) {
</el-table>
<!-- 新建/编辑对话框 -->
<RoleDialog
v-model="showDialog"
:role="editingRole"
@saved="loadRoles"
/>
<RoleDialog v-model="showDialog" :role="editingRole" @saved="loadRoles" />
<!-- 权限分配对话框 -->
<AssignPermissionsDialog
v-model="showPermsDialog"
:role="permsRole"
@saved="loadRoles"
/>
<AssignPermissionsDialog v-model="showPermsDialog" :role="permsRole" @saved="loadRoles" />
</section>
</template>
@@ -69,25 +69,39 @@ const agreementsVisible = ref(false)
const postRentalNoticeVisible = 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 listingPublishAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_agreements') || null)
const orderAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'order.agreements') || null)
const postRentalNoticeConfig = computed(() => configs.value.find((item) => item.key === 'profile.post_rental_notice') || null)
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 listingPublishAgreementsConfig = computed(
() => configs.value.find(item => item.key === 'listing.publish_agreements') || null
)
const orderAgreementsConfig = computed(
() => configs.value.find(item => item.key === 'order.agreements') || null
)
const postRentalNoticeConfig = computed(
() => configs.value.find(item => item.key === 'profile.post_rental_notice') || null
)
const regularConfigs = computed(() =>
configs.value.filter(
(item) =>
item =>
item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners' &&
item.key !== 'listing.publish_agreements' &&
item.key !== 'order.agreements' &&
item.key !== 'profile.post_rental_notice',
),
item.key !== 'profile.post_rental_notice'
)
)
// 只读计算属性,用于渲染卡片上的统计信息
@@ -134,7 +148,9 @@ const agreementStats = computed(() => {
})
const listingPublishAgreementStats = computed(() => {
const agreements = parseListingPublishAgreements(listingPublishAgreementsConfig.value?.value || '')
const agreements = parseListingPublishAgreements(
listingPublishAgreementsConfig.value?.value || ''
)
return {
saleTitle: agreements.virtual_asset_sale.title || '出售协议',
sellerTitle: agreements.seller_agreement.title || '号主协议',
@@ -199,12 +215,24 @@ function parseOrderAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultOrderAgreements)
return {
virtual_asset_purchase: {
title: readText(parsed.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title),
content: readText(parsed.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content),
title: readText(
parsed.virtual_asset_purchase?.title,
defaultOrderAgreements.virtual_asset_purchase.title
),
content: readText(
parsed.virtual_asset_purchase?.content,
defaultOrderAgreements.virtual_asset_purchase.content
),
},
renter_agreement: {
title: readText(parsed.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title),
content: readText(parsed.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content),
title: readText(
parsed.renter_agreement?.title,
defaultOrderAgreements.renter_agreement.title
),
content: readText(
parsed.renter_agreement?.content,
defaultOrderAgreements.renter_agreement.content
),
},
}
}
@@ -213,12 +241,24 @@ function parseListingPublishAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
return {
virtual_asset_sale: {
title: readText(parsed.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title),
content: readText(parsed.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content),
title: readText(
parsed.virtual_asset_sale?.title,
defaultListingPublishAgreements.virtual_asset_sale.title
),
content: readText(
parsed.virtual_asset_sale?.content,
defaultListingPublishAgreements.virtual_asset_sale.content
),
},
seller_agreement: {
title: readText(parsed.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title),
content: readText(parsed.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content),
title: readText(
parsed.seller_agreement?.title,
defaultListingPublishAgreements.seller_agreement.title
),
content: readText(
parsed.seller_agreement?.content,
defaultListingPublishAgreements.seller_agreement.content
),
},
}
}
@@ -343,8 +383,12 @@ function formatConfigValue(row: SystemConfig) {
<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>
<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">
@@ -378,7 +422,9 @@ function formatConfigValue(row: SystemConfig) {
<h2>发布协议配置</h2>
<span>管理发布账号前必须勾选确认的虚拟资产出售协议和号主协议</span>
</div>
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)">编辑发布协议</el-button>
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)"
>编辑发布协议</el-button
>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
@@ -2,7 +2,12 @@
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/features/admin/api/adminUsers'
import {
fetchAdminUsers,
freezeAdminUser,
unfreezeAdminUser,
type AdminUserItem,
} from '@/features/admin/api/adminUsers'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -12,7 +17,15 @@ 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>({
const {
loading,
data: users,
total,
currentPage,
currentPageSize,
load: loadUsers,
handleSizeChange,
} = useAdminPaginatedTable<AdminUserItem>({
fetchFn: fetchAdminUsers,
})
@@ -81,10 +94,23 @@ function readError(error: unknown, fallback: string) {
</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
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>
<el-button
v-else
size="small"
type="primary"
:loading="submitting"
@click="handleUnfreeze(row)"
>解冻</el-button
>
</template>
</el-table-column>
</el-table>
@@ -98,10 +124,22 @@ function readError(error: unknown, fallback: string) {
@page-change="loadUsers"
/>
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
<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="填写冻结原因,便于审计追踪" />
<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>
@@ -19,10 +19,14 @@ const filters = reactive({
})
const inAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'in').reduce((sum, item) => sum + Number(item.amount || 0), 0),
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),
ledger.value
.filter(item => item.direction === 'out')
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
)
onMounted(loadLedger)
@@ -89,7 +93,9 @@ function directionLabel(direction: string) {
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger"
>查询</el-button
>
</div>
</div>
@@ -134,7 +140,9 @@ function directionLabel(direction: string) {
</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>
<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>
@@ -104,7 +104,7 @@ async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '输入打款备注...',
inputValidator: (value) => {
inputValidator: value => {
return value && value.trim().length > 0
},
inputErrorMessage: '请输入打款备注',
@@ -202,19 +202,13 @@ function onDetailDialogSaved() {
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">
查询
</el-button>
<el-button type="primary" @click="handleFilter"> 查询 </el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 提现列表 -->
<el-table
v-loading="loading"
class="table-panel"
:data="withdrawals"
>
<el-table v-loading="loading" class="table-panel" :data="withdrawals">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" />
<el-table-column label="用户" min-width="140">
@@ -225,9 +219,7 @@ function onDetailDialogSaved() {
</el-table-column>
<el-table-column label="提现金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600">
¥{{ row.amount.toFixed(2) }}
</span>
<span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span>
</template>
</el-table-column>
<el-table-column label="收款方式" min-width="180">
@@ -257,9 +249,7 @@ function onDetailDialogSaved() {
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">
详情
</el-button>
<el-button size="small" @click="openDetail(row)"> 详情 </el-button>
<el-button
v-if="row.status === 'pending'"
size="small"
@@ -29,8 +29,13 @@ export interface PaginatedResult<T> {
page_size: number
}
export async function fetchAnnouncements(params: AnnouncementListParams = {}): Promise<PaginatedResult<Announcement>> {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>('/announcements', { params })
export async function fetchAnnouncements(
params: AnnouncementListParams = {}
): Promise<PaginatedResult<Announcement>> {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>(
'/announcements',
{ params }
)
return data.data
}
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bell, Calendar, Document, QuestionFilled, View, Warning } from '@element-plus/icons-vue'
import {
ArrowLeft,
Bell,
Calendar,
Document,
QuestionFilled,
View,
Warning,
} from '@element-plus/icons-vue'
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
import { formatDateTime } from '@/utils/time'
import { marked } from 'marked'
@@ -82,14 +90,20 @@ function getCategoryLabel(category: string) {
class="category-tag"
:style="{
borderColor: getCategoryInfo(announcement.category)?.color || '#94a3b8',
color: getCategoryInfo(announcement.category)?.color || '#94a3b8'
color: getCategoryInfo(announcement.category)?.color || '#94a3b8',
}"
>
<el-icon><component :is="getCategoryInfo(announcement.category)?.icon || Bell" /></el-icon>
<el-icon
><component :is="getCategoryInfo(announcement.category)?.icon || Bell"
/></el-icon>
{{ getCategoryLabel(announcement.category) }}
</el-tag>
<el-tag v-if="announcement.is_pinned" type="warning" effect="plain" size="small">置顶</el-tag>
<el-tag v-if="announcement.is_important" type="danger" effect="plain" size="small">重要</el-tag>
<el-tag v-if="announcement.is_pinned" type="warning" effect="plain" size="small"
>置顶</el-tag
>
<el-tag v-if="announcement.is_important" type="danger" effect="plain" size="small"
>重要</el-tag
>
</div>
<h1 class="detail-title">{{ announcement.title }}</h1>
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Bell, Document, InfoFilled, QuestionFilled, Tickets, Warning } from '@element-plus/icons-vue'
import {
Bell,
Document,
InfoFilled,
QuestionFilled,
Tickets,
Warning,
} from '@element-plus/icons-vue'
import { fetchAnnouncements, type Announcement } from '@/features/announcement'
import { formatDateTime } from '@/utils/time'
@@ -114,7 +121,10 @@ function getCategoryLabel(category: string) {
effect="plain"
size="small"
class="category-tag"
:style="{ borderColor: getCategoryInfo(item.category)?.color || '#94a3b8', color: getCategoryInfo(item.category)?.color || '#94a3b8' }"
:style="{
borderColor: getCategoryInfo(item.category)?.color || '#94a3b8',
color: getCategoryInfo(item.category)?.color || '#94a3b8',
}"
>
<el-icon><component :is="getCategoryInfo(item.category)?.icon || Bell" /></el-icon>
{{ getCategoryLabel(item.category) }}
@@ -122,10 +132,14 @@ function getCategoryLabel(category: string) {
<el-tag v-if="item.is_pinned" type="warning" effect="plain" size="small">置顶</el-tag>
<el-tag v-if="item.is_important" type="danger" effect="plain" size="small">重要</el-tag>
</div>
<span class="announcement-date">{{ formatDateTime(item.published_at || item.created_at) }}</span>
<span class="announcement-date">{{
formatDateTime(item.published_at || item.created_at)
}}</span>
</div>
<h3 class="announcement-title">{{ item.title }}</h3>
<p class="announcement-preview">{{ item.content.substring(0, 120) }}{{ item.content.length > 120 ? '...' : '' }}</p>
<p class="announcement-preview">
{{ item.content.substring(0, 120) }}{{ item.content.length > 120 ? '...' : '' }}
</p>
<div class="announcement-footer">
<span class="view-count">
<el-icon><Tickets /></el-icon>
@@ -159,9 +173,7 @@ function getCategoryLabel(category: string) {
padding: 32px;
border: 1px solid #e6eaf2;
border-radius: 12px;
background:
linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(99, 102, 241, 0.06)),
#ffffff;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(99, 102, 241, 0.06)), #ffffff;
box-shadow: 0 14px 36px rgba(17, 24, 39, 0.06);
}
+5 -2
View File
@@ -26,9 +26,12 @@ export interface LoginData {
}
export async function sendSmsCode(phone: string) {
const { data } = await apiClient.post<ApiResponse<{ phone: string; expires_in: number }>>('/auth/sms/send', {
const { data } = await apiClient.post<ApiResponse<{ phone: string; expires_in: number }>>(
'/auth/sms/send',
{
phone,
})
}
)
return data.data
}
@@ -15,9 +15,12 @@ export interface NotificationItem {
}
export async function fetchNotifications(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>('/notifications', {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>(
'/notifications',
{
params: { page, page_size: pageSize },
})
}
)
return data.data
}
+58 -64
View File
@@ -1,79 +1,78 @@
<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 { 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 "@/features/auth/api/auth";
import { useSessionStore } from "@/stores/session";
import { sendSmsCode } from '@/features/auth/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 router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const sending = ref(false)
const countDown = ref(0)
const form = reactive({
phone: "",
code: "",
});
phone: '',
code: '',
})
let timer: ReturnType<typeof setInterval> | null = null;
let timer: ReturnType<typeof setInterval> | null = null
function startCountDown() {
countDown.value = 60;
countDown.value = 60
timer = setInterval(() => {
countDown.value--;
countDown.value--
if (countDown.value <= 0) {
clearInterval(timer!);
timer = null;
clearInterval(timer!)
timer = null
}
}, 1000);
}, 1000)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
clearInterval(timer)
timer = null
}
});
})
async function handleSendCode() {
if (!form.phone.trim()) {
ElMessage.warning("请输入手机号");
return;
ElMessage.warning('请输入手机号')
return
}
sending.value = true;
sending.value = true
try {
await sendSmsCode(form.phone);
ElMessage.success("验证码已发送,请注意查收");
startCountDown();
await sendSmsCode(form.phone)
ElMessage.success('验证码已发送,请注意查收')
startCountDown()
} catch (error) {
ElMessage.error(readError(error, "验证码发送失败"));
ElMessage.error(readError(error, '验证码发送失败'))
} finally {
sending.value = false;
sending.value = false
}
}
async function handleLogin() {
loading.value = true;
loading.value = true
try {
await session.login(form.phone, form.code);
ElMessage.success("登录成功");
await router.push("/");
await session.login(form.phone, form.code)
ElMessage.success('登录成功')
await router.push('/')
} catch (error) {
ElMessage.error(readError(error, "登录失败"));
ElMessage.error(readError(error, '登录失败'))
} finally {
loading.value = false;
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;
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 fallback
}
</script>
@@ -131,13 +130,7 @@ function readError(error: unknown, fallback: string) {
:loading="sending"
@click="handleSendCode"
>
{{
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
</el-button>
</div>
</el-form-item>
@@ -179,7 +172,8 @@ function readError(error: unknown, fallback: string) {
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),
box-shadow:
0 24px 80px rgba(17, 24, 39, 0.08),
0 1px 0 rgba(255, 255, 255, 0.6) inset;
overflow: hidden;
}
@@ -190,16 +184,9 @@ function readError(error: unknown, fallback: string) {
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%
),
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));
}
@@ -298,14 +285,17 @@ function readError(error: unknown, fallback: string) {
box-shadow: 0 0 0 1px #e2e8f0 inset;
border-radius: 10px;
padding: 0 12px;
transition: box-shadow 0.2s, background 0.2s;
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,
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) {
@@ -337,7 +327,9 @@ function readError(error: unknown, fallback: string) {
border-color: #1477ff;
color: #1477ff;
box-shadow: none;
transition: background 0.2s, color 0.2s;
transition:
background 0.2s,
color 0.2s;
}
.user-code-row .el-button:hover:not(:disabled) {
background: #1477ff;
@@ -360,7 +352,9 @@ function readError(error: unknown, fallback: string) {
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;
transition:
transform 0.15s,
box-shadow 0.2s;
}
.user-login-btn:hover {
transform: translateY(-1px);
@@ -1,41 +1,41 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
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 "@/shared/composables/useSmsCountdown";
import { useSessionStore } from '@/stores/session'
import { useSmsCountdown } from '@/shared/composables/useSmsCountdown'
const router = useRouter();
const route = useRoute();
const session = useSessionStore();
const loading = ref(false);
const agreed = ref(false);
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const loading = ref(false)
const agreed = ref(false)
const form = reactive({
phone: "",
code: "",
});
phone: '',
code: '',
})
const { countDown, sending, handleSendCode, readError } = useSmsCountdown();
const { countDown, sending, handleSendCode, readError } = useSmsCountdown()
async function handleLogin() {
if (!agreed.value) {
showDialog({
title: "提示",
message: "请先阅读并同意用户协议和隐私政策",
});
return;
title: '提示',
message: '请先阅读并同意用户协议和隐私政策',
})
return
}
loading.value = true;
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);
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" });
showToast({ message: '登录失败,请检查手机号和验证码', icon: 'cross' })
} finally {
loading.value = false;
loading.value = false
}
}
</script>
@@ -58,12 +58,7 @@ async function handleLogin() {
<h1>登录</h1>
<label class="auth-input-row">
<input
v-model="form.phone"
type="tel"
maxlength="11"
placeholder="手机号"
/>
<input v-model="form.phone" type="tel" maxlength="11" placeholder="手机号" />
</label>
<label class="auth-input-row code-row">
@@ -83,13 +78,7 @@ async function handleLogin() {
class="code-btn"
@click="handleSendCode(form.phone)"
>
{{
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
</van-button>
</span>
</label>
@@ -116,9 +105,7 @@ async function handleLogin() {
登录
</van-button>
<RouterLink to="/m/register" class="secondary-entry">
还没有账号创建一个
</RouterLink>
<RouterLink to="/m/register" class="secondary-entry"> 还没有账号创建一个 </RouterLink>
</div>
</section>
</main>
@@ -1,256 +1,256 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useSessionStore } from "@/stores/session";
import { showDialog, showToast } from "vant";
import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { showDialog, showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchWalletBalance, fetchWalletLedger, type WalletLedger } from "@/features/wallet/api/wallet";
import { fetchPostRentalNotice, type PostRentalNotice } from "@/features/orders/api/orders";
import { formatDateMinute } from "@/utils/time";
import { uploadFile } from "@/shared/api/files";
import {
fetchWalletBalance,
fetchWalletLedger,
type WalletLedger,
} from '@/features/wallet/api/wallet'
import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders'
import { formatDateMinute } from '@/utils/time'
import { uploadFile } from '@/shared/api/files'
const session = useSessionStore();
const router = useRouter();
const route = useRoute();
const session = useSessionStore()
const router = useRouter()
const route = useRoute()
/** 数据项 */
const balance = ref(0);
const showSettings = ref(false);
const showProfileEditor = ref(false);
const savingProfile = ref(false);
const balance = ref(0)
const showSettings = ref(false)
const showProfileEditor = ref(false)
const savingProfile = ref(false)
const profileForm = reactive({
nickname: "",
avatar_url: "",
});
nickname: '',
avatar_url: '',
})
const avatarFileInput = ref<HTMLInputElement | null>(null);
const uploadingAvatar = ref(false);
const avatarFileInput = ref<HTMLInputElement | null>(null)
const uploadingAvatar = ref(false)
function triggerAvatarUpload() {
avatarFileInput.value?.click();
avatarFileInput.value?.click()
}
async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingAvatar.value = true;
uploadingAvatar.value = true
const toast = showToast({
type: "loading",
message: "上传中...",
type: 'loading',
message: '上传中...',
forbidClick: true,
duration: 0,
});
})
try {
const uploaded = await uploadFile(file, "avatar");
profileForm.avatar_url = uploaded.url;
showToast({ message: "上传成功", icon: "passed" });
const uploaded = await uploadFile(file, 'avatar')
profileForm.avatar_url = uploaded.url
showToast({ message: '上传成功', icon: 'passed' })
} catch (error) {
showToast({ message: readError(error, "上传失败"), icon: "cross" });
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
} finally {
uploadingAvatar.value = false;
uploadingAvatar.value = false
}
}
// 收支明细列表
const showLedgers = ref(false);
const loadingLedgers = ref(false);
const ledgers = ref<WalletLedger[]>([]);
const showLedgers = ref(false)
const loadingLedgers = ref(false)
const ledgers = ref<WalletLedger[]>([])
// 租后须知
const showPostRentalNotice = ref(false);
const loadingPostRentalNotice = ref(false);
const postRentalNotice = ref<PostRentalNotice | null>(null);
const showPostRentalNotice = ref(false)
const loadingPostRentalNotice = ref(false)
const postRentalNotice = ref<PostRentalNotice | null>(null)
const settingsGroups = [
{
title: "账号与安全",
title: '账号与安全',
items: [
{ label: "资料更改", icon: "edit", action: "profile" },
{ label: "实名认证", icon: "idcard", action: "realname" },
{ label: "注销账号", icon: "delete-o", action: "cancel-account" },
{ label: '资料更改', icon: 'edit', action: 'profile' },
{ label: '实名认证', icon: 'idcard', action: 'realname' },
{ label: '注销账号', icon: 'delete-o', action: 'cancel-account' },
],
},
{
title: "法律与隐私",
title: '法律与隐私',
items: [
{ label: "隐私政策", icon: "shield-o", action: "privacy" },
{ label: "用户协议", icon: "description", action: "terms" },
{ label: '隐私政策', icon: 'shield-o', action: 'privacy' },
{ label: '用户协议', icon: 'description', action: 'terms' },
],
},
];
]
onMounted(() => {
if (!isLoggedIn.value) {
router.replace({ path: "/m/login", query: { redirect: route.fullPath } });
router.replace({ path: '/m/login', query: { redirect: route.fullPath } })
} else {
loadBalance();
loadBalance()
}
});
})
async function loadBalance() {
try {
const wallet = await fetchWalletBalance();
balance.value = wallet.available_balance;
const wallet = await fetchWalletBalance()
balance.value = wallet.available_balance
} catch {
// 失败静默处理,显示为默认0
}
}
async function openLedgers() {
showLedgers.value = true;
loadingLedgers.value = true;
showLedgers.value = true
loadingLedgers.value = true
try {
const res = await fetchWalletLedger(1, 20);
ledgers.value = res.items;
const res = await fetchWalletLedger(1, 20)
ledgers.value = res.items
} catch {
showToast({ message: "无法获取账单明细", icon: "cross" });
showToast({ message: '无法获取账单明细', icon: 'cross' })
} finally {
loadingLedgers.value = false;
loadingLedgers.value = false
}
}
async function openPostRentalNotice() {
showPostRentalNotice.value = true;
showPostRentalNotice.value = true
if (!postRentalNotice.value) {
loadingPostRentalNotice.value = true;
loadingPostRentalNotice.value = true
try {
postRentalNotice.value = await fetchPostRentalNotice();
postRentalNotice.value = await fetchPostRentalNotice()
} catch {
showToast({ message: "无法获取租后须知", icon: "cross" });
showToast({ message: '无法获取租后须知', icon: 'cross' })
} finally {
loadingPostRentalNotice.value = false;
loadingPostRentalNotice.value = false
}
}
}
function handleWithdraw() {
showDialog({
title: "提现提示",
message: "为了您的资金安全,提现请前往电脑端网页版进行操作。",
});
title: '提现提示',
message: '为了您的资金安全,提现请前往电脑端网页版进行操作。',
})
}
function goOrders(tabKey: string) {
router.push({ path: "/m/orders", query: { tab: tabKey } });
router.push({ path: '/m/orders', query: { tab: tabKey } })
}
function onSettingClick(action: string) {
showSettings.value = false;
showSettings.value = false
switch (action) {
case "profile":
openProfileEditor();
break;
case "realname":
router.push("/m/realname");
break;
case "cancel-account":
case 'profile':
openProfileEditor()
break
case 'realname':
router.push('/m/realname')
break
case 'cancel-account':
showDialog({
title: "注销账号",
message: "注销后账号数据将无法恢复,确认注销吗?",
title: '注销账号',
message: '注销后账号数据将无法恢复,确认注销吗?',
showCancelButton: true,
confirmButtonText: "确认注销",
confirmButtonColor: "#ee0a24",
cancelButtonText: "再想想",
confirmButtonText: '确认注销',
confirmButtonColor: '#ee0a24',
cancelButtonText: '再想想',
})
.then(() => {
showToast({ message: "注销功能开发中", icon: "info-o" });
showToast({ message: '注销功能开发中', icon: 'info-o' })
})
.catch(() => {});
break;
case "privacy":
showToast({ message: "隐私政策页面开发中", icon: "info-o" });
break;
case "terms":
showToast({ message: "用户协议页面开发中", icon: "info-o" });
break;
.catch(() => {})
break
case 'privacy':
showToast({ message: '隐私政策页面开发中', icon: 'info-o' })
break
case 'terms':
showToast({ message: '用户协议页面开发中', icon: 'info-o' })
break
}
}
function openProfileEditor() {
profileForm.nickname = session.nickname || defaultName.value;
profileForm.avatar_url = session.avatarUrl || "";
showProfileEditor.value = true;
profileForm.nickname = session.nickname || defaultName.value
profileForm.avatar_url = session.avatarUrl || ''
showProfileEditor.value = true
}
async function saveProfile() {
const nickname = profileForm.nickname.trim();
const avatarURL = profileForm.avatar_url.trim();
const nickname = profileForm.nickname.trim()
const avatarURL = profileForm.avatar_url.trim()
if (!nickname) {
showToast({ message: "请输入昵称", icon: "warning-o" });
return;
showToast({ message: '请输入昵称', icon: 'warning-o' })
return
}
if (nickname.length > 24) {
showToast({ message: "昵称不能超过 24 个字符", icon: "warning-o" });
return;
showToast({ message: '昵称不能超过 24 个字符', icon: 'warning-o' })
return
}
savingProfile.value = true;
savingProfile.value = true
try {
await session.updateProfile({ nickname, avatar_url: avatarURL });
showProfileEditor.value = false;
showToast({ message: "资料已更新", icon: "passed" });
await loadBalance();
await session.updateProfile({ nickname, avatar_url: avatarURL })
showProfileEditor.value = false
showToast({ message: '资料已更新', icon: 'passed' })
await loadBalance()
} catch (error) {
showToast({ message: readError(error, "资料更新失败"), icon: "cross" });
showToast({ message: readError(error, '资料更新失败'), icon: 'cross' })
} finally {
savingProfile.value = false;
savingProfile.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;
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 fallback
}
function confirmLogout() {
showSettings.value = false;
showSettings.value = false
showDialog({
title: "退出登录",
message: "确定要退出当前账号吗?",
title: '退出登录',
message: '确定要退出当前账号吗?',
showCancelButton: true,
confirmButtonText: "退出",
confirmButtonColor: "#ee0a24",
cancelButtonText: "取消",
confirmButtonText: '退出',
confirmButtonColor: '#ee0a24',
cancelButtonText: '取消',
})
.then(() => {
session.logout();
router.replace("/m");
session.logout()
router.replace('/m')
})
.catch(() => {});
.catch(() => {})
}
const isLoggedIn = computed(() => Boolean(session.token));
const isRealnameVerified = computed(() => session.realnameStatus === "verified");
const isRealnamePending = computed(() => session.realnameStatus === "pending");
const isLoggedIn = computed(() => Boolean(session.token))
const isRealnameVerified = computed(() => session.realnameStatus === 'verified')
const isRealnamePending = computed(() => session.realnameStatus === 'pending')
const defaultName = computed(() =>
session.phone
? `用户${session.phone.slice(-6)}`
: `用户${session.userId || 883099}`
);
const displayName = computed(() => session.nickname || defaultName.value);
const displayId = computed(() => session.userId || 138865);
session.phone ? `用户${session.phone.slice(-6)}` : `用户${session.userId || 883099}`
)
const displayName = computed(() => session.nickname || defaultName.value)
const displayId = computed(() => session.userId || 138865)
const maskedPhone = computed(() =>
session.phone
? `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}`
: "登录后查看手机号"
);
const avatarText = computed(() => displayName.value.slice(0, 1));
session.phone ? `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}` : '登录后查看手机号'
)
const avatarText = computed(() => displayName.value.slice(0, 1))
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");
if (!url) return ''
if (url.includes('/api/files/object?key=avatar/')) {
return url.replace('/api/files/object', '/api/public/files/object')
}
return url;
return url
}
</script>
@@ -276,13 +276,32 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="hero-user">
<div class="name-row">
<h1 class="user-nickname">{{ displayName }}</h1>
<van-tag v-if="isRealnameVerified" type="success" size="medium" round class="verified-tag">
<van-tag
v-if="isRealnameVerified"
type="success"
size="medium"
round
class="verified-tag"
>
<van-icon name="shield" /> 已实名
</van-tag>
<van-tag v-else-if="isRealnamePending" type="warning" size="medium" round class="verified-tag">
<van-tag
v-else-if="isRealnamePending"
type="warning"
size="medium"
round
class="verified-tag"
>
审核中
</van-tag>
<van-tag v-else type="danger" size="medium" round class="unverified-tag" @click="router.push('/m/realname')">
<van-tag
v-else
type="danger"
size="medium"
round
class="unverified-tag"
@click="router.push('/m/realname')"
>
未实名
</van-tag>
</div>
@@ -416,7 +435,9 @@ function resolveAvatarURL(url: string | undefined | null) {
<button class="popup-close" @click="showPostRentalNotice = false"></button>
</header>
<div class="popup-body">
<van-loading v-if="loadingPostRentalNotice" class="center-loading" vertical>加载中...</van-loading>
<van-loading v-if="loadingPostRentalNotice" class="center-loading" vertical
>加载中...</van-loading
>
<div v-else-if="postRentalNotice" class="notice-content">
{{ postRentalNotice.content }}
</div>
@@ -438,11 +459,7 @@ function resolveAvatarURL(url: string | undefined | null) {
</button>
</header>
<div
v-for="group in settingsGroups"
:key="group.title"
class="settings-group"
>
<div v-for="group in settingsGroups" :key="group.title" class="settings-group">
<h3 class="settings-group-title">{{ group.title }}</h3>
<van-cell-group :border="false">
<van-cell
@@ -457,9 +474,7 @@ function resolveAvatarURL(url: string | undefined | null) {
</div>
<div class="settings-logout">
<van-button plain type="danger" block round @click="confirmLogout">
退出登录
</van-button>
<van-button plain type="danger" block round @click="confirmLogout"> 退出登录 </van-button>
</div>
</div>
</van-popup>
@@ -481,7 +496,11 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="profile-preview">
<div class="profile-preview-avatar clickable" @click="triggerAvatarUpload">
<img v-if="profileForm.avatar_url" :src="resolveAvatarURL(profileForm.avatar_url)" alt="" />
<img
v-if="profileForm.avatar_url"
:src="resolveAvatarURL(profileForm.avatar_url)"
alt=""
/>
<span v-else>{{ (profileForm.nickname || defaultName).slice(0, 1) }}</span>
<div class="avatar-upload-overlay">
<van-icon name="photograph" :size="16" />
@@ -516,11 +535,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<label class="editor-field">
<span>昵称</span>
<input
v-model="profileForm.nickname"
maxlength="24"
placeholder="请输入昵称"
/>
<input v-model="profileForm.nickname" maxlength="24" placeholder="请输入昵称" />
</label>
<label class="editor-field">
@@ -640,7 +655,8 @@ function resolveAvatarURL(url: string | undefined | null) {
color: #111827;
}
.verified-tag, .unverified-tag {
.verified-tag,
.unverified-tag {
font-size: 10px !important;
height: 18px;
padding: 0 6px;
@@ -660,7 +676,9 @@ function resolveAvatarURL(url: string | undefined | null) {
padding: 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.02),
0 1px 4px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(243, 244, 246, 0.9);
}
@@ -699,7 +717,9 @@ function resolveAvatarURL(url: string | undefined | null) {
padding: 0 16px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
transition:
background 0.15s,
color 0.15s;
}
.withdraw-btn:active {
@@ -732,7 +752,9 @@ function resolveAvatarURL(url: string | undefined | null) {
background: #ffffff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.02),
0 1px 4px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(243, 244, 246, 0.9);
}
@@ -801,13 +823,34 @@ function resolveAvatarURL(url: string | undefined | null) {
}
/* Vibrant pastel colors for menu icon wraps */
.icon-wrap.warning { color: #f59e0b; background: rgba(245, 158, 11, 0.08); }
.icon-wrap.info { color: #2563eb; background: rgba(37, 99, 235, 0.08); }
.icon-wrap.primary { color: #1477ff; background: rgba(20, 119, 255, 0.08); }
.icon-wrap.success { color: #10b981; background: rgba(16, 185, 129, 0.08); }
.icon-wrap.orange { color: #ff5f00; background: rgba(255, 95, 0, 0.08); }
.icon-wrap.purple { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); }
.icon-wrap.teal { color: #0d9488; background: rgba(13, 148, 136, 0.08); }
.icon-wrap.warning {
color: #f59e0b;
background: rgba(245, 158, 11, 0.08);
}
.icon-wrap.info {
color: #2563eb;
background: rgba(37, 99, 235, 0.08);
}
.icon-wrap.primary {
color: #1477ff;
background: rgba(20, 119, 255, 0.08);
}
.icon-wrap.success {
color: #10b981;
background: rgba(16, 185, 129, 0.08);
}
.icon-wrap.orange {
color: #ff5f00;
background: rgba(255, 95, 0, 0.08);
}
.icon-wrap.purple {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
.icon-wrap.teal {
color: #0d9488;
background: rgba(13, 148, 136, 0.08);
}
.grid-item span {
font-size: 11px;
@@ -1,81 +1,76 @@
<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 "@/features/auth/api/realname";
import { realnameStatusLabel } from "@/utils/statusLabels";
import { useSessionStore } from "@/stores/session";
import { formatDateTime } from "@/utils/time";
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { getRealnameStatus, startRealname, type RealnameStatus } from '@/features/auth/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 session = useSessionStore()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const status = ref<RealnameStatus | null>(null)
const form = reactive({
name: "",
idNo: "",
});
name: '',
idNo: '',
})
onMounted(loadStatus);
onMounted(loadStatus)
function redirectAfterVerified() {
const redirect =
typeof route.query.redirect === "string" ? route.query.redirect : "";
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect) {
router.replace(redirect);
router.replace(redirect)
}
}
async function loadStatus() {
if (!session.token) return;
if (!session.token) return
try {
status.value = await getRealnameStatus();
if (status.value.status === "verified") {
await session.loadMe();
redirectAfterVerified();
status.value = await getRealnameStatus()
if (status.value.status === 'verified') {
await session.loadMe()
redirectAfterVerified()
}
} catch {
status.value = null;
status.value = null
}
}
async function handleSubmit() {
if (!form.name.trim()) {
showToast({ message: "请输入真实姓名", icon: "warning-o" });
return;
showToast({ message: '请输入真实姓名', icon: 'warning-o' })
return
}
if (!form.idNo.trim() || form.idNo.length < 15) {
showToast({ message: "请输入有效的证件号", icon: "warning-o" });
return;
showToast({ message: '请输入有效的证件号', icon: 'warning-o' })
return
}
loading.value = true;
loading.value = true
try {
status.value = await startRealname(form.name, form.idNo);
await session.loadMe();
status.value = await startRealname(form.name, form.idNo)
await session.loadMe()
showDialog({
title: "认证成功",
message: "实名认证已通过",
confirmButtonText: "好的",
title: '认证成功',
message: '实名认证已通过',
confirmButtonText: '好的',
}).then(() => {
redirectAfterVerified();
});
redirectAfterVerified()
})
} catch (error: unknown) {
const msg =
typeof error === "object" && error && "response" in error
typeof error === 'object' && error && 'response' in error
? (
error as {
response?: { data?: { message?: string } };
response?: { data?: { message?: string } }
}
).response?.data?.message || "实名认证失败"
: "实名认证失败";
showToast({ message: msg, icon: "cross" });
).response?.data?.message || '实名认证失败'
: '实名认证失败'
showToast({ message: msg, icon: 'cross' })
} finally {
loading.value = false;
loading.value = false
}
}
</script>
@@ -91,31 +86,19 @@ async function handleSubmit() {
</header>
<!-- 未登录提示 -->
<van-empty
v-if="!session.token"
description="请先登录后再实名认证"
image="search"
/>
<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
>
<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.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>
@@ -124,9 +107,7 @@ async function handleSubmit() {
<!-- 认证表单 -->
<section v-if="status?.status !== 'verified'" class="form-card">
<h2>提交认证</h2>
<p class="form-hint">
号主发布前必须完成实名认证提交合法姓名和身份证号即可认证
</p>
<p class="form-hint">号主发布前必须完成实名认证提交合法姓名和身份证号即可认证</p>
<van-field
v-model="form.name"
label="姓名"
@@ -1,42 +1,42 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { useSessionStore } from "@/stores/session";
import { useSmsCountdown } from "@/shared/composables/useSmsCountdown";
import { useSessionStore } from '@/stores/session'
import { useSmsCountdown } from '@/shared/composables/useSmsCountdown'
const router = useRouter();
const route = useRoute();
const session = useSessionStore();
const loading = ref(false);
const agreed = ref(false);
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const loading = ref(false)
const agreed = ref(false)
const form = reactive({
phone: "",
code: "",
inviteCode: "",
});
phone: '',
code: '',
inviteCode: '',
})
const { countDown, sending, handleSendCode, readError } = useSmsCountdown();
const { countDown, sending, handleSendCode, readError } = useSmsCountdown()
async function handleRegister() {
if (!agreed.value) {
showToast({
message: "请先阅读并同意用户协议和隐私政策",
icon: "warning-o",
});
return;
message: '请先阅读并同意用户协议和隐私政策',
icon: 'warning-o',
})
return
}
loading.value = true;
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);
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" });
showToast({ message: '注册失败,请检查手机号和验证码', icon: 'cross' })
} finally {
loading.value = false;
loading.value = false
}
}
</script>
@@ -85,23 +85,13 @@ async function handleRegister() {
class="code-btn"
@click="handleSendCode(form.phone)"
>
{{
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
{{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
</van-button>
</span>
</label>
<label class="auth-input-row">
<input
v-model="form.inviteCode"
maxlength="16"
placeholder="邀请码(选填)"
/>
<input v-model="form.inviteCode" maxlength="16" placeholder="邀请码(选填)" />
</label>
<div class="assist-row">
@@ -126,9 +116,7 @@ async function handleRegister() {
注册并登录
</van-button>
<RouterLink to="/m/login" class="secondary-entry">
返回登录
</RouterLink>
<RouterLink to="/m/login" class="secondary-entry"> 返回登录 </RouterLink>
</div>
</section>
</main>
@@ -2,7 +2,11 @@
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { fetchNotifications, markNotificationRead, type NotificationItem } from '@/features/auth/api/notifications'
import {
fetchNotifications,
markNotificationRead,
type NotificationItem,
} from '@/features/auth/api/notifications'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
@@ -46,7 +50,12 @@ async function markRead(id: number) {
<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
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>
@@ -54,10 +63,15 @@ async function markRead(id: number) {
<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}`">
<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>
<el-button v-if="!item.read_at" size="small" type="primary" @click="markRead(item.id)"
>已读</el-button
>
</div>
</div>
</div>
@@ -35,10 +35,30 @@ const form = reactive({
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' } } },
{
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' },
@@ -146,8 +166,15 @@ function readError(error: unknown, fallback: string) {
<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>
<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>
@@ -168,19 +195,35 @@ function readError(error: unknown, fallback: string) {
<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>
<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" />
<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-input
v-model="form.nickname"
:prefix-icon="User"
maxlength="24"
placeholder="请输入昵称"
size="large"
/>
</el-form-item>
<el-form-item label="头像地址">
<el-input
@@ -193,7 +236,14 @@ function readError(error: unknown, fallback: string) {
</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>
<el-button
type="primary"
size="large"
:icon="EditPen"
:loading="saving"
@click="saveProfile"
>保存资料</el-button
>
</div>
</el-form>
</section>
@@ -231,7 +281,12 @@ function readError(error: unknown, fallback: string) {
</RouterLink>
</div>
<div class="service-grid buyer-grid">
<RouterLink v-for="item in buyerServices" :key="item.label" class="service-item" :to="item.to">
<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>
@@ -249,7 +304,12 @@ function readError(error: unknown, fallback: string) {
</RouterLink>
</div>
<div class="service-grid seller-grid">
<RouterLink v-for="item in sellerServices" :key="item.label" class="service-item" :to="item.to">
<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>
@@ -275,9 +335,7 @@ function readError(error: unknown, fallback: string) {
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;
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);
}
@@ -486,7 +544,9 @@ function readError(error: unknown, fallback: string) {
}
.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);
box-shadow:
0 0 0 1px #ff6a00 inset,
0 0 0 4px rgba(255, 106, 0, 0.08);
}
.form-actions {
@@ -150,7 +150,13 @@ function readError(error: unknown, fallback: string) {
</div>
<div>
<h2>{{ currentStatus === 'verified' ? '实名信息' : '提交认证' }}</h2>
<p>{{ currentStatus === 'verified' ? '实名信息已完成脱敏展示。' : '请使用本人真实身份信息完成核验。' }}</p>
<p>
{{
currentStatus === 'verified'
? '实名信息已完成脱敏展示。'
: '请使用本人真实身份信息完成核验。'
}}
</p>
</div>
</div>
@@ -160,13 +166,20 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="verified-copy">
<strong>认证通过</strong>
<span>{{ status?.verified_at ? formatDateTime(status.verified_at) : '已完成实名核验' }}</span>
<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-input
v-model="form.name"
:prefix-icon="User"
placeholder="请输入真实姓名"
size="large"
/>
</el-form-item>
<el-form-item label="身份证号">
<el-input
@@ -248,7 +261,12 @@ function readError(error: unknown, fallback: string) {
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%),
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);
}
@@ -420,7 +438,9 @@ function readError(error: unknown, fallback: string) {
}
.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);
box-shadow:
0 0 0 1px #ff6a00 inset,
0 0 0 4px rgba(255, 106, 0, 0.08);
}
.verify-form .el-button {
+53 -16
View File
@@ -68,9 +68,12 @@ export async function ensureSupportChat() {
}
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(
`/chats/${id}/messages`,
{
params: { page, page_size: pageSize },
})
}
)
return data.data
}
@@ -88,9 +91,12 @@ export async function markChatRead(id: number) {
}
export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>(
'/admin/chats',
{
params: { page, page_size: pageSize, filter },
})
}
)
return data.data
}
@@ -100,13 +106,20 @@ export async function fetchAdminChat(id: number) {
}
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/admin/chats/${id}/messages`, {
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[] = []) {
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,
@@ -132,14 +145,20 @@ export async function fetchSupportAdmins() {
}
export async function transferChat(id: number, toAdminId: number) {
const { data } = await apiClient.post<ApiResponse<{ transferred: boolean }>>(`/admin/chats/${id}/transfer`, {
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 })
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
`/admin/chats/${id}/remark`,
{ remark }
)
return data.data
}
@@ -157,7 +176,12 @@ export async function fetchQuickReplies() {
return data.data
}
export async function createQuickReply(title: string, content: string, sortOrder = 0, isGlobal = false) {
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,
@@ -167,22 +191,35 @@ export async function createQuickReply(title: string, content: string, sortOrder
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)
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}`)
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')
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 })
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
'/admin/chats/auto-welcome',
{ message }
)
return data.data
}
@@ -34,9 +34,8 @@ async function loadImage() {
}
try {
const key = extractObjectKey(props.source)
const blob = props.admin && key
? await fetchAdminFileBlob(key)
: await fetchFileBlobByURL(props.source)
const blob =
props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(props.source)
objectURL.value = URL.createObjectURL(blob)
} catch {
failed.value = true
@@ -55,7 +54,7 @@ onBeforeUnmount(revokeCurrentURL)
<template>
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async">
<img :src="objectURL" alt="聊天图片" loading="lazy" decoding="async" />
</button>
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</template>
@@ -49,18 +49,22 @@ export function useChatSSE(scope: AuthScope, endpoint: string) {
connected.value = true
})
source.addEventListener('new_message', (e) => {
source.addEventListener('new_message', e => {
try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data))
} catch { /* ignore */ }
} catch {
/* ignore */
}
})
source.addEventListener('conversation_updated', (e) => {
source.addEventListener('conversation_updated', e => {
try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data))
} catch { /* ignore */ }
} catch {
/* ignore */
}
})
source.onerror = async () => {
@@ -68,10 +68,15 @@ function roleLabel(role: string) {
}
function previewText(item: ChatConversation) {
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
return (
item.last_message_preview ||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
)
}
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
const unreadTotal = computed(() =>
conversations.value.reduce((sum, item) => sum + item.unread_count, 0)
)
</script>
<template>
@@ -113,7 +118,9 @@ const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum
<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>
<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>
+12 -3
View File
@@ -22,7 +22,10 @@ export interface Dispute {
updated_at: string
}
export async function createDispute(orderId: number, payload: { type: string; description: string; evidence_urls?: 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
}
@@ -41,7 +44,13 @@ export async function fetchAdminDisputes(page = 1, pageSize = 20) {
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)
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,9 +1,6 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
import {
mergeListingPublishOptions,
type ListingPublishOptions,
} from './listingOptions'
import { mergeListingPublishOptions, type ListingPublishOptions } from './listingOptions'
export interface HomeBannerSlide {
eyebrow: string
@@ -61,11 +58,11 @@ export async function fetchMobileHomeConfig() {
export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeConfig {
const announcements =
config?.announcements
?.map((item) => (typeof item === 'string' ? item.trim() : ''))
?.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean) || []
const banners =
config?.banners
?.map((item) => {
?.map(item => {
const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>)
return {
eyebrow: banner.eyebrow?.trim() || '',
@@ -76,7 +73,7 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
image_url: banner.image_url?.trim() || '',
}
})
.filter((item) => item.title || item.image_url) || []
.filter(item => item.title || item.image_url) || []
return {
announcements: announcements.length ? announcements : defaultHomeAnnouncements,
@@ -182,21 +182,29 @@ export const emptyListingPublishAgreements: ListingPublishAgreements = {
}
export async function fetchListingPublishOptions() {
const { data } = await apiClient.get<ApiResponse<ListingPublishOptions>>('/listing-publish-options')
const { data } = await apiClient.get<ApiResponse<ListingPublishOptions>>(
'/listing-publish-options'
)
return mergeListingPublishOptions(data.data)
}
export async function fetchListingPublishAgreements() {
const { data } = await apiClient.get<ApiResponse<ListingPublishAgreements>>('/listing-publish-agreements')
const { data } = await apiClient.get<ApiResponse<ListingPublishAgreements>>(
'/listing-publish-agreements'
)
return mergeListingPublishAgreements(data.data)
}
export async function fetchListingSalePriceConfig() {
const { data } = await apiClient.get<ApiResponse<PublishSalePriceConfig>>('/listing-sale-price-config')
const { data } = await apiClient.get<ApiResponse<PublishSalePriceConfig>>(
'/listing-sale-price-config'
)
return mergeListingSalePriceConfig(data.data)
}
export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions {
export function mergeListingPublishOptions(
options?: Partial<ListingPublishOptions>
): ListingPublishOptions {
return {
server_options: normalizeStringList(options?.server_options),
face_options: normalizeStringList(options?.face_options),
@@ -217,34 +225,46 @@ export function mergeListingPublishOptions(options?: Partial<ListingPublishOptio
}
}
export function mergeListingSalePriceConfig(options?: Partial<PublishSalePriceConfig>): PublishSalePriceConfig {
export function mergeListingSalePriceConfig(
options?: Partial<PublishSalePriceConfig>
): PublishSalePriceConfig {
return normalizeSalePriceConfig(options)
}
export function mergeListingPublishAgreements(options?: Partial<ListingPublishAgreements>): ListingPublishAgreements {
export function mergeListingPublishAgreements(
options?: Partial<ListingPublishAgreements>
): ListingPublishAgreements {
return {
virtual_asset_sale: normalizeAgreementContent(options?.virtual_asset_sale, emptyListingPublishAgreements.virtual_asset_sale),
seller_agreement: normalizeAgreementContent(options?.seller_agreement, emptyListingPublishAgreements.seller_agreement),
virtual_asset_sale: normalizeAgreementContent(
options?.virtual_asset_sale,
emptyListingPublishAgreements.virtual_asset_sale
),
seller_agreement: normalizeAgreementContent(
options?.seller_agreement,
emptyListingPublishAgreements.seller_agreement
),
}
}
function normalizeAgreementContent(value: unknown, fallback: AgreementContent): AgreementContent {
const row = isRecord(value) ? value : {}
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title
const content = typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content
const title =
typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title
const content =
typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content
return { title, content }
}
function normalizeStringList(values?: unknown[]) {
return Array.isArray(values)
? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
? values.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
: []
}
function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
if (!Array.isArray(values)) return []
return values
.map((item) => {
.map(item => {
const group = isRecord(item) ? item : {}
return {
key: typeof group.key === 'string' ? group.key.trim() : '',
@@ -252,13 +272,13 @@ function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
options: normalizeStringList(Array.isArray(group.options) ? group.options : []),
}
})
.filter((item) => item.key && item.title)
.filter(item => item.key && item.title)
}
function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
if (!Array.isArray(values)) return []
return values
.map((item) => {
.map(item => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
@@ -267,13 +287,13 @@ function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined,
}
})
.filter((item) => item.key && item.label)
.filter(item => item.key && item.label)
}
function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
if (!Array.isArray(values)) return []
return values
.map((item) => {
.map(item => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
@@ -282,19 +302,22 @@ function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
hint: typeof row.hint === 'string' ? row.hint.trim() : '',
}
})
.filter((item) => item.key && item.label)
.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() : '',
deposit_placeholder:
typeof row.deposit_placeholder === 'string' ? row.deposit_placeholder.trim() : '',
deposit_hint:
typeof row.deposit_hint === 'string' && row.deposit_hint.trim()
? row.deposit_hint.trim()
: emptyListingPublishOptions.price_config.deposit_hint,
price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
price_placeholder:
typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '',
ratio_description:
typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '',
}
}
@@ -303,21 +326,23 @@ function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecomme
const config = {
base_amount: readNumber(row.base_amount),
skin_group_rules: normalizeDepositSkinGroupRules(
Array.isArray(row.skin_group_rules) ? row.skin_group_rules : [],
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]
config.skin_group_rules = [
...emptyListingPublishOptions.deposit_recommend_config.skin_group_rules,
]
}
return config
}
function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] {
return values
.map((item) => {
.map(item => {
const row = isRecord(item) ? item : {}
return {
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
@@ -325,20 +350,20 @@ function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGr
amount_per_item: readNumber(row.amount_per_item),
}
})
.filter((item) => item.group_key && item.label && item.amount_per_item >= 0)
.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 : [],
Array.isArray(row.insurance_base_ratios) ? row.insurance_base_ratios : []
),
config_items: normalizeRatioConfigItems(
Array.isArray(row.config_items) ? row.config_items : [],
Array.isArray(row.config_items) ? row.config_items : []
),
coin_corrections: normalizeCoinCorrections(
Array.isArray(row.coin_corrections) ? row.coin_corrections : [],
Array.isArray(row.coin_corrections) ? row.coin_corrections : []
),
}
}
@@ -347,10 +372,10 @@ 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 : [],
Array.isArray(row.fixed_markup_rules) ? row.fixed_markup_rules : []
),
ratio_adjustment_rules: normalizeSaleRatioAdjustmentRules(
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : [],
Array.isArray(row.ratio_adjustment_rules) ? row.ratio_adjustment_rules : []
),
}
if (config.fixed_markup_rules.length === 0 && config.ratio_adjustment_rules.length === 0) {
@@ -361,19 +386,19 @@ function normalizeSalePriceConfig(value?: unknown): PublishSalePriceConfig {
function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] {
return values
.map((item) => {
.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)
.filter(item => item.insurance && item.ratio > 0)
}
function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] {
return values
.map((item) => {
.map(item => {
const row = isRecord(item) ? item : {}
return {
key: typeof row.key === 'string' ? row.key.trim() : '',
@@ -383,24 +408,24 @@ function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[]
missing_penalty: readNumber(row.missing_penalty),
}
})
.filter((item) => item.key && item.label && item.kind)
.filter(item => item.key && item.label && item.kind)
}
function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] {
return values
.map((item) => {
.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)
.filter(item => item.threshold_m >= 0 && item.correction > 0)
}
function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] {
return values
.map((item) => {
.map(item => {
const row = isRecord(item) ? item : {}
return {
min_m: readNumber(row.min_m),
@@ -408,12 +433,12 @@ function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarku
markup_amount: readNumber(row.markup_amount),
}
})
.filter((item) => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0)
.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) => {
.map(item => {
const row = isRecord(item) ? item : {}
return {
min_m: readNumber(row.min_m),
@@ -422,10 +447,10 @@ function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioA
}
})
.filter(
(item) =>
item =>
item.min_m >= 0 &&
(item.max_m === 0 || item.max_m >= item.min_m) &&
item.ratio_subtract >= 0,
item.ratio_subtract >= 0
)
}
+41 -12
View File
@@ -91,19 +91,28 @@ export async function fetchListings(query: PublicListingQuery = {}) {
export async function fetchListingsPage(query: PublicListingQuery = {}) {
const params = Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined && value !== null)
Object.entries(query).filter(
([, value]) => value !== '' && value !== undefined && value !== null
)
const { data } = await apiClient.get<ApiResponse<Partial<PublicListingPage>>>('/listings', { params })
)
const { data } = await apiClient.get<ApiResponse<Partial<PublicListingPage>>>('/listings', {
params,
})
return normalizePublicListingPage(data.data, query)
}
function normalizePublicListingPage(data: Partial<PublicListingPage>, query: PublicListingQuery): PublicListingPage {
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),
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 : {},
}
}
@@ -155,18 +164,25 @@ export interface AdminListingPage {
}
export async function fetchAdminListings(query: AdminListingQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
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 {
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),
page_size: Number.isFinite(Number(data.page_size))
? Number(data.page_size)
: Number(query.page_size || items.length || 10),
}
}
@@ -176,12 +192,17 @@ export async function fetchAdminListing(id: string | number) {
}
export async function adminOfflineListing(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
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 })
const { data } = await apiClient.post<ApiResponse<Listing>>(
`/admin/listings/${id}/mark-abnormal`,
{ reason }
)
return data.data
}
@@ -196,12 +217,20 @@ export interface AdminListingPriceAdjustPayload {
reason?: string
}
export async function adjustListingReviewPrice(id: number, payload: AdminListingPriceAdjustPayload) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/adjust-price`, payload)
export async function adjustListingReviewPrice(
id: number,
payload: AdminListingPriceAdjustPayload
) {
const { data } = await apiClient.post<ApiResponse<Listing>>(
`/admin/listings/${id}/adjust-price`,
payload
)
return data.data
}
export async function rejectListing(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, {
reason,
})
return data.data
}
@@ -19,10 +19,7 @@ defineProps<Props>()
<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 }]"
>
<div class="hero-slide" :class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]">
<img
v-if="slide.image_url"
:src="slide.image_url"
@@ -95,7 +92,12 @@ defineProps<Props>()
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(15, 23, 42, 0.74) 0%, rgba(15, 23, 42, 0.24) 62%, transparent 100%);
background: linear-gradient(
90deg,
rgba(15, 23, 42, 0.74) 0%,
rgba(15, 23, 42, 0.24) 62%,
transparent 100%
);
}
.hero-copy {
@@ -28,9 +28,9 @@ const props = defineProps<Props>()
const emit = defineEmits<{
'update:filters': [filters: Partial<HomeFilters>]
'reset': []
'setFilterPopover': [key: FilterPopoverKey, visible: boolean]
'closeFilterPopover': []
reset: []
setFilterPopover: [key: FilterPopoverKey, visible: boolean]
closeFilterPopover: []
}>()
const filterPopoverBaseProps = {
@@ -67,9 +67,7 @@ function closePopover() {
<strong>筛选大厅</strong>
<span>{{ totalListings }} 个结果</span>
</div>
<el-button :icon="Refresh" link @click="emit('reset')">
重置全部条件
</el-button>
<el-button :icon="Refresh" link @click="emit('reset')"> 重置全部条件 </el-button>
</div>
<div class="filter-chip-row">
@@ -65,7 +65,11 @@ const coverURL = computed(() => props.listing.cover_url || props.listing.screens
</div>
<div class="stat-row">
<label>体力/负重</label>
<span>{{ readAssetString(listing, 'stamina_level') }}/{{ readAssetString(listing, 'load_level') }}</span>
<span
>{{ readAssetString(listing, 'stamina_level') }}/{{
readAssetString(listing, 'load_level')
}}</span
>
</div>
</div>
@@ -79,12 +83,16 @@ const coverURL = computed(() => props.listing.cover_url || props.listing.screens
<div class="stat-row">
<label>六级头甲</label>
<span>
{{ getResourceQuantity(listing, 'helmet6') }} / {{ getResourceQuantity(listing, 'armor6') }}
{{ getResourceQuantity(listing, 'helmet6') }} /
{{ getResourceQuantity(listing, 'armor6') }}
</span>
</div>
<div class="stat-row">
<label>其他重器</label>
<span>巴雷特 {{ getResourceQuantity(listing, 'barrett') }} / 喷子 {{ getResourceQuantity(listing, 'shotgun') }}</span>
<span
>巴雷特 {{ getResourceQuantity(listing, 'barrett') }} / 喷子
{{ getResourceQuantity(listing, 'shotgun') }}</span
>
</div>
</div>
@@ -29,12 +29,12 @@ const emit = defineEmits<{
const minValue = computed({
get: () => props.modelMin,
set: (val) => emit('update:modelMin', val),
set: val => emit('update:modelMin', val),
})
const maxValue = computed({
get: () => props.modelMax,
set: (val) => emit('update:modelMax', val),
set: val => emit('update:modelMax', val),
})
const isActive = computed(() => {
@@ -59,11 +59,7 @@ function isSelected(item: RangeOption) {
<template>
<el-popover v-bind="popoverProps" :width="350">
<template #reference>
<button
class="filter-chip"
:class="{ active: isActive, wide }"
type="button"
>
<button class="filter-chip" :class="{ active: isActive, wide }" type="button">
<span>{{ chipLabel }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
@@ -79,19 +75,9 @@ function isSelected(item: RangeOption) {
{{ item.label }}
</button>
<div class="range-manual">
<el-input-number
v-model="minValue"
:controls="false"
:min="0"
placeholder="最小值"
/>
<el-input-number v-model="minValue" :controls="false" :min="0" placeholder="最小值" />
<span>-</span>
<el-input-number
v-model="maxValue"
:controls="false"
:min="0"
placeholder="最大值"
/>
<el-input-number v-model="maxValue" :controls="false" :min="0" placeholder="最大值" />
</div>
</div>
</el-popover>
@@ -31,7 +31,7 @@ const isActive = computed(() => {
const chipLabel = computed(() => {
if (props.skinName) return props.skinName
if (props.skinGroup) {
return props.groups.find((g) => g.key === props.skinGroup)?.title || '皮肤'
return props.groups.find(g => g.key === props.skinGroup)?.title || '皮肤'
}
return '皮肤'
})
@@ -36,21 +36,13 @@ function selectOption(value: string) {
<template>
<el-popover v-bind="popoverProps" :width="220">
<template #reference>
<button
class="filter-chip"
:class="{ active: isActive, wide }"
type="button"
>
<button class="filter-chip" :class="{ active: isActive, wide }" type="button">
<span>{{ displayLabel }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
</template>
<div class="filter-menu">
<button
type="button"
:class="{ active: !modelValue }"
@click="selectOption('')"
>
<button type="button" :class="{ active: !modelValue }" @click="selectOption('')">
{{ placeholder }}
</button>
<button
@@ -36,7 +36,10 @@ describe('useHomeFilters', () => {
const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([])
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(publishOptions, listings)
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(
publishOptions,
listings
)
setStringFilter('region', '上海')
@@ -30,11 +30,7 @@ export const fireLevelRangeOptions = [
{ label: '70+', min: 70, max: undefined },
]
export function rangeLabel(
min: number | undefined,
max: number | undefined,
fallback: string
) {
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}`
@@ -17,13 +17,7 @@ export type FilterPopoverKey =
| 'fireLevel'
| 'loginMethod'
export type StringFilterKey =
| 'insurance'
| 'stamina'
| 'load'
| 'region'
| 'rank'
| 'loginMethod'
export type StringFilterKey = 'insurance' | 'stamina' | 'load' | 'region' | 'rank' | 'loginMethod'
export interface HomeFilters {
keyword: string
@@ -80,35 +74,33 @@ export function useHomeFilters(
const regionOptions = computed(() =>
uniqueOptions([
...publishOptions.value.region_options,
...listings.value.flatMap((item) => assetRegions(item)),
...listings.value.flatMap(item => assetRegions(item)),
])
)
const loginMethodOptions = computed(() =>
uniqueOptions(
publishOptions.value.login_method_options
.map((item) => item.trim())
.filter(Boolean)
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))
.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 skinFilterGroups.value.find(group => group.key === filters.skinGroup)?.title || '皮肤'
}
return '皮肤'
})
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))]
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
}
function resetFilters() {
@@ -102,12 +102,15 @@ export function useListingQuery(
// 使用防抖优化搜索
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => listingQuerySignature(), () => {
watch(
() => listingQuerySignature(),
() => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
loadListingsPage(true)
}, 300)
})
}
)
return {
loading,
@@ -35,7 +35,10 @@ const {
resetFilters,
setFilterPopover,
closeFilterPopover,
} = useHomeFilters(publishOptions, computed(() => listings.value))
} = useHomeFilters(
publishOptions,
computed(() => listings.value)
)
const {
loading,
@@ -104,10 +107,7 @@ const zoneOptions = computed(() => [
async function loadHome() {
loading.value = true
try {
const [, config] = await Promise.all([
loadListingsPage(true),
fetchMobileHomeConfig(),
])
const [, config] = await Promise.all([loadListingsPage(true), fetchMobileHomeConfig()])
announcements.value = config.announcements
banners.value = config.banners
publishOptions.value = config.publish_options
@@ -179,11 +179,7 @@ loadHome()
<button type="button" @click="handleResetFilters">重置条件</button>
</div>
<div v-else v-loading="loading" class="enhanced-desktop-list">
<ListingCard
v-for="item in listings"
:key="item.id"
:listing="item"
/>
<ListingCard v-for="item in listings" :key="item.id" :listing="item" />
</div>
<div v-if="!loading && listings.length" class="infinite-load-state">
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchListing, type Listing } from "@/features/listings/api/listings";
import { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders";
import { useSessionStore } from "@/stores/session";
import { roundMoney, formatMoney } from "@/shared/utils/money";
import { fetchListing, type Listing } from '@/features/listings/api/listings'
import {
createOrder,
fetchOrderAgreements,
type OrderAgreements,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { roundMoney, formatMoney } from '@/shared/utils/money'
import {
assetRegions,
formatEstimatedRentalDuration,
@@ -26,45 +30,45 @@ import {
getServerRegion,
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
} from '@/utils/listingDisplay'
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const ordering = ref(false);
const agreementsLoading = ref(false);
const agreementVisible = ref(false);
const agreements = ref<OrderAgreements | null>(null);
const virtualAgreementRead = ref(false);
const renterAgreementRead = ref(false);
const virtualAgreementChecked = ref(false);
const renterAgreementChecked = ref(false);
const virtualAgreementRef = ref<HTMLElement | null>(null);
const renterAgreementRef = ref<HTMLElement | null>(null);
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const ordering = ref(false)
const agreementsLoading = ref(false)
const agreementVisible = ref(false)
const agreements = ref<OrderAgreements | null>(null)
const virtualAgreementRead = ref(false)
const renterAgreementRead = ref(false)
const virtualAgreementChecked = ref(false)
const renterAgreementChecked = ref(false)
const virtualAgreementRef = ref<HTMLElement | null>(null)
const renterAgreementRef = ref<HTMLElement | null>(null)
const canCreateOrderAfterAgreement = computed(
() =>
virtualAgreementRead.value &&
renterAgreementRead.value &&
virtualAgreementChecked.value &&
renterAgreementChecked.value,
);
const listing = ref<Listing | null>(null);
renterAgreementChecked.value
)
const listing = ref<Listing | null>(null)
onMounted(async () => {
loading.value = true;
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id));
listing.value = await fetchListing(String(route.params.id))
} finally {
loading.value = false;
loading.value = false
}
});
})
const orderTotal = computed(() => {
if (!listing.value) return "0";
return formatMoney(getListingDisplayPrice(listing.value));
});
if (!listing.value) return '0'
return formatMoney(getListingDisplayPrice(listing.value))
})
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
@@ -72,260 +76,253 @@ const orderPriceBreakdown = computed(() => {
rent: 0,
consumable: 0,
total: 0,
};
}
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
total: roundMoney(getListingDisplayPrice(listing.value)),
};
});
}
})
const coverURL = computed(() => {
if (!listing.value) return "";
return listing.value.cover_url || listing.value.screenshot_urls?.[0] || "";
});
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);
if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value)
return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: "日损耗",
value: dailyLoss ? `${dailyLoss}/天` : "--",
tone: "coin",
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{ label: "价格", value: `¥${orderTotal.value}`, tone: "price" },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
];
});
{ label: '价格', value: `¥${orderTotal.value}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
]
})
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const groupedScreenshots = readGroupedScreenshots(listing.value);
if (groupedScreenshots.length) return groupedScreenshots;
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
url,
}));
});
}))
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups;
if (typeof groups !== "object" || groups === null) return [];
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
const slots = [
{ key: "coin", label: "纯币截图" },
{ key: "gameId", label: "游戏ID截图" },
{ key: "totalAsset", label: "总资产截图" },
{ key: "tencentSecurity", label: "腾讯安全中心截图" },
{ key: "skin", label: "皮肤截图" },
];
return slots.flatMap((slot) => {
const urls = (groups as Record<string, unknown>)[slot.key];
if (!Array.isArray(urls)) return [];
const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url));
{ key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
]
return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}));
});
}))
})
}
const detailSkinGroups = computed(() => {
if (!listing.value) return [];
const groups = listing.value.asset_summary?.skin_groups;
if (typeof groups !== "object" || groups === null) return [];
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: "武器皮肤",
};
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")
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.filter((group) => group.options.length);
});
.filter(group => group.options.length)
})
const accountRows = computed(() => {
if (!listing.value) return [];
const regions = assetRegions(listing.value);
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") || "无" },
];
});
{ 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 (!listing.value) return
if (!session.token) {
await router.push({ path: "/login", query: { redirect: route.fullPath } });
return;
await router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe();
await session.loadMe()
} catch {
// 登录态失效时由全局请求拦截处理。
}
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
try {
await ElMessageBox.confirm("租号下单前需要完成实名认证。", "请先实名认证", {
confirmButtonText: "去认证",
cancelButtonText: "取消",
type: "warning",
});
await router.push({ path: "/realname", query: { redirect: route.fullPath } });
await ElMessageBox.confirm('租号下单前需要完成实名认证。', '请先实名认证', {
confirmButtonText: '去认证',
cancelButtonText: '取消',
type: 'warning',
})
await router.push({ path: '/realname', query: { redirect: route.fullPath } })
} catch {
// 用户取消认证时停留在当前页面。
}
return;
return
}
await openAgreementBeforeOrder();
await openAgreementBeforeOrder()
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true;
agreementsLoading.value = true
try {
agreements.value = await fetchOrderAgreements();
virtualAgreementRead.value = false;
renterAgreementRead.value = false;
virtualAgreementChecked.value = false;
renterAgreementChecked.value = false;
agreementVisible.value = true;
await nextTick();
updateAgreementReadState("virtual");
updateAgreementReadState("renter");
agreements.value = await fetchOrderAgreements()
virtualAgreementRead.value = false
renterAgreementRead.value = false
virtualAgreementChecked.value = false
renterAgreementChecked.value = false
agreementVisible.value = true
await nextTick()
updateAgreementReadState('virtual')
updateAgreementReadState('renter')
} catch (error) {
ElMessage.error(readError(error, "协议加载失败"));
ElMessage.error(readError(error, '协议加载失败'))
} finally {
agreementsLoading.value = false;
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
ElMessage.warning("请先阅读并勾选两份协议");
return;
ElMessage.warning('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false;
await submitOrder();
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return;
ordering.value = true;
if (!listing.value) return
ordering.value = true
try {
const order = await createOrder(listing.value.id);
ElMessage.success("订单已创建,请完成支付");
await router.push(`/orders/${order.id}`);
const order = await createOrder(listing.value.id)
ElMessage.success('订单已创建,请完成支付')
await router.push(`/orders/${order.id}`)
} catch (error) {
ElMessage.error(readError(error, "下单失败"));
ElMessage.error(readError(error, '下单失败'))
} finally {
ordering.value = false;
ordering.value = false
}
}
function handleAgreementScroll(type: "virtual" | "renter") {
updateAgreementReadState(type);
function handleAgreementScroll(type: 'virtual' | 'renter') {
updateAgreementReadState(type)
}
function updateAgreementReadState(type: "virtual" | "renter") {
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value;
if (!el) return;
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
if (type === "virtual") {
virtualAgreementRead.value = read;
function updateAgreementReadState(type: 'virtual' | 'renter') {
const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
if (!el) return
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
if (type === 'virtual') {
virtualAgreementRead.value = read
} else {
renterAgreementRead.value = read;
renterAgreementRead.value = read
}
}
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;
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 fallback
}
// 手动锁定/解锁背景滚动
watch(agreementVisible, (visible) => {
watch(agreementVisible, visible => {
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
const pcMain = document.querySelector('.pc-main') as HTMLElement;
if (!pcMain) return;
const pcMain = document.querySelector('.pc-main') as HTMLElement
if (!pcMain) return
if (visible) {
// 保存当前滚动位置
const scrollTop = pcMain.scrollTop || 0;
const scrollTop = pcMain.scrollTop || 0
// 锁定主容器滚动
pcMain.style.overflow = 'hidden';
pcMain.style.position = 'fixed';
pcMain.style.top = `-${scrollTop}px`;
pcMain.style.left = '0';
pcMain.style.right = '0';
pcMain.style.width = '100%';
pcMain.style.overflow = 'hidden'
pcMain.style.position = 'fixed'
pcMain.style.top = `-${scrollTop}px`
pcMain.style.left = '0'
pcMain.style.right = '0'
pcMain.style.width = '100%'
// 保存滚动位置供恢复使用
pcMain.dataset.scrollTop = String(scrollTop);
pcMain.dataset.scrollTop = String(scrollTop)
} else {
// 恢复主容器滚动
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10);
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10)
pcMain.style.overflow = '';
pcMain.style.position = '';
pcMain.style.top = '';
pcMain.style.left = '';
pcMain.style.right = '';
pcMain.style.width = '';
pcMain.style.overflow = ''
pcMain.style.position = ''
pcMain.style.top = ''
pcMain.style.left = ''
pcMain.style.right = ''
pcMain.style.width = ''
// 恢复滚动位置
pcMain.scrollTop = scrollTop;
delete pcMain.dataset.scrollTop;
pcMain.scrollTop = scrollTop
delete pcMain.dataset.scrollTop
}
});
})
function listingPrice(item: Listing) {
return formatMoney(getListingDisplayPrice(item));
return formatMoney(getListingDisplayPrice(item))
}
</script>
<template>
<section class="pc-detail page" v-loading="loading">
<div class="anti-fraud-strip compact">
<span
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
<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)"
/>
<img v-if="coverURL" :src="coverURL" :alt="getListingTitle(listing)" />
<span v-else>HFB ACCOUNT</span>
<div class="detail-hero-overlay">
<div class="detail-tags">
@@ -340,7 +337,12 @@ function listingPrice(item: Listing) {
<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}`">
<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>
@@ -349,7 +351,7 @@ function listingPrice(item: Listing) {
<section class="detail-section">
<div class="detail-section-head">
<h2>账号资料</h2>
<span>{{ listing.game_name || "三角洲行动" }}</span>
<span>{{ listing.game_name || '三角洲行动' }}</span>
</div>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
@@ -370,13 +372,17 @@ function listingPrice(item: Listing) {
<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">
<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>
<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-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
</div>
@@ -402,7 +408,7 @@ function listingPrice(item: Listing) {
<div class="detail-section-head">
<h2>号主备注</h2>
</div>
<p class="detail-description">{{ listing.description || "号主暂未填写详细说明。" }}</p>
<p class="detail-description">{{ listing.description || '号主暂未填写详细说明。' }}</p>
</section>
<section v-if="detailScreenshots.length" class="detail-section">
@@ -444,11 +450,11 @@ function listingPrice(item: Listing) {
<dl class="order-check-list">
<div>
<dt>账号区服</dt>
<dd>{{ getServerRegion(listing) || "--" }}</dd>
<dd>{{ getServerRegion(listing) || '--' }}</dd>
</div>
<div>
<dt>上号方式</dt>
<dd>{{ getLoginMethod(listing) || "--" }}</dd>
<dd>{{ getLoginMethod(listing) || '--' }}</dd>
</div>
<div>
<dt>预计可租</dt>
@@ -463,7 +469,7 @@ function listingPrice(item: Listing) {
class="full-control"
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
{{ listing.in_transaction ? '交易中' : '立即下单' }}
</el-button>
</el-form>
</aside>
@@ -560,7 +566,7 @@ function listingPrice(item: Listing) {
.detail-hero-card::after {
position: absolute;
inset: 0;
content: "";
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%);
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from 'vue'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/features/listings/api/listingOptions";
import { fetchListings, type Listing } from "@/features/listings/api/listings";
} from '@/features/listings/api/listingOptions'
import { fetchListings, type Listing } from '@/features/listings/api/listings'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type HomeBannerSlide,
} from "@/features/listings/api/homeConfig";
} from '@/features/listings/api/homeConfig'
import {
assetRegions,
formatHafCoinM,
@@ -29,296 +29,285 @@ import {
hasGiftResources,
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
import { listingStatusLabel } from "@/utils/statusLabels";
} from '@/utils/listingDisplay'
import { listingStatusLabel } from '@/utils/statusLabels'
/* ------------------------------------------------------------------ */
/* 数据加载 */
/* ------------------------------------------------------------------ */
const loading = ref(false);
const loadFailed = ref(false);
const listings = ref<Listing[]>([]);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const announcements = ref<string[]>(defaultHomeAnnouncements);
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
const loading = ref(false)
const loadFailed = ref(false)
const listings = ref<Listing[]>([])
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const announcements = ref<string[]>(defaultHomeAnnouncements)
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
onMounted(() => {
loadListings();
loadHomeConfig();
});
loadListings()
loadHomeConfig()
})
async function loadListings() {
loading.value = true;
loadFailed.value = false;
loading.value = true
loadFailed.value = false
try {
listings.value = await fetchListings();
listings.value = await fetchListings()
} catch {
loadFailed.value = true;
loadFailed.value = true
} finally {
loading.value = false;
loading.value = false
}
}
async function loadHomeConfig() {
try {
const config = await fetchMobileHomeConfig();
announcements.value = config.announcements;
bannerSlides.value = config.banners;
publishOptions.value = config.publish_options;
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;
announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions
}
}
/* ------------------------------------------------------------------ */
/* 排序 */
/* ------------------------------------------------------------------ */
const sortBy = ref("comprehensive");
const sortBy = ref('comprehensive')
const sortOptions = [
{ key: "comprehensive", label: "综合排序" },
{ key: "published", label: "发布时间" },
{ key: "awmDesc", label: "AWM数量" },
{ key: "priceAsc", label: "价格最低" },
{ key: "priceDesc", label: "价格最高" },
];
{ key: 'comprehensive', label: '综合排序' },
{ key: 'published', label: '发布时间' },
{ key: 'awmDesc', label: 'AWM数量' },
{ key: 'priceAsc', label: '价格最低' },
{ key: 'priceDesc', label: '价格最高' },
]
const activeSortLabel = computed(
() => sortOptions.find((o) => o.key === sortBy.value)?.label || "综合排序"
);
() => sortOptions.find(o => o.key === sortBy.value)?.label || '综合排序'
)
function selectSort(key: string) {
sortBy.value = key;
sortOpen.value = false;
sortBy.value = key
sortOpen.value = false
}
/* ------------------------------------------------------------------ */
/* 筛选 — 复用移动端 FilterSection 体系 */
/* ------------------------------------------------------------------ */
type SelectedFilters = Record<string, string[]>;
type RangeFilters = Record<string, { min: string; max: string }>;
type RangePresets = Record<
string,
Array<{ label: string; min: string; max: string }>
>;
type SelectedFilters = Record<string, string[]>
type RangeFilters = Record<string, { min: string; max: string }>
type RangePresets = Record<string, Array<{ label: string; min: string; max: string }>>
const selectedFilters = ref<SelectedFilters>({});
const rangeFilters = ref<RangeFilters>({});
const searchValue = ref("");
const sortOpen = ref(false);
const filterOpen = ref(false);
const selectedFilters = ref<SelectedFilters>({})
const rangeFilters = ref<RangeFilters>({})
const searchValue = ref('')
const sortOpen = ref(false)
const filterOpen = ref(false)
const rangePresets: RangePresets = {
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: "" },
{ 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: "" },
{ 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 serverFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.server_options
.map((item) => item.trim())
.filter(Boolean)
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)
uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
)
);
type FilterSection =
| {
key: string;
title: string;
type: "range";
unit?: string;
minPlaceholder?: string;
maxPlaceholder?: string;
key: string
title: string
type: 'range'
unit?: string
minPlaceholder?: string
maxPlaceholder?: string
}
| { key: string; title: string; type: "chips"; options: string[] };
| { key: string; title: string; type: 'chips'; options: string[] }
const filterSections = computed<FilterSection[]>(() => [
{
key: "price",
title: "价格区间",
type: "range",
unit: "元",
minPlaceholder: "最低价",
maxPlaceholder: "最高价",
key: 'price',
title: '价格区间',
type: 'range',
unit: '元',
minPlaceholder: '最低价',
maxPlaceholder: '最高价',
},
{
key: "coin",
title: "哈夫币数量",
type: "range",
unit: "M",
minPlaceholder: "最低",
maxPlaceholder: "最高",
key: 'coin',
title: '哈夫币数量',
type: 'range',
unit: 'M',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: "server",
title: "区服",
type: "chips",
key: 'server',
title: '区服',
type: 'chips',
options: serverFilterOptions.value,
},
{
key: "login",
title: "上号方式",
type: "chips",
key: 'login',
title: '上号方式',
type: 'chips',
options: loginMethodFilterOptions.value,
},
{
key: "insurance",
title: "保险",
type: "chips",
key: 'insurance',
title: '保险',
type: 'chips',
options: publishOptions.value.insurance_options,
},
{
key: "stamina",
title: "体力",
type: "chips",
key: 'stamina',
title: '体力',
type: 'chips',
options: publishOptions.value.level_options,
},
{
key: "load",
title: "负重",
type: "chips",
key: 'load',
title: '负重',
type: 'chips',
options: publishOptions.value.level_options,
},
...publishOptions.value.quantity_items.map((item) => ({
...publishOptions.value.quantity_items.map(item => ({
key: `resource_${item.key}`,
title: item.label,
type: "range" as const,
type: 'range' as const,
unit: parseQuantityUnit(item.price),
minPlaceholder: "最低",
maxPlaceholder: "最高",
minPlaceholder: '最低',
maxPlaceholder: '最高',
})),
...publishOptions.value.skin_groups.map((group) => ({
...publishOptions.value.skin_groups.map(group => ({
key: group.key,
title: group.title,
type: "chips" as const,
type: 'chips' as const,
options: group.options,
})),
{
key: "secretKd",
title: "绝密KD",
type: "range",
minPlaceholder: "最低",
maxPlaceholder: "最高",
key: 'secretKd',
title: '绝密KD',
type: 'range',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: "rank",
title: "段位",
type: "chips",
key: 'rank',
title: '段位',
type: 'chips',
options: publishOptions.value.rank_options,
},
{
key: "deposit",
title: "押金",
type: "range",
unit: "元",
minPlaceholder: "最低",
maxPlaceholder: "最高",
key: 'deposit',
title: '押金',
type: 'range',
unit: '元',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
]);
])
/* 筛选交互 */
const activeFilterSection = ref("price");
const filterContentRef = ref<HTMLElement | null>(null);
const filterSectionRefs = new Map<string, HTMLElement>();
const filterTabRefs = new Map<string, HTMLElement>();
const activeFilterSection = ref('price')
const filterContentRef = ref<HTMLElement | null>(null)
const filterSectionRefs = new Map<string, HTMLElement>()
const filterTabRefs = new Map<string, HTMLElement>()
function toggleChip(sectionKey: string, value: string) {
const selected = selectedFilters.value[sectionKey] || [];
const selected = selectedFilters.value[sectionKey] || []
const next = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next };
? selected.filter(item => item !== value)
: [...selected, value]
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next }
}
function updateRange(sectionKey: string, side: "min" | "max", value: string) {
const current = rangeFilters.value[sectionKey] || { min: "", max: "" };
function updateRange(sectionKey: string, side: 'min' | 'max', value: string) {
const current = rangeFilters.value[sectionKey] || { min: '', max: '' }
rangeFilters.value = {
...rangeFilters.value,
[sectionKey]: { ...current, [side]: value },
};
}
}
function applyRangePreset(sectionKey: string, min: string, max: string) {
rangeFilters.value = {
...rangeFilters.value,
[sectionKey]: { min, max },
};
}
}
function isRangePresetActive(sectionKey: string, min: string, max: string) {
const range = rangeFilters.value[sectionKey];
return range?.min === min && range?.max === max;
const range = rangeFilters.value[sectionKey]
return range?.min === min && range?.max === max
}
function isSkinGroupKey(key: string) {
return publishOptions.value.skin_groups.some((group) => group.key === key);
return publishOptions.value.skin_groups.some(group => group.key === key)
}
function setFilterSectionRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) filterSectionRefs.set(key, el);
else filterSectionRefs.delete(key);
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);
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;
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",
});
behavior: 'smooth',
})
}
function handleFilterScroll() {
const container = filterContentRef.value;
if (!container) return;
const anchorTop = container.scrollTop + 16;
let activeKey = filterSections.value[0]?.key || "";
const container = filterContentRef.value
if (!container) return
const anchorTop = container.scrollTop + 16
let activeKey = filterSections.value[0]?.key || ''
for (const section of filterSections.value) {
const el = filterSectionRefs.get(section.key);
if (el && el.offsetTop <= anchorTop) activeKey = section.key;
const el = filterSectionRefs.get(section.key)
if (el && el.offsetTop <= anchorTop) activeKey = section.key
}
if (activeKey && activeKey !== activeFilterSection.value) {
activeFilterSection.value = activeKey;
activeFilterSection.value = activeKey
}
}
function toggleSortPanel() {
sortOpen.value = !sortOpen.value;
sortOpen.value = !sortOpen.value
}
function clearFilters() {
selectedFilters.value = {};
rangeFilters.value = {};
searchValue.value = "";
selectedFilters.value = {}
rangeFilters.value = {}
searchValue.value = ''
}
/* ------------------------------------------------------------------ */
@@ -328,64 +317,59 @@ 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;
});
range => range.min || range.max
).length
return chipCount + rangeCount
})
const filteredListings = computed(() => {
const keyword = searchValue.value.trim().toLowerCase();
const filtered = listings.value.filter((item) => {
if (!matchesFilters(item)) return false;
if (!keyword) return true;
return searchText(item).includes(keyword);
});
return sortListings(filtered);
});
const keyword = searchValue.value.trim().toLowerCase()
const filtered = listings.value.filter(item => {
if (!matchesFilters(item)) return false
if (!keyword) return true
return searchText(item).includes(keyword)
})
return sortListings(filtered)
})
function matchesFilters(item: Listing) {
const chipOk = Object.entries(selectedFilters.value).every(
([key, values]) => {
if (!values.length) return true;
if (key === "server") return values.includes(getServerRegion(item));
if (key === "login") return values.includes(getLoginMethod(item));
if (key === "insurance")
return values.includes(readAssetString(item, "season_insurance"));
if (key === "stamina")
return values.includes(readAssetString(item, "stamina_level"));
if (key === "load")
return values.includes(readAssetString(item, "load_level"));
if (key === "rank") return values.includes(item.rank_level);
const chipOk = Object.entries(selectedFilters.value).every(([key, values]) => {
if (!values.length) return true
if (key === 'server') return values.includes(getServerRegion(item))
if (key === 'login') return values.includes(getLoginMethod(item))
if (key === 'insurance') return values.includes(readAssetString(item, 'season_insurance'))
if (key === 'stamina') return values.includes(readAssetString(item, 'stamina_level'))
if (key === 'load') return values.includes(readAssetString(item, 'load_level'))
if (key === 'rank') return values.includes(item.rank_level)
if (isSkinGroupKey(key)) {
return getSkinGroup(item, key).some((skin) => values.includes(skin));
return getSkinGroup(item, key).some(skin => values.includes(skin))
}
return true;
}
);
if (!chipOk) return false;
return true
})
if (!chipOk) return false
return Object.entries(rangeFilters.value).every(([key, range]) => {
if (!range.min && !range.max) return true;
let value = 0;
if (key === "price") value = getListingDisplayPrice(item);
if (key === "coin") value = getCoinM(item);
if (key === "secretKd") value = readAssetNumber(item, "secret_kd");
if (key === "deposit") value = Number(item.deposit_amount || 0);
if (key.startsWith("resource_")) {
value = getResourceQuantity(item, key.replace("resource_", ""));
if (!range.min && !range.max) return true
let value = 0
if (key === 'price') value = getListingDisplayPrice(item)
if (key === 'coin') value = getCoinM(item)
if (key === 'secretKd') value = readAssetNumber(item, 'secret_kd')
if (key === 'deposit') value = Number(item.deposit_amount || 0)
if (key.startsWith('resource_')) {
value = getResourceQuantity(item, key.replace('resource_', ''))
}
return inRange(value, range);
});
return inRange(value, range)
})
}
function inRange(value: number, range: { min: string; max: string }) {
const min = range.min === "" ? undefined : Number(range.min);
const max = range.max === "" ? undefined : Number(range.max);
if (min !== undefined && Number.isFinite(min) && value < min) return false;
if (max !== undefined && Number.isFinite(max) && value > max) return false;
return true;
const min = range.min === '' ? undefined : Number(range.min)
const max = range.max === '' ? undefined : Number(range.max)
if (min !== undefined && Number.isFinite(min) && value < min) return false
if (max !== undefined && Number.isFinite(max) && value > max) return false
return true
}
function searchText(item: Listing) {
@@ -395,53 +379,47 @@ function searchText(item: Listing) {
getServerRegion(item),
getLoginMethod(item),
item.rank_level,
readAssetString(item, "season_insurance"),
readAssetString(item, "stamina_level"),
readAssetString(item, "load_level"),
readAssetString(item, 'season_insurance'),
readAssetString(item, 'stamina_level'),
readAssetString(item, 'load_level'),
formatHafCoinM(getCoinWan(item)),
assetRegions(item).join(" "),
...publishOptions.value.skin_groups.map((group) =>
getSkinGroup(item, group.key).join(" ")
),
assetRegions(item).join(' '),
...publishOptions.value.skin_groups.map(group => getSkinGroup(item, group.key).join(' ')),
]
.join(" ")
.toLowerCase();
.join(' ')
.toLowerCase()
}
function sortListings(items: Listing[]) {
const sorted = [...items];
if (sortBy.value === "comprehensive") return sorted;
if (sortBy.value === "priceAsc") {
return sorted.sort(
(a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b)
);
const sorted = [...items]
if (sortBy.value === 'comprehensive') return sorted
if (sortBy.value === 'priceAsc') {
return sorted.sort((a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b))
}
if (sortBy.value === "priceDesc") {
return sorted.sort(
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a)
);
if (sortBy.value === 'priceDesc') {
return sorted.sort((a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a))
}
if (sortBy.value === "awmDesc") {
if (sortBy.value === 'awmDesc') {
return sorted.sort(
(a, b) =>
getResourceQuantity(b, "awmAmmo") - getResourceQuantity(a, "awmAmmo") ||
getResourceQuantity(b, 'awmAmmo') - getResourceQuantity(a, 'awmAmmo') ||
getCoinWan(b) - getCoinWan(a)
);
)
}
return sorted.sort((a, b) => {
const bTime = Date.parse(b.published_at || b.created_at || "") || 0;
const aTime = Date.parse(a.published_at || a.created_at || "") || 0;
return bTime - aTime || b.id - a.id;
});
const bTime = Date.parse(b.published_at || b.created_at || '') || 0
const aTime = Date.parse(a.published_at || a.created_at || '') || 0
return bTime - aTime || b.id - a.id
})
}
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
}
function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim();
return unit || undefined;
const unit = price.split('/')[1]?.trim()
return unit || undefined
}
</script>
@@ -450,12 +428,7 @@ function parseQuantityUnit(price: string) {
<!-- 防骗提示 -->
<div class="fraud-tip">
<span class="fraud-dot"></span>
<span
v-for="(text, idx) in announcements"
:key="idx"
class="fraud-text"
>{{ text }}</span
>
<span v-for="(text, idx) in announcements" :key="idx" class="fraud-text">{{ text }}</span>
</div>
<!-- 页头 -->
@@ -479,11 +452,7 @@ function parseQuantityUnit(price: string) {
<input v-model="searchValue" placeholder="搜区服 / 段位 / 哈夫币数量" />
</div>
<button
type="button"
class="toolbar-btn sort-btn"
@click="toggleSortPanel"
>
<button type="button" class="toolbar-btn sort-btn" @click="toggleSortPanel">
<span>{{ activeSortLabel }}</span>
<svg
:class="{ rotated: sortOpen }"
@@ -500,11 +469,7 @@ function parseQuantityUnit(price: string) {
</svg>
</button>
<button
type="button"
class="toolbar-btn filter-btn"
@click="filterOpen = !filterOpen"
>
<button type="button" class="toolbar-btn filter-btn" @click="filterOpen = !filterOpen">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path
fill-rule="evenodd"
@@ -550,13 +515,7 @@ function parseQuantityUnit(price: string) {
<aside v-if="filterOpen" class="filter-sidebar">
<header class="filter-sidebar-header">
<h2>筛选</h2>
<button
type="button"
class="filter-close-btn"
@click="filterOpen = false"
>
</button>
<button type="button" class="filter-close-btn" @click="filterOpen = false"></button>
</header>
<div class="filter-sidebar-body">
@@ -564,7 +523,7 @@ function parseQuantityUnit(price: string) {
<button
v-for="section in filterSections"
:key="section.key"
:ref="(el) => setFilterTabRef(section.key, el as Element | null)"
:ref="el => setFilterTabRef(section.key, el as Element | null)"
type="button"
:class="{ active: activeFilterSection === section.key }"
@click="scrollToFilterSection(section.key)"
@@ -581,14 +540,11 @@ function parseQuantityUnit(price: string) {
<div
v-for="section in filterSections"
:key="section.key"
:ref="(el) => setFilterSectionRef(section.key, el as Element | null)"
:ref="el => setFilterSectionRef(section.key, el as Element | null)"
class="filter-block"
>
<h3>{{ section.title }}</h3>
<p
v-if="section.type === 'range' && section.unit"
class="filter-unit"
>
<p v-if="section.type === 'range' && section.unit" class="filter-unit">
单位{{ section.unit }}
</p>
@@ -600,11 +556,7 @@ function parseQuantityUnit(price: string) {
inputmode="decimal"
:placeholder="section.minPlaceholder || '最低'"
@input="
updateRange(
section.key,
'min',
($event.target as HTMLInputElement).value
)
updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)
"
/>
<span class="range-sep"></span>
@@ -614,32 +566,19 @@ function parseQuantityUnit(price: string) {
inputmode="decimal"
:placeholder="section.maxPlaceholder || '最高'"
@input="
updateRange(
section.key,
'max',
($event.target as HTMLInputElement).value
)
updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)
"
/>
</div>
<div
v-if="rangePresets[section.key]?.length"
class="chip-grid range-preset-grid"
>
<div v-if="rangePresets[section.key]?.length" class="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
),
active: isRangePresetActive(section.key, preset.min, preset.max),
}"
@click="
applyRangePreset(section.key, preset.min, preset.max)
"
@click="applyRangePreset(section.key, preset.min, preset.max)"
>
{{ preset.label }}
</button>
@@ -664,35 +603,23 @@ function parseQuantityUnit(price: string) {
</div>
<footer class="filter-sidebar-footer">
<button type="button" class="reset-btn" @click="clearFilters">
重置
</button>
<button type="button" class="confirm-btn" @click="filterOpen = false">
确定
</button>
<button type="button" class="reset-btn" @click="clearFilters">重置</button>
<button type="button" class="confirm-btn" @click="filterOpen = false">确定</button>
</footer>
</aside>
</Transition>
<!-- 筛选遮罩 -->
<Transition name="overlay">
<div
v-if="filterOpen"
class="filter-overlay"
@click="filterOpen = false"
></div>
<div v-if="filterOpen" class="filter-overlay" @click="filterOpen = false"></div>
</Transition>
<!-- 加载 / 错误 / 空状态 -->
<div v-if="loading" class="state-loading">正在加载优质账号...</div>
<div v-else-if="loadFailed" class="state-error">
接口暂不可用请稍后刷新
</div>
<div v-else-if="loadFailed" class="state-error">接口暂不可用请稍后刷新</div>
<div v-else-if="filteredListings.length === 0" class="state-empty">
<p>没有符合条件的账号</p>
<button type="button" class="reset-btn" @click="clearFilters">
重置条件
</button>
<button type="button" class="reset-btn" @click="clearFilters">重置条件</button>
</div>
<!-- 列表 -->
@@ -712,10 +639,7 @@ function parseQuantityUnit(price: string) {
decoding="async"
/>
<span v-else></span>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="cover-labels"
>
<div v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)" class="cover-labels">
<em v-if="hasGiftResources(item)">有赠送</em>
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
</div>
@@ -726,14 +650,10 @@ function parseQuantityUnit(price: string) {
<div class="resource-badge-row">
<span class="trust-badge">押金秒退</span>
<span class="server-badge">{{ getServerRegion(item) }}</span>
<span v-if="getLoginMethod(item)" class="server-badge">{{
getLoginMethod(item)
}}</span>
<span v-if="getLoginMethod(item)" class="server-badge">{{ getLoginMethod(item) }}</span>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`"
>{{ chip.label }}:{{ chip.value }}</span
>
</div>
@@ -797,7 +717,7 @@ function parseQuantityUnit(price: string) {
}
.fraud-text + .fraud-text::before {
content: " | ";
content: ' | ';
color: #d4c47a;
}
@@ -848,7 +768,9 @@ function parseQuantityUnit(price: string) {
border-radius: 10px;
background: #f4f6f8;
border: 1px solid transparent;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search-box:focus-within {
@@ -890,7 +812,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.toolbar-btn:hover {
@@ -950,7 +874,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s, color 0.12s;
transition:
background 0.12s,
color 0.12s;
}
.sort-panel button:hover {
@@ -972,7 +898,9 @@ function parseQuantityUnit(price: string) {
.sort-panel-enter-active,
.sort-panel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.sort-panel-enter-from,
@@ -1051,7 +979,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s;
transition:
background 0.12s,
color 0.12s;
}
.filter-tabs button:hover {
@@ -1114,7 +1044,9 @@ function parseQuantityUnit(price: string) {
text-align: center;
font-size: 13px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.range-editor input:focus {
@@ -1139,7 +1071,10 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
transition:
background 0.12s,
color 0.12s,
box-shadow 0.12s;
}
.chip-grid button:hover {
@@ -1255,7 +1190,9 @@ function parseQuantityUnit(price: string) {
border: 1px solid #eef1f5;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s, border-color 0.15s;
transition:
box-shadow 0.15s,
border-color 0.15s;
}
.resource-card:hover {
@@ -36,14 +36,21 @@ const emit = defineEmits<{
}>()
const activeCount = computed(() => {
const chipCount = Object.values(props.selectedFilters).reduce((sum, values) => sum + values.length, 0)
const rangeCount = Object.values(props.rangeFilters).filter((range) => range.min || range.max).length
const chipCount = Object.values(props.selectedFilters).reduce(
(sum, values) => sum + values.length,
0
)
const rangeCount = Object.values(props.rangeFilters).filter(
range => range.min || range.max
).length
return chipCount + rangeCount
})
function toggleChip(sectionKey: string, value: string) {
const selected = props.selectedFilters[sectionKey] || []
const next = selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]
const next = selected.includes(value)
? selected.filter(item => item !== value)
: [...selected, value]
emit('update:selectedFilters', { ...props.selectedFilters, [sectionKey]: next })
}
@@ -140,7 +140,7 @@
.mobile-banner.has-image::after {
position: absolute;
inset: 0;
content: "";
content: '';
background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.72));
}
@@ -1,24 +1,26 @@
<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 { 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 "@/features/chats/api/chats";
import { ensureSupportChat } from '@/features/chats/api/chats'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/features/listings/api/listingOptions";
import { fetchListingsPage, type Listing, type PublicListingQuery } from "@/features/listings/api/listings";
} from '@/features/listings/api/listingOptions'
import {
fetchListingsPage,
type Listing,
type PublicListingQuery,
} from '@/features/listings/api/listings'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type HomeBannerSlide,
} from "@/features/listings/api/homeConfig";
import MobileHomeFilterSheet, {
type FilterSection,
} from "./MobileHomeFilterSheet.vue";
} from '@/features/listings/api/homeConfig'
import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
import {
getListingChips,
getListingDisplayPrice,
@@ -28,237 +30,256 @@ import {
getServerRegion,
hasAcceleratedSaleRatio,
hasGiftResources,
} from "@/utils/listingDisplay";
import { useSessionStore } from "@/stores/session";
} 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 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: "价格最高" },
];
{ 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: "" },
{ 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: "" },
{ 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 ||
"综合排序"
);
() => 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;
router.push({ path: '/m/login', query: { redirect: router.currentRoute.value.fullPath } })
return
}
if (supportLoading.value) return;
supportLoading.value = true;
if (supportLoading.value) return
supportLoading.value = true
try {
const chat = await ensureSupportChat();
router.push(`/m/chats/${chat.id}`);
const chat = await ensureSupportChat()
router.push(`/m/chats/${chat.id}`)
} catch {
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" });
showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' })
} finally {
supportLoading.value = false;
supportLoading.value = false
}
}
const serverFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.server_options
.map((item) => item.trim())
.filter(Boolean)
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)
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: '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,
type: 'range' as const,
unit: parseQuantityUnit(item.price),
minPlaceholder: "最低",
maxPlaceholder: "最高",
minPlaceholder: '最低',
maxPlaceholder: '最高',
})),
...publishOptions.value.skin_groups.map((group) => ({
...publishOptions.value.skin_groups.map(group => ({
key: group.key,
title: group.title,
type: "chips" as const,
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: "最高" },
]);
{
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;
});
range => range.min || range.max
).length
return chipCount + rangeCount
})
const displayListings = computed(() => {
return listings.value;
});
return listings.value
})
onMounted(() => {
loadListings();
loadHomeConfig();
window.addEventListener("scroll", handleWindowScroll, { passive: true });
});
loadListings()
loadHomeConfig()
window.addEventListener('scroll', handleWindowScroll, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener("scroll", handleWindowScroll);
});
window.removeEventListener('scroll', handleWindowScroll)
})
watch(
() => listingQuerySignature(),
() => {
loadListings(true);
loadListings(true)
}
);
)
async function loadListings(reset = true) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return;
const requestSeq = ++listingRequestSeq;
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return
const requestSeq = ++listingRequestSeq
if (reset) {
loading.value = true;
currentPage.value = 1;
hasMoreListings.value = true;
loading.value = true
currentPage.value = 1
hasMoreListings.value = true
}
loadingMore.value = true;
loadFailed.value = false;
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);
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;
listings.value = []
totalListings.value = 0
hasMoreListings.value = false
loadFailed.value = true
}
} finally {
if (requestSeq === listingRequestSeq) {
loading.value = false;
loadingMore.value = false;
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;
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;
announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions
}
}
async function onRefresh() {
refreshing.value = true;
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" });
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;
refreshing.value = false
}
}
function openFilters() {
sortOpen.value = false;
filterOpen.value = true;
sortOpen.value = false
filterOpen.value = true
}
function toggleSortPanel() {
sortOpen.value = !sortOpen.value;
sortOpen.value = !sortOpen.value
}
function selectSort(sortKey: string) {
activeSort.value = sortKey;
sortOpen.value = false;
activeSort.value = sortKey
sortOpen.value = false
}
function clearFilters() {
selectedFilters.value = {};
rangeFilters.value = {};
searchValue.value = "";
selectedFilters.value = {}
rangeFilters.value = {}
searchValue.value = ''
}
function buildListingQuery(page: number): PublicListingQuery {
@@ -267,79 +288,78 @@ function buildListingQuery(page: number): PublicListingQuery {
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
sort: activeSort.value,
};
const skinGroups: string[] = [];
const skinNames: string[] = [];
}
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;
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);
skinGroups.push(key)
skinNames.push(...values)
}
}
if (skinGroups.length) query.skin_group = skinGroups.join(",");
if (skinNames.length) query.skin_name = skinNames.join(",");
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;
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;
return query
}
function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1));
return JSON.stringify(buildListingQuery(1))
}
function parseOptionalNumber(value: string) {
if (value === "") return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
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);
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);
return publishOptions.value.skin_groups.some(group => group.key === key)
}
function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim();
return unit || undefined;
const unit = price.split('/')[1]?.trim()
return unit || undefined
}
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
}
</script>
<template>
@@ -360,8 +380,13 @@ function uniqueOptions(values: string[]) {
placeholder="搜区服 / 段位"
class="home-search"
/>
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
{{ supportLoading ? "接入中" : "客服" }}
<button
class="mobile-service"
type="button"
:disabled="supportLoading"
@click="handleSupportClick"
>
{{ supportLoading ? '接入中' : '客服' }}
</button>
</div>
@@ -388,10 +413,7 @@ function uniqueOptions(values: string[]) {
<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"
>
<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 }]"
@@ -432,11 +454,7 @@ function uniqueOptions(values: string[]) {
@click="selectSort(option.key)"
>
<span>{{ option.label }}</span>
<van-icon
v-if="activeSort === option.key"
name="success"
:size="18"
/>
<van-icon v-if="activeSort === option.key" name="success" :size="18" />
</button>
</div>
<div class="result-count">
@@ -460,9 +478,7 @@ function uniqueOptions(values: string[]) {
image="search"
description="没有符合条件的账号"
>
<van-button size="small" type="primary" @click="clearFilters">
重置条件
</van-button>
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
</van-empty>
<!-- 列表卡片全宽上下布局 -->
@@ -510,10 +526,7 @@ function uniqueOptions(values: string[]) {
</div>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
>
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { fetchListing, type Listing } from "@/features/listings/api/listings";
import { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders";
import { useSessionStore } from "@/stores/session";
import { formatMoney } from "@/shared/utils/money";
import { fetchListing, type Listing } from '@/features/listings/api/listings'
import {
createOrder,
fetchOrderAgreements,
type OrderAgreements,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { formatMoney } from '@/shared/utils/money'
import {
assetRegions,
formatHafCoinM,
@@ -23,241 +27,240 @@ import {
getLoginMethod,
getServerRegion,
readAssetString,
} from "@/utils/listingDisplay";
} from '@/utils/listingDisplay'
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const ordering = ref(false);
const agreementsLoading = ref(false);
const agreementVisible = ref(false);
const listing = ref<Listing | null>(null);
const agreements = ref<OrderAgreements | null>(null);
const virtualAgreementRead = ref(false);
const renterAgreementRead = ref(false);
const virtualAgreementChecked = ref(false);
const renterAgreementChecked = ref(false);
const virtualAgreementRef = ref<HTMLElement | null>(null);
const renterAgreementRef = ref<HTMLElement | null>(null);
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const ordering = ref(false)
const agreementsLoading = ref(false)
const agreementVisible = ref(false)
const listing = ref<Listing | null>(null)
const agreements = ref<OrderAgreements | null>(null)
const virtualAgreementRead = ref(false)
const renterAgreementRead = ref(false)
const virtualAgreementChecked = ref(false)
const renterAgreementChecked = ref(false)
const virtualAgreementRef = ref<HTMLElement | null>(null)
const renterAgreementRef = ref<HTMLElement | null>(null)
const canCreateOrderAfterAgreement = computed(
() =>
virtualAgreementRead.value &&
renterAgreementRead.value &&
virtualAgreementChecked.value &&
renterAgreementChecked.value,
);
renterAgreementChecked.value
)
onMounted(async () => {
loading.value = true;
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id));
listing.value = await fetchListing(String(route.params.id))
} catch {
showToast({ message: "加载失败", icon: "warning-o" });
showToast({ message: '加载失败', icon: 'warning-o' })
} finally {
loading.value = false;
loading.value = false
}
});
})
const orderTotal = computed(() => {
if (!listing.value) return "0.0";
return formatMoney(getListingDisplayPrice(listing.value));
});
if (!listing.value) return '0.0'
return formatMoney(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);
if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value)
return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: "日损耗",
value: dailyLoss ? `${dailyLoss}/天` : "--",
tone: "coin",
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{ label: "价格", value: `¥${getListingDisplayPrice(listing.value)}`, tone: "price" },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
];
});
{ label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
]
})
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const groupedScreenshots = readGroupedScreenshots(listing.value);
if (groupedScreenshots.length) return groupedScreenshots;
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
url,
}));
});
}))
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups;
if (typeof groups !== "object" || groups === null) return [];
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
const slots = [
{ key: "coin", label: "纯币截图" },
{ key: "gameId", label: "游戏ID截图" },
{ key: "totalAsset", label: "总资产截图" },
{ key: "tencentSecurity", label: "腾讯安全中心截图" },
{ key: "skin", label: "皮肤截图" },
];
return slots.flatMap((slot) => {
const urls = (groups as Record<string, unknown>)[slot.key];
if (!Array.isArray(urls)) return [];
const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url));
{ key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
]
return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}));
});
}))
})
}
const detailSkinGroups = computed(() => {
if (!listing.value) return [];
const groups = listing.value.asset_summary?.skin_groups;
if (typeof groups !== "object" || groups === null) return [];
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: "武器皮肤",
};
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")
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.filter((group) => group.options.length);
});
.filter(group => group.options.length)
})
/* 下单 */
async function handleCreateOrder() {
if (!listing.value) return;
if (!listing.value) return
if (!session.token) {
showDialog({
title: "请先登录",
message: "下单需要登录账号,是否前往登录?",
confirmButtonText: "去登录",
cancelButtonText: "取消",
title: '请先登录',
message: '下单需要登录账号,是否前往登录?',
confirmButtonText: '去登录',
cancelButtonText: '取消',
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/login", query: { redirect: route.fullPath } });
});
return;
router.push({ path: '/m/login', query: { redirect: route.fullPath } })
})
return
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe();
await session.loadMe()
} catch {
// 401 会由全局拦截器处理。
}
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
showDialog({
title: "请先实名认证",
message: "租号下单前需要完成实名认证。",
confirmButtonText: "去认证",
cancelButtonText: "取消",
title: '请先实名认证',
message: '租号下单前需要完成实名认证。',
confirmButtonText: '去认证',
cancelButtonText: '取消',
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/realname", query: { redirect: route.fullPath } });
});
return;
router.push({ path: '/m/realname', query: { redirect: route.fullPath } })
})
return
}
await openAgreementBeforeOrder();
await openAgreementBeforeOrder()
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true;
agreementsLoading.value = true
try {
agreements.value = await fetchOrderAgreements();
virtualAgreementRead.value = false;
renterAgreementRead.value = false;
virtualAgreementChecked.value = false;
renterAgreementChecked.value = false;
agreementVisible.value = true;
await nextTick();
updateAgreementReadState("virtual");
updateAgreementReadState("renter");
agreements.value = await fetchOrderAgreements()
virtualAgreementRead.value = false
renterAgreementRead.value = false
virtualAgreementChecked.value = false
renterAgreementChecked.value = false
agreementVisible.value = true
await nextTick()
updateAgreementReadState('virtual')
updateAgreementReadState('renter')
} catch (error) {
showToast({ message: readError(error, "协议加载失败"), icon: "cross" });
showToast({ message: readError(error, '协议加载失败'), icon: 'cross' })
} finally {
agreementsLoading.value = false;
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
showToast("请先阅读并勾选两份协议");
return;
showToast('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false;
await submitOrder();
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return;
ordering.value = true;
if (!listing.value) return
ordering.value = true
try {
await createOrder(listing.value.id);
showToast({ message: "订单已创建,请完成支付", icon: "passed" });
await router.push(`/m/orders`);
await createOrder(listing.value.id)
showToast({ message: '订单已创建,请完成支付', icon: 'passed' })
await router.push(`/m/orders`)
} catch (error) {
showToast({ message: readError(error, "下单失败"), icon: "cross" });
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
} finally {
ordering.value = false;
ordering.value = false
}
}
function handleAgreementScroll(type: "virtual" | "renter") {
updateAgreementReadState(type);
function handleAgreementScroll(type: 'virtual' | 'renter') {
updateAgreementReadState(type)
}
function updateAgreementReadState(type: "virtual" | "renter") {
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value;
if (!el) return;
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
if (type === "virtual") {
virtualAgreementRead.value = read;
function updateAgreementReadState(type: 'virtual' | 'renter') {
const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
if (!el) return
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
if (type === 'virtual') {
virtualAgreementRead.value = read
} else {
renterAgreementRead.value = read;
renterAgreementRead.value = read
}
}
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;
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 fallback
}
/** 判断当前底部导航是否激活 */
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
return route.path.startsWith(path);
if (path === '/m') return route.path === '/m'
return route.path.startsWith(path)
}
</script>
@@ -280,9 +283,7 @@ function isNavActive(path: string) {
<!-- 防骗提示 -->
<div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" />
<span
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
<span>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span>
</div>
<!-- 封面图 -->
@@ -299,10 +300,7 @@ function isNavActive(path: string) {
<span>暂无截图</span>
</div>
<!-- 截图指示器 -->
<div
v-if="detailScreenshots.length > 1"
class="cover-count"
>
<div v-if="detailScreenshots.length > 1" class="cover-count">
{{ detailScreenshots.length }}
</div>
</div>
@@ -310,15 +308,11 @@ function isNavActive(path: string) {
<!-- 标题信息 -->
<div class="info-card">
<div class="info-tag-row">
<van-tag plain type="primary" size="medium">{{
getServerRegion(listing)
}}</van-tag>
<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>
<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>
@@ -336,10 +330,7 @@ function isNavActive(path: string) {
<div class="info-card">
<h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row">
<span
v-for="chip in getListingChips(listing)"
:key="chip.label"
>
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
@@ -349,11 +340,11 @@ function isNavActive(path: string) {
</div>
<div class="info-line">
<span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join("、") || "--" }}</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>
<span class="info-text">{{ readAssetString(listing, 'ban_record') }}</span>
</div>
</div>
@@ -368,9 +359,9 @@ function isNavActive(path: string) {
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || "--" }}</b>
<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-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
</div>
@@ -427,11 +418,18 @@ function isNavActive(path: string) {
loading-text="下单中..."
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
{{ listing.in_transaction ? '交易中' : '立即下单' }}
</van-button>
</div>
<van-popup v-model:show="agreementVisible" round closeable position="bottom" lock-scroll class="agreement-popup">
<van-popup
v-model:show="agreementVisible"
round
closeable
position="bottom"
lock-scroll
class="agreement-popup"
>
<div v-if="agreements" class="agreement-popup-body">
<h3>下单协议确认</h3>
<p>请完整阅读并勾选以下两份协议后继续下单</p>
@@ -444,7 +442,11 @@ function isNavActive(path: string) {
>
{{ agreements.virtual_asset_purchase.content }}
</div>
<van-checkbox v-model="virtualAgreementChecked" :disabled="!virtualAgreementRead" icon-size="18px">
<van-checkbox
v-model="virtualAgreementChecked"
:disabled="!virtualAgreementRead"
icon-size="18px"
>
我已阅读并同意{{ agreements.virtual_asset_purchase.title }}
</van-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
@@ -458,7 +460,11 @@ function isNavActive(path: string) {
>
{{ agreements.renter_agreement.content }}
</div>
<van-checkbox v-model="renterAgreementChecked" :disabled="!renterAgreementRead" icon-size="18px">
<van-checkbox
v-model="renterAgreementChecked"
:disabled="!renterAgreementRead"
icon-size="18px"
>
我已阅读并同意{{ agreements.renter_agreement.title }}
</van-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
+53 -16
View File
@@ -159,7 +159,9 @@ export async function fetchOrders() {
}
export async function fetchAdminOrders(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<{ items: Order[]; total: number; page: number; page_size: number }>>('/admin/orders', {
const { data } = await apiClient.get<
ApiResponse<{ items: Order[]; total: number; page: number; page_size: number }>
>('/admin/orders', {
params: { page, page_size: pageSize },
})
return data.data
@@ -181,27 +183,39 @@ export async function cancelOrder(id: number) {
}
export async function submitHandoff(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/handoff`, { content })
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`)
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`)
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 })
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 })
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(
`/admin/orders/${id}/mark-abnormal`,
{ reason }
)
return data.data
}
@@ -220,7 +234,9 @@ export async function adminRefundOrder(id: number) {
}
export async function adminRefundStatus(id: number) {
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund-status`)
const { data } = await apiClient.get<ApiResponse<RefundStatus>>(
`/admin/orders/${id}/refund-status`
)
return data.data
}
@@ -230,46 +246,67 @@ export interface StartOrderPaymentRequest {
}
export async function startOrderPayment(orderId: number, req?: StartOrderPaymentRequest) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(`/orders/${orderId}/start-payment`, req || {})
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`)
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`)
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 })
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`)
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)
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`)
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)
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`)
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(
`/orders/${id}/checkout/accept`
)
return data.data
}
@@ -56,13 +56,21 @@ export function useOrderDetail() {
const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(
order.value.status
)
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)
return (
!!order.value &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
)
})
// Methods
@@ -182,7 +190,7 @@ export function useOrderDetail() {
function linesToList(value: string) {
return value
.split('\n')
.map((line) => line.trim())
.map(line => line.trim())
.filter(Boolean)
}
@@ -36,14 +36,14 @@ export function readSnapshotResources(order: Order | null): SnapshotResource[] {
const quantities = snapshot.quantities as Record<string, any>[]
return quantities
.map((item) => ({
.map(item => ({
key: String(item.key || ''),
label: String(item.label || ''),
quantity: readNumber(item.quantity),
unitPrice: readNumber(item.price),
chargeMode: (item.charge_mode === '收费' ? '收费' : '赠送') as SnapshotResource['chargeMode'],
}))
.filter((item) => item.key && item.label)
.filter(item => item.key && item.label)
}
export function isChargedResource(resource: SnapshotResource): boolean {
@@ -106,7 +106,7 @@ export function hydrateResourceUsageFromOrder(
const consumedResources = info.consumed_resources as Record<string, number> | undefined
if (consumedResources) {
resources.forEach((res) => {
resources.forEach(res => {
if (res.key in consumedResources) {
usage[res.key] = consumedResources[res.key] ?? 0
}
@@ -147,7 +147,7 @@ export function useSettlement(order: Ref<Order | null>) {
function linesToList(value: string) {
return value
.split('\n')
.map((line) => line.trim())
.map(line => line.trim())
.filter(Boolean)
}
File diff suppressed because it is too large Load Diff
@@ -1,139 +1,143 @@
<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 { 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 "@/features/orders/api/orders";
import { useSessionStore } from "@/stores/session";
import { formatDateMinute } from "@/utils/time";
import {
fetchOrders,
startOrderPayment,
type Order,
type PaymentOrder,
} from '@/features/orders/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);
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();
loadOrders()
if (route.query.tab) {
activeTab.value = String(route.query.tab);
activeTab.value = String(route.query.tab)
}
});
})
async function loadOrders() {
loading.value = true;
loading.value = true
try {
orders.value = await fetchOrders();
orders.value = await fetchOrders()
} catch {
showToast({ message: "订单加载失败,请稍后重试", icon: "warning-o" });
showToast({ message: '订单加载失败,请稍后重试', icon: 'warning-o' })
} finally {
loading.value = false;
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: "已完成" },
];
{ 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);
});
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;
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}`);
router.push(`/m/orders/${id}`)
}
async function handlePay(order: Order) {
payingOrderId.value = order.id;
payingOrderId.value = order.id
try {
const payment = await startOrderPayment(order.id);
const payment = await startOrderPayment(order.id)
if (payment.paid) {
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
await loadOrders();
showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await loadOrders()
} else {
openPaymentCashier(payment);
openPaymentCashier(payment)
}
} catch (error) {
showToast({ message: readError(error, "支付失败"), icon: "cross" });
showToast({ message: readError(error, '支付失败'), icon: 'cross' })
} finally {
payingOrderId.value = null;
payingOrderId.value = null
}
}
function openPaymentCashier(payment: PaymentOrder) {
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || "";
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || ''
if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL;
return;
window.location.href = payURL
return
}
showDialog({
title: "订单支付",
message: payURL || "支付单已创建,请在订单详情页刷新支付状态。",
confirmButtonText: "查看详情",
title: '订单支付',
message: payURL || '支付单已创建,请在订单详情页刷新支付状态。',
confirmButtonText: '查看详情',
}).then(() => {
router.push(`/m/orders/${payment.order_id}`);
});
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;
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 fallback
}
function money(value: unknown) {
return Math.round(Number(value || 0));
return Math.round(Number(value || 0))
}
function isOwner(order: Order) {
return order.owner_id === session.userId;
return order.owner_id === session.userId
}
function amountLabel(order: Order) {
return isOwner(order) ? "预计租金" : "支付租金";
return isOwner(order) ? '预计租金' : '支付租金'
}
function orderRentAmount(order: Order) {
if (isOwner(order)) return Number(order.owner_rent_amount ?? order.display_amount ?? 0);
return Number(order.rent_amount ?? order.display_amount ?? 0);
if (isOwner(order)) return Number(order.owner_rent_amount ?? order.display_amount ?? 0)
return Number(order.rent_amount ?? order.display_amount ?? 0)
}
function ownerActualIncome(order: Order) {
if (!isOwner(order)) return null;
const value = order.checkout?.owner_income_amount;
return typeof value === "number" ? value : null;
if (!isOwner(order)) return null
const value = order.checkout?.owner_income_amount
return typeof value === 'number' ? value : null
}
</script>
@@ -161,12 +165,7 @@ function ownerActualIncome(order: Order) {
swipeable
animated
>
<van-tab
v-for="tab in statusTabs"
:key="tab.key"
:title="tab.label"
:name="tab.key"
/>
<van-tab v-for="tab in statusTabs" :key="tab.key" :title="tab.label" :name="tab.key" />
</van-tabs>
<!-- 订单列表 -->
@@ -208,7 +207,9 @@ function ownerActualIncome(order: Order) {
<div class="price-item">
<span class="price-label">{{ amountLabel(order) }}</span>
<span class="price-val">¥{{ money(orderRentAmount(order)) }}</span>
<span v-if="ownerActualIncome(order) !== null" class="price-sub">实际到手 ¥{{ money(ownerActualIncome(order)) }}</span>
<span v-if="ownerActualIncome(order) !== null" class="price-sub"
>实际到手 ¥{{ money(ownerActualIncome(order)) }}</span
>
</div>
<div class="price-item">
<span class="price-label">押金金额</span>
@@ -235,9 +236,7 @@ function ownerActualIncome(order: Order) {
>
去支付
</van-button>
<span v-else class="detail-link">
查看详情 <van-icon name="arrow" :size="10" />
</span>
<span v-else class="detail-link"> 查看详情 <van-icon name="arrow" :size="10" /> </span>
</div>
</div>
</div>
@@ -334,8 +333,12 @@ function ownerActualIncome(order: Order) {
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;
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;
}
@@ -368,19 +371,58 @@ function ownerActualIncome(order: Order) {
}
/* 状态徽章颜色 - 现代轻量化配色 */
.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); }
.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;
@@ -79,18 +79,32 @@ const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金'))
const orderRentDisplayAmount = computed(() => (order.value ? orderRentAmount(order.value) : 0))
const ownerIncomeDisplayAmount = computed(() => (order.value ? ownerActualIncome(order.value) : null))
const ownerIncomeLabel = computed(() => (order.value?.status === 'completed' ? '实际到手' : '结账预计到手'))
const ownerIncomeDisplayAmount = computed(() =>
order.value ? ownerActualIncome(order.value) : null
)
const ownerIncomeLabel = computed(() =>
order.value?.status === 'completed' ? '实际到手' : '结账预计到手'
)
const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(order.value.status)
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)
return (
!!order.value &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
)
})
const checkoutResources = computed(() => {
const resources = readSnapshotResources()
return resources.filter((item) => item.quantity > 0)
return resources.filter(item => item.quantity > 0)
})
const resourceChargeAmount = computed(() => {
return roundMoney(
@@ -98,7 +112,7 @@ const resourceChargeAmount = computed(() => {
if (!isChargedResource(item)) return sum
const used = readResourceUsage(item.key)
return sum + used * item.unitPrice
}, 0),
}, 0)
)
})
const snapshotHafCoinM = computed(() => {
@@ -106,20 +120,22 @@ const snapshotHafCoinM = computed(() => {
return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
})
const remainingHafCoinM = computed(() => {
return roundQuantity(Math.max(snapshotHafCoinM.value - Number(checkoutForm.value.coin_consumed_m || 0), 0))
return roundQuantity(
Math.max(snapshotHafCoinM.value - Number(checkoutForm.value.coin_consumed_m || 0), 0)
)
})
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,
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
}
@@ -489,7 +505,7 @@ function readSnapshotResources(): CheckoutResource[] {
if (!Array.isArray(resources)) return []
return resources
.filter(isRecord)
.map((item) => {
.map(item => {
const key = String(item.key || item.label || '')
const label = String(item.label || key || '额外消耗品')
const price = String(item.price || '')
@@ -502,13 +518,16 @@ function readSnapshotResources(): CheckoutResource[] {
unitPrice: readUnitPrice(price),
}
})
.filter((item) => item.key && item.quantity > 0)
.filter(item => item.key && item.quantity > 0)
}
function hydrateResourceUsage() {
const next: Record<string, number> = {}
for (const item of checkoutResources.value) {
next[item.key] = Math.min(Math.max(Number(resourceUsage.value[item.key] || 0), 0), item.quantity)
next[item.key] = Math.min(
Math.max(Number(resourceUsage.value[item.key] || 0), 0),
item.quantity
)
}
resourceUsage.value = next
}
@@ -527,16 +546,21 @@ function resourceLineAmount(item: CheckoutResource) {
function checkoutContentWithSummary() {
const lines = [checkoutForm.value.content.trim()].filter(Boolean)
const usedResources = checkoutResources.value.filter((item) => readResourceUsage(item.key) > 0)
const usedResources = checkoutResources.value.filter(item => readResourceUsage(item.key) > 0)
if (usedResources.length) {
lines.push(
`额外消耗品:${usedResources
.map((item) => `${item.label} ${readResourceUsage(item.key)}/${item.quantity}${isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款'}`)
.join('')}`,
.map(
item =>
`${item.label} ${readResourceUsage(item.key)}/${item.quantity}${isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款'}`
)
.join('')}`
)
}
if (Number(checkoutForm.value.coin_consumed_m || 0) > 0) {
lines.push(`哈夫币消耗:${quantity(Number(checkoutForm.value.coin_consumed_m))}M,预计剩余${quantity(remainingHafCoinM.value)}M`)
lines.push(
`哈夫币消耗:${quantity(Number(checkoutForm.value.coin_consumed_m))}M,预计剩余${quantity(remainingHafCoinM.value)}M`
)
}
if (lines.length === 0) {
lines.push('租客发起结账。')
@@ -571,7 +595,8 @@ function money(value: unknown) {
}
function orderRentAmount(item: Order) {
if (item.owner_id === session.userId) return Number(item.owner_rent_amount ?? item.display_amount ?? 0)
if (item.owner_id === session.userId)
return Number(item.owner_rent_amount ?? item.display_amount ?? 0)
if (item.renter_id === session.userId) return Number(item.rent_amount ?? item.display_amount ?? 0)
return Number(item.display_amount ?? 0)
}
@@ -608,7 +633,7 @@ function hydrateCounterForm() {
function linesToList(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.map(item => item.trim())
.filter(Boolean)
}
@@ -662,7 +687,7 @@ function formatHandoffRecordType(type: string) {
<p class="order-meta">{{ order.server_region }} / {{ order.login_platform }}</p>
</div>
<el-button type="primary" :loading="openingChat" @click="openOrderChat">
<el-icon style="margin-right: 4px;"><ChatDotRound /></el-icon>
<el-icon style="margin-right: 4px"><ChatDotRound /></el-icon>
联系对方
</el-button>
</div>
@@ -671,11 +696,34 @@ function formatHandoffRecordType(type: string) {
<div v-if="order" class="content-layout">
<div class="main-content">
<div v-if="order" class="order-progress-section">
<el-steps :active="getOrderStep(order.status)" align-center finish-status="success" process-status="process">
<el-step title="待支付" :description="order.status === 'pending_payment' ? '等待租客支付' : ''" />
<el-step title="待交接" :description="order.status === 'pending_handoff' ? handoffStatusLabel(order.handoff_status) : ''" />
<el-step title="使用中" :description="['renting', 'overdue'].includes(order.status) ? '租赁进行中' : ''" />
<el-step title="结账中" :description="['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status) ? '等待确认' : ''" />
<el-steps
:active="getOrderStep(order.status)"
align-center
finish-status="success"
process-status="process"
>
<el-step
title="待支付"
:description="order.status === 'pending_payment' ? '等待租客支付' : ''"
/>
<el-step
title="待交接"
:description="
order.status === 'pending_handoff' ? handoffStatusLabel(order.handoff_status) : ''
"
/>
<el-step
title="使用中"
:description="['renting', 'overdue'].includes(order.status) ? '租赁进行中' : ''"
/>
<el-step
title="结账中"
:description="
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
? '等待确认'
: ''
"
/>
<el-step title="已完成" :description="order.status === 'completed' ? '订单完成' : ''" />
</el-steps>
</div>
@@ -718,7 +766,13 @@ function formatHandoffRecordType(type: string) {
<div v-if="order.status === 'pending_payment'" class="action-card warning">
<p class="action-message">订单待支付支付后账号进入交接流程</p>
<div class="action-buttons">
<el-button v-if="isRenter" type="primary" size="large" :loading="startingPayment" @click="handlePay">
<el-button
v-if="isRenter"
type="primary"
size="large"
:loading="startingPayment"
@click="handlePay"
>
立即支付订单
</el-button>
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
@@ -756,23 +810,42 @@ function formatHandoffRecordType(type: string) {
</el-timeline>
</div>
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="form-section checkout-form-section">
<div
v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)"
class="form-section checkout-form-section"
>
<div class="section-header">
<h2>发起结账</h2>
</div>
<div class="form-card">
<el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写结账说明、租后资产状态或注意事项" />
<el-input
v-model="checkoutForm.content"
type="textarea"
:rows="4"
placeholder="填写结账说明、租后资产状态或注意事项"
/>
<div class="checkout-resource-panel">
<div class="checkout-resource-head">
<strong>额外消耗品</strong>
<span class="amount-highlight">已用金额¥{{ money(resourceChargeAmount) }}</span>
</div>
<el-empty v-if="checkoutResources.length === 0" description="订单快照中暂无额外消耗品" />
<div v-for="item in checkoutResources" v-else :key="item.key" class="checkout-resource-row">
<el-empty
v-if="checkoutResources.length === 0"
description="订单快照中暂无额外消耗品"
/>
<div
v-for="item in checkoutResources"
v-else
:key="item.key"
class="checkout-resource-row"
>
<div class="checkout-resource-meta">
<strong>{{ item.label }}</strong>
<span>库存 {{ item.quantity }}{{ item.mode }}{{ item.price || '未设置单价' }}</span>
<span
>库存 {{ item.quantity }}{{ item.mode }}{{
item.price || '未设置单价'
}}</span
>
</div>
<el-input-number
v-model="resourceUsage[item.key]"
@@ -786,12 +859,30 @@ function formatHandoffRecordType(type: string) {
</div>
<el-form class="form-grid" label-position="top">
<el-form-item label="消耗哈夫币(M">
<el-input-number v-model="checkoutForm.coin_consumed_m" class="full-control" :min="0" :max="snapshotHafCoinM" :precision="2" controls-position="right" />
<span class="field-hint">订单快照 {{ quantity(snapshotHafCoinM) }}M预计剩余 {{ quantity(remainingHafCoinM) }}M</span>
<el-input-number
v-model="checkoutForm.coin_consumed_m"
class="full-control"
:min="0"
:max="snapshotHafCoinM"
:precision="2"
controls-position="right"
/>
<span class="field-hint"
>订单快照 {{ quantity(snapshotHafCoinM) }}M预计剩余
{{ quantity(remainingHafCoinM) }}M</span
>
</el-form-item>
<el-form-item label="其他押金赔付(元)">
<el-input-number v-model="checkoutForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
<span class="field-hint">不含上方额外消耗品仅填写封禁违规资产损坏等需要从押金赔付的费用</span>
<el-input-number
v-model="checkoutForm.other_amount"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
/>
<span class="field-hint"
>不含上方额外消耗品仅填写封禁违规资产损坏等需要从押金赔付的费用</span
>
</el-form-item>
</el-form>
<div class="checkout-deduct-preview">
@@ -812,11 +903,24 @@ function formatHandoffRecordType(type: string) {
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
/>
<el-button type="primary" size="large" :loading="returning" @click="handleSubmitCheckout">发起结账</el-button>
<el-button
type="primary"
size="large"
:loading="returning"
@click="handleSubmitCheckout"
>发起结账</el-button
>
</div>
</div>
<div v-if="order && order.checkout && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)" class="info-section">
<div
v-if="
order &&
order.checkout &&
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
"
class="info-section"
>
<div class="section-header">
<h2>结账明细</h2>
</div>
@@ -856,7 +960,10 @@ function formatHandoffRecordType(type: string) {
</div>
</div>
<div v-if="order && isOwner && order.status === 'pending_checkout_confirm'" class="form-section counter-checkout-section">
<div
v-if="order && isOwner && order.status === 'pending_checkout_confirm'"
class="form-section counter-checkout-section"
>
<div class="section-header">
<h2>修改结账</h2>
</div>
@@ -864,32 +971,88 @@ function formatHandoffRecordType(type: string) {
<h3 class="sub-title">修改结账金额</h3>
<el-form class="form-grid" label-position="top">
<el-form-item label="额外消耗品已用金额">
<el-input-number v-model="counterForm.consumable_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
<el-input-number
v-model="counterForm.consumable_amount"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="消耗哈夫币(M">
<el-input-number v-model="counterForm.coin_consumed_m" class="full-control" :min="0" :precision="2" controls-position="right" />
<el-input-number
v-model="counterForm.coin_consumed_m"
class="full-control"
:min="0"
:precision="2"
controls-position="right"
/>
</el-form-item>
<el-form-item label="其他押金扣款(元)">
<el-input-number v-model="counterForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" />
<el-input-number
v-model="counterForm.other_amount"
class="full-control"
:min="0"
:precision="0"
controls-position="right"
/>
</el-form-item>
<el-form-item label="押金扣除(元)">
<el-input-number v-model="counterForm.deposit_deduct_amount" class="full-control" :min="0" :max="order.deposit_amount" :precision="0" controls-position="right" />
<el-input-number
v-model="counterForm.deposit_deduct_amount"
class="full-control"
:min="0"
:max="order.deposit_amount"
:precision="0"
controls-position="right"
/>
</el-form-item>
</el-form>
<el-input v-model="counterForm.reason" type="textarea" :rows="3" placeholder="填写修改原因" />
<el-input v-model="counterForm.evidenceText" type="textarea" :rows="3" placeholder="修正证据链接,一行一个" />
<el-button type="warning" size="large" :loading="countering" @click="handleCounterCheckout">提交修正给租客确认</el-button>
<el-input
v-model="counterForm.reason"
type="textarea"
:rows="3"
placeholder="填写修改原因"
/>
<el-input
v-model="counterForm.evidenceText"
type="textarea"
:rows="3"
placeholder="修正证据链接,一行一个"
/>
<el-button
type="warning"
size="large"
:loading="countering"
@click="handleCounterCheckout"
>提交修正给租客确认</el-button
>
</div>
</div>
<div v-if="order && isRenter && order.status === 'pending_checkout_accept'" class="form-section reject-checkout-section">
<div
v-if="order && isRenter && order.status === 'pending_checkout_accept'"
class="form-section reject-checkout-section"
>
<div class="section-header">
<h2>拒绝修正</h2>
</div>
<div class="form-card">
<p class="form-hint">不同意修正时填写原因,订单会进入争议处理。</p>
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="不同意时填写原因,会进入争议处理" />
<el-button type="danger" plain size="large" :loading="rejectingCheckout" @click="handleRejectCheckout">拒绝修正并发起争议</el-button>
<el-input
v-model="rejectReason"
type="textarea"
:rows="3"
placeholder="不同意时填写原因,会进入争议处理"
/>
<el-button
type="danger"
plain
size="large"
:loading="rejectingCheckout"
@click="handleRejectCheckout"
>拒绝修正并发起争议</el-button
>
</div>
</div>
</div>
@@ -897,29 +1060,56 @@ function formatHandoffRecordType(type: string) {
<!-- 右侧悬浮操作区 -->
<div v-if="order" class="action-sidebar">
<!-- 交接操作 -->
<div v-if="isOwner && order.status === 'pending_handoff' && order.handoff_status === 'pending_owner'" class="sidebar-card">
<div
v-if="
isOwner &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_owner'
"
class="sidebar-card"
>
<h3 class="sidebar-title">提交交接说明</h3>
<el-input v-model="handoffContent" type="textarea" :rows="5" placeholder="填写登录方式、注意事项和交接说明" />
<el-button type="primary" size="large" :loading="handoffing" @click="handleSubmitHandoff">提交交接</el-button>
<el-input
v-model="handoffContent"
type="textarea"
:rows="5"
placeholder="填写登录方式、注意事项和交接说明"
/>
<el-button type="primary" size="large" :loading="handoffing" @click="handleSubmitHandoff"
>提交交接</el-button
>
</div>
<!-- 确认收号 -->
<div
v-if="isRenter && order.status === 'pending_handoff' && order.handoff_status === 'pending_renter_confirm'"
v-if="
isRenter &&
order.status === 'pending_handoff' &&
order.handoff_status === 'pending_renter_confirm'
"
class="sidebar-card"
>
<h3 class="sidebar-title">确认收号</h3>
<p class="sidebar-hint">确认账号可以正常登录后订单会进入使用中并重新计算预计截止时间</p>
<el-button type="primary" size="large" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
<p class="sidebar-hint">
确认账号可以正常登录后,订单会进入使用中并重新计算预计截止时间。
</p>
<el-button type="primary" size="large" :loading="confirming" @click="handleConfirmReceive"
>确认已收到账号</el-button
>
</div>
<!-- 发起结账 -->
<div v-if="isRenter && ['renting', 'overdue'].includes(order.status)" class="sidebar-card highlight-card">
<div
v-if="isRenter && ['renting', 'overdue'].includes(order.status)"
class="sidebar-card highlight-card"
>
<h3 class="sidebar-title">快捷结账</h3>
<el-button type="primary" size="large" :loading="returning" @click="handleSubmitCheckout">
立即提交结账
</el-button>
<p class="sidebar-hint">如需修改消耗品哈夫币等详细信息请先向下滚动填写完整表单后再提交</p>
<p class="sidebar-hint">
如需修改消耗品、哈夫币等详细信息,请先向下滚动填写完整表单后再提交
</p>
<el-button type="default" size="small" @click="scrollToCheckout">
查看详细结账表单
</el-button>
@@ -928,7 +1118,12 @@ function formatHandoffRecordType(type: string) {
<!-- 确认结账 -->
<div v-if="isOwner && order.status === 'pending_checkout_confirm'" class="sidebar-card">
<h3 class="sidebar-title">确认结账</h3>
<el-button type="primary" size="large" :loading="completing" @click="handleConfirmCheckout">
<el-button
type="primary"
size="large"
:loading="completing"
@click="handleConfirmCheckout"
>
确认结账并完成订单
</el-button>
<div class="section-divider-mini">
@@ -943,7 +1138,12 @@ function formatHandoffRecordType(type: string) {
<!-- 确认修正结账 -->
<div v-if="isRenter && order.status === 'pending_checkout_accept'" class="sidebar-card">
<h3 class="sidebar-title">确认修正结账</h3>
<el-button type="primary" size="large" :loading="acceptingCheckout" @click="handleAcceptCheckout">
<el-button
type="primary"
size="large"
:loading="acceptingCheckout"
@click="handleAcceptCheckout"
>
同意修正并完成订单
</el-button>
<div class="section-divider-mini">
@@ -971,7 +1171,12 @@ function formatHandoffRecordType(type: string) {
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
</div>
<div class="form-card">
<el-select v-if="!isCheckoutDisputeStage" v-model="disputeType" class="full-control" placeholder="选择申诉类型">
<el-select
v-if="!isCheckoutDisputeStage"
v-model="disputeType"
class="full-control"
placeholder="选择申诉类型"
>
<el-option label="无法登录" value="cannot_login" />
<el-option label="虚假描述" value="false_description" />
<el-option label="账号被封" value="account_banned" />
@@ -993,7 +1198,12 @@ function formatHandoffRecordType(type: string) {
placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
/>
<div class="upload-line">
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
:disabled="uploadingEvidence"
@change="handleEvidenceUpload"
/>
</div>
<el-button type="warning" size="large" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
@@ -1597,7 +1807,7 @@ function formatHandoffRecordType(type: string) {
border-radius: 6px;
}
.upload-line input[type="file"] {
.upload-line input[type='file'] {
width: 100%;
font-size: 14px;
}

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