增加前端格式检查配置

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", "dev": "vue-tsc -b --noEmit --watch & vite --host 0.0.0.0",
"build": "vue-tsc -b --noEmit && vite build", "build": "vue-tsc -b --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0", "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": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
@@ -20,12 +24,19 @@
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@vant/auto-import-resolver": "^1.3.0", "@vant/auto-import-resolver": "^1.3.0",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1", "@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": "~5.9.3",
"typescript-eslint": "^8.60.1",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vant": "^4.9.24", "vant": "^4.9.24",
+10 -10
View File
@@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from "vue-router"; import { RouterView } from 'vue-router'
import { computed } from "vue"; import { computed } from 'vue'
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router'
import AdminLayout from "./layouts/AdminLayout.vue"; import AdminLayout from './layouts/AdminLayout.vue'
import AppLayout from "./layouts/AppLayout.vue"; import AppLayout from './layouts/AppLayout.vue'
const route = useRoute(); const route = useRoute()
const layout = computed(() => { const layout = computed(() => {
if (route.path === "/m" || route.path.startsWith("/m/")) { if (route.path === '/m' || route.path.startsWith('/m/')) {
return "blank"; return 'blank'
} }
return route.meta.layout || "app"; return route.meta.layout || 'app'
}); })
</script> </script>
<template> <template>
@@ -34,9 +34,8 @@ async function loadImage() {
} }
try { try {
const key = extractObjectKey(props.source) const key = extractObjectKey(props.source)
const blob = props.admin && key const blob =
? await fetchAdminFileBlob(key) props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(props.source)
: await fetchFileBlobByURL(props.source)
objectURL.value = URL.createObjectURL(blob) objectURL.value = URL.createObjectURL(blob)
} catch { } catch {
failed.value = true failed.value = true
@@ -55,7 +54,7 @@ onBeforeUnmount(revokeCurrentURL)
<template> <template>
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage"> <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> </button>
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span> <span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</template> </template>
+4 -20
View File
@@ -11,19 +11,11 @@ function isNavActive(path: string) {
<template> <template>
<nav class="bottom-nav"> <nav class="bottom-nav">
<RouterLink <RouterLink to="/m" class="nav-item" :class="{ active: isNavActive('/m') }">
to="/m"
class="nav-item"
:class="{ active: isNavActive('/m') }"
>
<van-icon name="home-o" :size="22" /> <van-icon name="home-o" :size="22" />
<span>首页</span> <span>首页</span>
</RouterLink> </RouterLink>
<RouterLink <RouterLink to="/m/messages" class="nav-item" :class="{ active: isNavActive('/m/messages') }">
to="/m/messages"
class="nav-item"
:class="{ active: isNavActive('/m/messages') }"
>
<van-icon name="chat-o" :size="22" /> <van-icon name="chat-o" :size="22" />
<span>消息</span> <span>消息</span>
</RouterLink> </RouterLink>
@@ -31,19 +23,11 @@ function isNavActive(path: string) {
<div class="publish-pill">+</div> <div class="publish-pill">+</div>
<span>发布</span> <span>发布</span>
</RouterLink> </RouterLink>
<RouterLink <RouterLink to="/m/orders" class="nav-item" :class="{ active: isNavActive('/m/orders') }">
to="/m/orders"
class="nav-item"
:class="{ active: isNavActive('/m/orders') }"
>
<van-icon name="orders-o" :size="22" /> <van-icon name="orders-o" :size="22" />
<span>订单</span> <span>订单</span>
</RouterLink> </RouterLink>
<RouterLink <RouterLink to="/m/profile" class="nav-item" :class="{ active: isNavActive('/m/profile') }">
to="/m/profile"
class="nav-item"
:class="{ active: isNavActive('/m/profile') }"
>
<van-icon name="manager-o" :size="22" /> <van-icon name="manager-o" :size="22" />
<span>我的</span> <span>我的</span>
</RouterLink> </RouterLink>
@@ -26,7 +26,10 @@ export async function fetchAdminAnnouncements(params: {
page?: number page?: number
page_size?: number page_size?: number
}): Promise<PaginatedResult<Announcement>> { }): 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 return data.data
} }
@@ -40,7 +43,10 @@ export async function createAnnouncement(req: CreateAnnouncementRequest): Promis
return data.data 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) const { data } = await apiClient.put<ApiResponse<Announcement>>(`/admin/announcements/${id}`, req)
return data.data return data.data
} }
@@ -26,7 +26,12 @@ export interface AdminAuditQuery {
} }
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) { export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) const params = Object.fromEntries(
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params }) Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>(
'/admin/audit-logs',
{ params }
)
return data.data return data.data
} }
+17 -5
View File
@@ -45,7 +45,12 @@ export async function fetchAdminCaptcha() {
return data.data 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', { const { data } = await apiClient.post<ApiResponse<AdminLoginData>>('/admin/auth/login', {
username, username,
password, password,
@@ -66,9 +71,12 @@ export async function logoutAdmin() {
} }
export async function updateSupportStatus(status: 'online' | 'offline' | 'busy') { 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 }>>(
status, '/admin/me/support-status',
}) {
status,
}
)
return data.data return data.data
} }
@@ -76,7 +84,11 @@ export async function updateSupportStatus(status: 'online' | 'offline' | 'busy')
export async function refreshAdminSession() { export async function refreshAdminSession() {
const refreshToken = getRefreshToken('admin') const refreshToken = getRefreshToken('admin')
if (!refreshToken) throw new Error('no refresh token') 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) setAuthTokens('admin', data.data)
return data.data return data.data
} }
+19 -8
View File
@@ -37,9 +37,12 @@ export interface ChangePasswordRequest {
} }
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) { 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>>>(
params: { page, page_size: pageSize }, '/admin/admin-users',
}) {
params: { page, page_size: pageSize },
}
)
return data.data return data.data
} }
@@ -59,18 +62,26 @@ export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) {
} }
export async function deleteAdminMgrUser(id: number) { 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 return data.data
} }
export async function assignAdminRoles(id: number, roleIds: number[]) { 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 }>>(
role_ids: roleIds, `/admin/admin-users/${id}/roles`,
}) {
role_ids: roleIds,
}
)
return data.data return data.data
} }
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) { 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 return data.data
} }
@@ -58,9 +58,12 @@ export async function deleteRole(id: number) {
} }
export async function assignRolePermissions(roleId: number, permissionIds: 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 }>>(
permission_ids: permissionIds, `/admin/roles/${roleId}/permissions`,
}) {
permission_ids: permissionIds,
}
)
return data.data return data.data
} }
@@ -20,14 +20,19 @@ export interface AdminUserItem {
} }
export async function fetchAdminUsers(page = 1, pageSize = 20) { 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>>>(
params: { page, page_size: pageSize }, '/admin/users',
}) {
params: { page, page_size: pageSize },
}
)
return data.data return data.data
} }
export async function freezeAdminUser(id: number, reason: string) { 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 return data.data
} }
@@ -30,8 +30,13 @@ export interface AdminWalletLedgerQuery {
} }
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) { export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) const params = Object.fromEntries(
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params }) Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>(
'/admin/wallet/ledger',
{ params }
)
const result = data.data const result = data.data
return { return {
items: Array.isArray(result?.items) ? result.items : [], items: Array.isArray(result?.items) ? result.items : [],
@@ -53,9 +53,12 @@ export async function fetchAdminWithdrawals(params: {
page?: number page?: number
page_size?: number page_size?: number
}) { }) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalDetail>>>('/admin/withdrawals', { const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalDetail>>>(
params, '/admin/withdrawals',
}) {
params,
}
)
return data.data return data.data
} }
@@ -65,11 +68,17 @@ export async function fetchAdminWithdrawal(id: number) {
} }
export async function reviewWithdrawal(id: number, req: ReviewWithdrawalRequest) { 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 return data.data
} }
export async function confirmPayment(id: number, req: ConfirmPaymentRequest) { 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 return data.data
} }
@@ -85,9 +85,12 @@ export async function fetchPaymentConfigs(params?: {
page?: number page?: number
page_size?: number page_size?: number
}) { }) {
const { data } = await apiClient.get<ApiResponse<PaymentConfigListResponse>>('/admin/payment-configs', { const { data } = await apiClient.get<ApiResponse<PaymentConfigListResponse>>(
params, '/admin/payment-configs',
}) {
params,
}
)
return data.data return data.data
} }
@@ -104,27 +107,39 @@ export async function exportPaymentConfigBackup() {
}) })
return { return {
blob: response.data, blob: response.data,
filename: readDownloadFilename(response.headers['content-disposition']) || fallbackBackupFilename(), filename:
readDownloadFilename(response.headers['content-disposition']) || fallbackBackupFilename(),
} }
} }
export async function importPaymentConfigBackup(payload: unknown) { 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 return data.data
} }
export async function createPaymentConfig(payload: CreatePaymentConfigRequest) { 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 return data.data
} }
export async function updatePaymentConfig(id: number, payload: UpdatePaymentConfigRequest) { 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 return data.data
} }
export async function deletePaymentConfig(id: number) { 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 return data.data
} }
@@ -136,6 +151,9 @@ function readDownloadFilename(contentDisposition: unknown) {
} }
function fallbackBackupFilename() { 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` return `payment-config-backup-${stamp}.json`
} }
@@ -12,11 +12,18 @@ export interface SystemConfig {
} }
export async function fetchSystemConfigs() { 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 return data.data.items
} }
export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) { export async function updateSystemConfig(
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(`/admin/system-configs/${key}`, payload) key: string,
payload: { value: string; description?: string }
) {
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(
`/admin/system-configs/${key}`,
payload
)
return data.data return data.data
} }
@@ -60,8 +60,12 @@ function nextPage() {
<el-option :value="100" label="100条" /> <el-option :value="100" label="100条" />
</el-select> </el-select>
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span> <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 <= 1 || loading" @click="prevPage"
<el-button size="small" :disabled="currentPage >= totalPages || loading" @click="nextPage">下页</el-button> >上页</el-button
>
<el-button size="small" :disabled="currentPage >= totalPages || loading" @click="nextPage"
>下页</el-button
>
</div> </div>
</div> </div>
</template> </template>
@@ -2,7 +2,13 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue' 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<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -26,7 +32,7 @@ const isEdit = ref(false)
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
if (props.admin) { if (props.admin) {
isEdit.value = true isEdit.value = true
@@ -42,7 +48,7 @@ watch(
} }
} }
}, },
{ immediate: true }, { immediate: true }
) )
async function handleSave() { async function handleSave() {
@@ -94,7 +100,12 @@ function readError(error: unknown, fallback: string) {
<el-input v-model="form.username" :disabled="isEdit" placeholder="请输入用户名" /> <el-input v-model="form.username" :disabled="isEdit" placeholder="请输入用户名" />
</el-form-item> </el-form-item>
<el-form-item v-if="!isEdit" label="密码" class="full-control"> <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>
<el-form-item label="昵称" class="full-control"> <el-form-item label="昵称" class="full-control">
<el-input v-model="form.nickname" placeholder="请输入昵称" /> <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 type { FormInstance, FormRules } from 'element-plus'
import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue' import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue'
import type { Announcement } from '@/features/announcement' 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 { interface Props {
visible: boolean visible: boolean
@@ -39,42 +42,41 @@ const rules: FormRules = {
{ required: true, message: '请输入公告标题', trigger: 'blur' }, { required: true, message: '请输入公告标题', trigger: 'blur' },
{ max: 255, message: '标题不能超过255个字符', trigger: 'blur' }, { max: 255, message: '标题不能超过255个字符', trigger: 'blur' },
], ],
content: [ content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
{ required: true, message: '请输入公告内容', trigger: 'blur' }, category: [{ required: true, message: '请选择公告分类', trigger: 'change' }],
],
category: [
{ required: true, message: '请选择公告分类', trigger: 'change' },
],
} }
const dialogTitle = computed(() => { const dialogTitle = computed(() => {
return props.mode === 'create' ? '新建公告' : '编辑公告' return props.mode === 'create' ? '新建公告' : '编辑公告'
}) })
watch(() => props.visible, (val) => { watch(
if (val) { () => props.visible,
if (props.mode === 'edit' && props.announcement) { val => {
formData.value = { if (val) {
title: props.announcement.title, if (props.mode === 'edit' && props.announcement) {
content: props.announcement.content, formData.value = {
category: props.announcement.category as any, title: props.announcement.title,
priority: props.announcement.priority, content: props.announcement.content,
is_pinned: props.announcement.is_pinned, category: props.announcement.category as any,
is_important: props.announcement.is_important, priority: props.announcement.priority,
} is_pinned: props.announcement.is_pinned,
} else { is_important: props.announcement.is_important,
formData.value = { }
title: '', } else {
content: '', formData.value = {
category: 'notice', title: '',
priority: 0, content: '',
is_pinned: false, category: 'notice',
is_important: false, priority: 0,
is_pinned: false,
is_important: false,
}
} }
formRef.value?.clearValidate()
} }
formRef.value?.clearValidate()
} }
}) )
function handleClose() { function handleClose() {
emit('update:visible', false) emit('update:visible', false)
@@ -93,18 +95,8 @@ async function handleSave() {
</script> </script>
<template> <template>
<el-dialog <el-dialog :model-value="visible" :title="dialogTitle" width="800px" @close="handleClose">
:model-value="visible" <el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
:title="dialogTitle"
width="800px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="公告标题" prop="title"> <el-form-item label="公告标题" prop="title">
<el-input <el-input
v-model="formData.title" v-model="formData.title"
@@ -144,7 +136,9 @@ async function handleSave() {
:max="999" :max="999"
placeholder="数值越大越靠前" 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>
<el-form-item label="标记"> <el-form-item label="标记">
@@ -2,7 +2,13 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue' 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<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -24,13 +30,16 @@ const groupedPermissions = ref<Record<string, Permission[]>>({})
watch( watch(
() => props.modelValue, () => props.modelValue,
async (val) => { async val => {
if (val && props.role) { if (val && props.role) {
loading.value = true loading.value = true
try { 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 allPermissions.value = perms
selectedPermIds.value = (roleDetail.permissions || []).map((p) => p.id) selectedPermIds.value = (roleDetail.permissions || []).map(p => p.id)
// 按 resource 分组 // 按 resource 分组
const grouped: Record<string, Permission[]> = {} const grouped: Record<string, Permission[]> = {}
@@ -45,7 +54,7 @@ watch(
loading.value = false loading.value = false
} }
} }
}, }
) )
async function handleSave() { async function handleSave() {
@@ -64,10 +73,10 @@ async function handleSave() {
} }
function toggleGroup(perms: Permission[]) { function toggleGroup(perms: Permission[]) {
const ids = perms.map((p) => p.id) const ids = perms.map(p => p.id)
const allSelected = ids.every((id) => selectedPermIds.value.includes(id)) const allSelected = ids.every(id => selectedPermIds.value.includes(id))
if (allSelected) { if (allSelected) {
selectedPermIds.value = selectedPermIds.value.filter((id) => !ids.includes(id)) selectedPermIds.value = selectedPermIds.value.filter(id => !ids.includes(id))
} else { } else {
const newIds = [...selectedPermIds.value] const newIds = [...selectedPermIds.value]
for (const id of ids) { for (const id of ids) {
@@ -78,11 +87,11 @@ function toggleGroup(perms: Permission[]) {
} }
function isGroupAllSelected(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[]) { 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) { function readError(error: unknown, fallback: string) {
@@ -22,19 +22,19 @@ const selectedRoleIds = ref<number[]>([])
watch( watch(
() => props.modelValue, () => props.modelValue,
async (val) => { async val => {
if (val && props.admin) { if (val && props.admin) {
loading.value = true loading.value = true
try { try {
allRoles.value = await fetchRoles() allRoles.value = await fetchRoles()
selectedRoleIds.value = props.admin.roles.map((r) => r.id) selectedRoleIds.value = props.admin.roles.map(r => r.id)
} catch { } catch {
ElMessage.error('加载角色列表失败') ElMessage.error('加载角色列表失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
}, }
) )
async function handleSave() { async function handleSave() {
@@ -21,7 +21,7 @@ const description = ref('')
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
value.value = props.config.value || '' value.value = props.config.value || ''
description.value = props.config.description || '' description.value = props.config.description || ''
@@ -38,7 +38,7 @@ const isStructuredConfig = computed(() => {
const selectOptions = computed<SystemConfigOption[] | null>(() => { const selectOptions = computed<SystemConfigOption[] | null>(() => {
const options = getSystemConfigSelectOptions(props.config.key) const options = getSystemConfigSelectOptions(props.config.key)
if (!options) return null 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 options
} }
return [{ label: `当前值:${value.value}`, value: value.value }, ...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)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="dialog-body"> <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-form-item v-if="isStructuredConfig" label="配置值 (JSON)" class="full-control">
<el-input v-model="value" type="textarea" :rows="12" placeholder="配置值 JSON" /> <el-input v-model="value" type="textarea" :rows="12" placeholder="配置值 JSON" />
@@ -22,7 +22,7 @@ const homeAnnouncementLines = ref('')
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(props.config.value)) homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(props.config.value))
description.value = props.config.description || '' description.value = props.config.description || ''
@@ -39,7 +39,7 @@ function parseHomeAnnouncements(raw: string) {
function linesToItems(value: string) { function linesToItems(value: string) {
return value return value
.split('\n') .split('\n')
.map((item) => item.trim()) .map(item => item.trim())
.filter(Boolean) .filter(Boolean)
} }
@@ -86,7 +86,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="dialog-body"> <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="home-config-editor">
<div class="editor-toolbar"> <div class="editor-toolbar">
@@ -3,7 +3,11 @@ import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { uploadAdminFile } from '@/shared/api/files' 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 { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
import { safeParseJSON } from '@/utils/json' import { safeParseJSON } from '@/utils/json'
@@ -24,7 +28,7 @@ const uploadingBannerIndex = ref<number | null>(null)
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
homeBannersDraft.value = parseHomeBanners(props.config.value) homeBannersDraft.value = parseHomeBanners(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
@@ -88,7 +92,7 @@ async function handleSave() {
submitting.value = true submitting.value = true
try { try {
const value = JSON.stringify( 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, null,
2 2
) )
@@ -123,7 +127,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="dialog-body"> <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="home-config-editor">
<div class="editor-toolbar"> <div class="editor-toolbar">
@@ -154,16 +160,24 @@ function readError(error: unknown, fallback: string) {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="眉标" min-width="160"> <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>
<el-table-column label="主标题" min-width="220"> <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>
<el-table-column label="角标" width="110"> <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>
<el-table-column label="胶囊文案" min-width="220"> <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>
<el-table-column label="色调" width="130"> <el-table-column label="色调" width="130">
<template #default="{ row }"> <template #default="{ row }">
@@ -176,7 +190,9 @@ function readError(error: unknown, fallback: string) {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -63,25 +63,37 @@ const draft = ref<ListingPublishAgreements>(cloneAgreements(defaultListingPublis
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
draft.value = parseListingPublishAgreements(props.config.value) draft.value = parseListingPublishAgreements(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
} }
}, },
{ immediate: true }, { immediate: true }
) )
function parseListingPublishAgreements(raw: string) { function parseListingPublishAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultListingPublishAgreements) const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
return cloneAgreements({ return cloneAgreements({
virtual_asset_sale: { virtual_asset_sale: {
title: readText(parsed?.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title), title: readText(
content: readText(parsed?.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content), parsed?.virtual_asset_sale?.title,
defaultListingPublishAgreements.virtual_asset_sale.title
),
content: readText(
parsed?.virtual_asset_sale?.content,
defaultListingPublishAgreements.virtual_asset_sale.content
),
}, },
seller_agreement: { seller_agreement: {
title: readText(parsed?.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title), title: readText(
content: readText(parsed?.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content), 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-body">
<div class="dialog-header"> <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> <el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div> </div>
@@ -63,25 +63,37 @@ const draft = ref<OrderAgreements>(cloneAgreements(defaultOrderAgreements))
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
draft.value = parseOrderAgreements(props.config.value) draft.value = parseOrderAgreements(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
} }
}, },
{ immediate: true }, { immediate: true }
) )
function parseOrderAgreements(raw: string) { function parseOrderAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultOrderAgreements) const parsed = safeParseJSON(raw, defaultOrderAgreements)
return cloneAgreements({ return cloneAgreements({
virtual_asset_purchase: { virtual_asset_purchase: {
title: readText(parsed?.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title), title: readText(
content: readText(parsed?.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content), parsed?.virtual_asset_purchase?.title,
defaultOrderAgreements.virtual_asset_purchase.title
),
content: readText(
parsed?.virtual_asset_purchase?.content,
defaultOrderAgreements.virtual_asset_purchase.content
),
}, },
renter_agreement: { renter_agreement: {
title: readText(parsed?.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title), title: readText(
content: readText(parsed?.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content), 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-body">
<div class="dialog-header"> <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> <el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div> </div>
@@ -86,12 +86,7 @@ const isLakala = computed(() => formData.value.provider === 'lakala')
const isMock = computed(() => formData.value.provider === 'mock') const isMock = computed(() => formData.value.provider === 'mock')
const signKeyLabel = computed(() => (isLakala.value ? '商户私钥' : '签名密钥')) const signKeyLabel = computed(() => (isLakala.value ? '商户私钥' : '签名密钥'))
const notifyKeyLabel = computed(() => (isLakala.value ? '通知证书' : '通知密钥')) const notifyKeyLabel = computed(() => (isLakala.value ? '通知证书' : '通知密钥'))
const extraConfig = computed<Record<string, any>>(() => { const extraConfig = computed<Record<string, any>>(() => formData.value.extra_config || {})
if (!formData.value.extra_config) {
formData.value.extra_config = {}
}
return formData.value.extra_config
})
function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any> | null) { function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any> | null) {
const extra = { const extra = {
@@ -106,7 +101,7 @@ function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val && props.config) { if (val && props.config) {
formData.value = { formData.value = {
name: props.config.name, name: props.config.name,
@@ -218,7 +213,11 @@ function handleClose() {
</el-form-item> </el-form-item>
<el-form-item label="支付服务商" required> <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="leshua" />
<el-option label="拉卡拉支付" value="lakala" /> <el-option label="拉卡拉支付" value="lakala" />
<el-option label="模拟支付" value="mock" /> <el-option label="模拟支付" value="mock" />
@@ -234,11 +233,19 @@ function handleClose() {
</el-form-item> </el-form-item>
<el-form-item v-if="isLakala" label="App ID" required> <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>
<el-form-item v-if="isLakala" label="证书序列号" required> <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>
<el-form-item v-if="isLakala" label="终端号" required> <el-form-item v-if="isLakala" label="终端号" required>
@@ -251,7 +258,9 @@ function handleClose() {
:type="isLakala ? 'textarea' : 'password'" :type="isLakala ? 'textarea' : 'password'"
:rows="isLakala ? 5 : undefined" :rows="isLakala ? 5 : undefined"
:show-password="!isLakala" :show-password="!isLakala"
:placeholder="mode === 'edit' ? '留空则不修改' : isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥'" :placeholder="
mode === 'edit' ? '留空则不修改' : isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥'
"
:disabled="isReadonly" :disabled="isReadonly"
/> />
</el-form-item> </el-form-item>
@@ -262,7 +271,13 @@ function handleClose() {
:type="isLakala ? 'textarea' : 'password'" :type="isLakala ? 'textarea' : 'password'"
:rows="isLakala ? 5 : undefined" :rows="isLakala ? 5 : undefined"
:show-password="!isLakala" :show-password="!isLakala"
:placeholder="mode === 'edit' ? '留空则不修改' : isLakala ? '请输入拉卡拉通知验签证书 PEM' : '请输入通知密钥'" :placeholder="
mode === 'edit'
? '留空则不修改'
: isLakala
? '请输入拉卡拉通知验签证书 PEM'
: '请输入通知密钥'
"
:disabled="isReadonly" :disabled="isReadonly"
/> />
</el-form-item> </el-form-item>
@@ -282,7 +297,11 @@ function handleClose() {
</el-form-item> </el-form-item>
<el-form-item v-if="!isMock" label="回调地址" required> <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>
<el-form-item label="是否默认"> <el-form-item label="是否默认">
@@ -312,7 +331,11 @@ function handleClose() {
</template> </template>
<el-form-item label="跳转地址"> <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>
<el-form-item label="支付方式"> <el-form-item label="支付方式">
@@ -332,7 +355,12 @@ function handleClose() {
</el-form-item> </el-form-item>
<el-form-item v-if="isLakala" label="有效期分钟"> <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>
<el-form-item label="支付形态"> <el-form-item label="支付形态">
@@ -63,13 +63,13 @@ const draft = ref<PostRentalNotice>(cloneNotice(defaultPostRentalNotice))
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
draft.value = parsePostRentalNotice(props.config.value) draft.value = parsePostRentalNotice(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
} }
}, },
{ immediate: true }, { immediate: true }
) )
function parsePostRentalNotice(raw: string) { function parsePostRentalNotice(raw: string) {
@@ -128,7 +128,9 @@ function readError(error: unknown, fallback: string) {
> >
<div class="dialog-body"> <div class="dialog-body">
<div class="dialog-header"> <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> <el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div> </div>
@@ -26,7 +26,7 @@ const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListing
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
publishOptionsDraft.value = parsePublishOptions(props.config.value) publishOptionsDraft.value = parsePublishOptions(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
@@ -47,7 +47,7 @@ function cloneOptions(options: ListingPublishOptions) {
function linesToItems(value: string) { function linesToItems(value: string) {
return value return value
.split('\n') .split('\n')
.map((item) => item.trim()) .map(item => item.trim())
.filter(Boolean) .filter(Boolean)
} }
@@ -200,7 +200,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="dialog-body"> <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="publish-options-editor">
<div class="editor-toolbar"> <div class="editor-toolbar">
@@ -300,7 +302,12 @@ function readError(error: unknown, fallback: string) {
<strong>发布规则</strong> <strong>发布规则</strong>
</div> </div>
<el-form-item label="最低烽火等级"> <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> </el-form-item>
</div> </div>
@@ -309,7 +316,11 @@ function readError(error: unknown, fallback: string) {
<strong>押金与价格提示</strong> <strong>押金与价格提示</strong>
</div> </div>
<el-form-item label="押金提示"> <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>
<el-form-item label="押金后缀"> <el-form-item label="押金后缀">
<el-input <el-input
@@ -319,10 +330,16 @@ function readError(error: unknown, fallback: string) {
/> />
</el-form-item> </el-form-item>
<el-form-item label="价格提示"> <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>
<el-form-item label="比例说明"> <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> </el-form-item>
</div> </div>
@@ -339,19 +356,35 @@ function readError(error: unknown, fallback: string) {
class="full-control" class="full-control"
/> />
</el-form-item> </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"> <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>
<el-table-column label="名称" min-width="140"> <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>
<el-table-column label="每个增加押金" min-width="140"> <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>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -365,16 +398,30 @@ function readError(error: unknown, fallback: string) {
<span>保险基础比例</span> <span>保险基础比例</span>
<el-button size="small" @click="addInsuranceBaseRatio">添加保险比例</el-button> <el-button size="small" @click="addInsuranceBaseRatio">添加保险比例</el-button>
</div> </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"> <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>
<el-table-column label="基础比例" min-width="120"> <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>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -403,11 +450,15 @@ function readError(error: unknown, fallback: string) {
<template #default="{ row }"><el-input v-model="row.group_key" /></template> <template #default="{ row }"><el-input v-model="row.group_key" /></template>
</el-table-column> </el-table-column>
<el-table-column label="缺失加成" min-width="120"> <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>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -418,14 +469,20 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<el-table :data="publishOptionsDraft.ratio_config.coin_corrections" size="small" border> <el-table :data="publishOptionsDraft.ratio_config.coin_corrections" size="small" border>
<el-table-column label="大于 M" min-width="130"> <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>
<el-table-column label="比例加成" min-width="130"> <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>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -436,7 +493,11 @@ function readError(error: unknown, fallback: string) {
<strong>皮肤分类</strong> <strong>皮肤分类</strong>
<el-button size="small" @click="addSkinGroup">添加分类</el-button> <el-button size="small" @click="addSkinGroup">添加分类</el-button>
</div> </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.key" placeholder="分类 key" />
<el-input v-model="group.title" placeholder="分类名称" /> <el-input v-model="group.title" placeholder="分类名称" />
<el-input <el-input
@@ -470,7 +531,9 @@ function readError(error: unknown, fallback: string) {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -496,7 +559,9 @@ function readError(error: unknown, fallback: string) {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -29,14 +29,17 @@ const form = ref({
is_global: false, is_global: false,
}) })
watch(() => props.modelValue, (val) => { watch(
visible.value = val () => props.modelValue,
if (val) { val => {
loadReplies() visible.value = val
if (val) {
loadReplies()
}
} }
}) )
watch(visible, (val) => { watch(visible, val => {
emit('update:modelValue', val) emit('update:modelValue', val)
}) })
@@ -76,7 +79,12 @@ async function handleSubmit() {
await updateQuickReply(editingId.value, form.value) await updateQuickReply(editingId.value, form.value)
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } 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('创建成功') ElMessage.success('创建成功')
} }
resetForm() resetForm()
@@ -100,7 +108,9 @@ async function handleDelete(reply: QuickReply) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
await loadReplies() await loadReplies()
emit('success') emit('success')
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
function handleCancel() { function handleCancel() {
@@ -2,7 +2,13 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue' 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<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -25,7 +31,7 @@ const isEdit = ref(false)
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
if (props.role) { if (props.role) {
isEdit.value = true isEdit.value = true
@@ -40,7 +46,7 @@ watch(
} }
} }
}, },
{ immediate: true }, { immediate: true }
) )
async function handleSave() { async function handleSave() {
@@ -22,11 +22,13 @@ const emit = defineEmits<{
const submitting = ref(false) const submitting = ref(false)
const description = ref('') const description = ref('')
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig)) const salePriceConfigDraft = ref<PublishSalePriceConfig>(
cloneSalePriceConfig(emptyListingSalePriceConfig)
)
watch( watch(
() => props.modelValue, () => props.modelValue,
(val) => { val => {
if (val) { if (val) {
salePriceConfigDraft.value = parseSalePriceConfig(props.config.value) salePriceConfigDraft.value = parseSalePriceConfig(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
@@ -103,7 +105,9 @@ function readError(error: unknown, fallback: string) {
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="dialog-body"> <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="publish-options-editor">
<div class="editor-toolbar"> <div class="editor-toolbar">
@@ -117,17 +121,29 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border> <el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border>
<el-table-column label="最小 M" min-width="120"> <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>
<el-table-column label="最大 M" min-width="120"> <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>
<el-table-column label="加价金额/元" min-width="140"> <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>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -138,17 +154,29 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border> <el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border>
<el-table-column label="最小 M" min-width="120"> <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>
<el-table-column label="最大 M" min-width="120"> <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>
<el-table-column label="比例修正" min-width="130"> <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>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -51,14 +51,17 @@ function getSupportStatusColor(status: string) {
return colors[status] || '#9ca3af' return colors[status] || '#9ca3af'
} }
watch(() => props.modelValue, (val) => { watch(
visible.value = val () => props.modelValue,
if (val) { val => {
loadAdmins() visible.value = val
if (val) {
loadAdmins()
}
} }
}) )
watch(visible, (val) => { watch(visible, val => {
emit('update:modelValue', val) emit('update:modelValue', val)
}) })
@@ -112,7 +115,10 @@ function getStatusTag(count: number) {
<div class="admin-info"> <div class="admin-info">
<div class="admin-avatar-wrap"> <div class="admin-avatar-wrap">
<el-avatar :size="36" :icon="User" /> <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>
<div class="admin-detail"> <div class="admin-detail">
<span class="admin-name">{{ admin.nickname }}</span> <span class="admin-name">{{ admin.nickname }}</span>
@@ -120,7 +126,10 @@ function getStatusTag(count: number) {
</div> </div>
</div> </div>
<div class="admin-status"> <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) }} {{ getSupportStatusLabel(admin.support_status) }}
</span> </span>
<span class="admin-count">{{ admin.chat_count }} 个会话</span> <span class="admin-count">{{ admin.chat_count }} 个会话</span>
@@ -131,7 +140,12 @@ function getStatusTag(count: number) {
</div> </div>
<template #footer> <template #footer>
<el-button @click="visible = false">取消</el-button> <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> </el-button>
</template> </template>
@@ -62,7 +62,7 @@ async function handleConfirmPayment() {
cancelButtonText: '取消', cancelButtonText: '取消',
inputType: 'textarea', inputType: 'textarea',
inputPlaceholder: '输入打款备注...', inputPlaceholder: '输入打款备注...',
inputValidator: (value) => { inputValidator: value => {
return value && value.trim().length > 0 return value && value.trim().length > 0
}, },
inputErrorMessage: '请输入打款备注', inputErrorMessage: '请输入打款备注',
@@ -97,10 +97,15 @@ function statusLabel(status: string) {
} }
function statusType(status: string) { function statusType(status: string) {
return status === 'completed' ? 'success' : return status === 'completed'
status === 'pending' ? 'warning' : ? 'success'
status === 'processing' ? 'primary' : : status === 'pending'
status === 'rejected' ? 'danger' : 'info' ? 'warning'
: status === 'processing'
? 'primary'
: status === 'rejected'
? 'danger'
: 'info'
} }
function accountTypeLabel(type: string) { function accountTypeLabel(type: string) {
@@ -188,7 +193,11 @@ function accountTypeLabel(type: string) {
<el-descriptions-item v-if="withdrawal.bank_branch" label="开户支行"> <el-descriptions-item v-if="withdrawal.bank_branch" label="开户支行">
{{ withdrawal.bank_branch }} {{ withdrawal.bank_branch }}
</el-descriptions-item> </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"> <div style="display: flex; gap: 8px; flex-wrap: wrap">
<el-image <el-image
v-for="(url, idx) in withdrawal.certificate_urls" v-for="(url, idx) in withdrawal.certificate_urls"
@@ -299,9 +308,7 @@ function accountTypeLabel(type: string) {
确认打款 确认打款
</el-button> </el-button>
</div> </div>
<el-button @click="emit('update:modelValue', false)"> <el-button @click="emit('update:modelValue', false)"> 关闭 </el-button>
关闭
</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -21,7 +21,9 @@ interface AdminPaginatedTableResult<T> {
handleSizeChange: () => void 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 loading = ref(false)
const data = ref<T[]>([]) as Ref<T[]> const data = ref<T[]>([]) as Ref<T[]>
const total = ref(0) const total = ref(0)
+2 -2
View File
@@ -19,7 +19,7 @@ export {
type AdminUser, type AdminUser,
type AdminTokenPair, type AdminTokenPair,
type AdminLoginData, type AdminLoginData,
type AdminCaptcha type AdminCaptcha,
} from './api/adminAuth' } from './api/adminAuth'
// adminRoles 导出 // adminRoles 导出
@@ -31,5 +31,5 @@ export {
deleteRole, deleteRole,
fetchPermissions, fetchPermissions,
type Role, type Role,
type Permission type Permission,
} from './api/adminRoles' } from './api/adminRoles'
@@ -1,7 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
fetchAdminAnnouncements, fetchAdminAnnouncements,
createAnnouncement, createAnnouncement,
@@ -199,12 +209,34 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</div> </div>
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="statusFilter" placeholder="筛选状态" clearable @change="handleFilterChange" style="width: 150px"> <el-select
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /> 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>
<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 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> </el-select>
</div> </div>
@@ -220,14 +252,23 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
<el-icon><Top /></el-icon> <el-icon><Top /></el-icon>
置顶 置顶
</el-tag> </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>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="分类" width="120"> <el-table-column label="分类" width="120">
<template #default="{ row }"> <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) }} {{ getCategoryLabel(row.category) }}
</el-tag> </el-tag>
</template> </template>
@@ -248,17 +289,43 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }"> <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-icon><Edit /></el-icon>
编辑 编辑
</el-button> </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>
<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>
<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-icon><Delete /></el-icon>
删除 删除
</el-button> </el-button>
@@ -18,7 +18,11 @@ const filters = reactive({
biz_type: '', 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) onMounted(loadLogs)
@@ -81,7 +85,9 @@ function actionType(action: string) {
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button> <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>
</div> </div>
@@ -101,7 +107,13 @@ function actionType(action: string) {
<el-input v-model="filters.actor_id" clearable placeholder="按管理员筛选" /> <el-input v-model="filters.actor_id" clearable placeholder="按管理员筛选" />
</el-form-item> </el-form-item>
<el-form-item label="动作"> <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.freeze" />
<el-option label="解冻用户" value="admin_user.unfreeze" /> <el-option label="解冻用户" value="admin_user.unfreeze" />
<el-option label="后台下架商品" value="listing.admin_offline" /> <el-option label="后台下架商品" value="listing.admin_offline" />
@@ -155,9 +167,18 @@ function actionType(action: string) {
@page-change="handlePageChange" @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"> <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>操作人{{ actorName(activeLog) }} · IP{{ activeLog.ip }}</p>
<p>User-Agent{{ activeLog.user_agent }}</p> <p>User-Agent{{ activeLog.user_agent }}</p>
<div class="code-panel"> <div class="code-panel">
@@ -13,7 +13,7 @@ import {
Tickets, Tickets,
User, User,
Wallet, Wallet,
Warning Warning,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard' import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard'
import { useAdminTable } from '@/features/admin/composables/useAdminTable' import { useAdminTable } from '@/features/admin/composables/useAdminTable'
@@ -23,7 +23,12 @@ import { formatDateTime } from '@/utils/time'
const money = useMoney() const money = useMoney()
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({ const {
loading,
error,
data: dashboard,
load: loadDashboard,
} = useAdminTable<AdminDashboard>({
fetchFn: fetchAdminDashboard, fetchFn: fetchAdminDashboard,
}) })
</script> </script>
@@ -108,7 +113,13 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
待处理事项 待处理事项
</h2> </h2>
<el-tag type="warning" effect="dark" round> <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> </el-tag>
</div> </div>
<div class="pending-list"> <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-label">待仲裁申诉</span>
<span class="pending-desc">需要处理的用户争议</span> <span class="pending-desc">需要处理的用户争议</span>
</div> </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>
<RouterLink class="pending-row" to="/admin/listings/review"> <RouterLink class="pending-row" to="/admin/listings/review">
<div class="pending-info"> <div class="pending-info">
<span class="pending-label">待审核商品</span> <span class="pending-label">待审核商品</span>
<span class="pending-desc">新提交的商品审核</span> <span class="pending-desc">新提交的商品审核</span>
</div> </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> </RouterLink>
<div class="pending-row"> <div class="pending-row">
<div class="pending-info"> <div class="pending-info">
<span class="pending-label">待交接订单</span> <span class="pending-label">待交接订单</span>
<span class="pending-desc">等待卖家交接</span> <span class="pending-desc">等待卖家交接</span>
</div> </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>
<div class="pending-row"> <div class="pending-row">
<div class="pending-info"> <div class="pending-info">
<span class="pending-label">待结账确认</span> <span class="pending-label">待结账确认</span>
<span class="pending-desc">等待双方确认</span> <span class="pending-desc">等待双方确认</span>
</div> </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> </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 prop="title" label="商品名称" min-width="170" />
<el-table-column label="状态" width="120"> <el-table-column label="状态" width="120">
<template #default="{ row }"> <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) }} {{ orderStatusLabel(row.status) }}
</el-tag> </el-tag>
</template> </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 prop="type" label="申诉类型" width="140" />
<el-table-column label="状态" width="120"> <el-table-column label="状态" width="120">
<template #default="{ row }"> <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) }} {{ disputeStatusLabel(row.status) }}
</el-tag> </el-tag>
</template> </template>
@@ -128,11 +128,26 @@ function readError(error: unknown, fallback: string) {
<el-table-column label="创建时间" min-width="180"> <el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template> <template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column> </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"> <el-table-column label="操作" width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button> <el-button
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button> size="small"
:disabled="evidenceItems(row).length === 0"
@click="evidenceDispute = row"
>证据</el-button
>
<el-button
size="small"
:disabled="row.status === 'resolved'"
@click="openArbitration(row)"
>仲裁</el-button
>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -146,9 +161,16 @@ function readError(error: unknown, fallback: string) {
@page-change="handlePageChange" @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"> <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> <p>{{ activeDispute.description }}</p>
<el-select v-model="result" class="full-control" placeholder="选择裁决结果"> <el-select v-model="result" class="full-control" placeholder="选择裁决结果">
<el-option label="全额退款" value="full_refund" /> <el-option label="全额退款" value="full_refund" />
@@ -168,19 +190,38 @@ function readError(error: unknown, fallback: string) {
:step="10" :step="10"
placeholder="裁决金额" placeholder="裁决金额"
/> />
<p v-if="result === 'partial_refund'">部分退款金额表示退给租客的金额剩余冻结金额结算给号主</p> <p v-if="result === 'partial_refund'">
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">金额表示从押金中赔付给号主的部分不填则默认处理全额押金</p> 部分退款金额表示退给租客的金额剩余冻结金额结算给号主
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" /> </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> </div>
<template #footer> <template #footer>
<el-button @click="activeDispute = null">取消</el-button> <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> </template>
</el-dialog> </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"> <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"> <div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
<span>{{ item }}</span> <span>{{ item }}</span>
<el-button size="small" @click="openEvidence(item)">打开</el-button> <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 { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/shared/api/files' 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 { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
@@ -15,8 +20,15 @@ const listing = ref<Listing | null>(null)
const actionType = ref<'offline' | 'abnormal' | ''>('') const actionType = ref<'offline' | 'abnormal' | ''>('')
const reason = ref('') const reason = ref('')
const actionTitle = computed(() => (actionType.value === 'offline' ? '强制下架商品' : '标记商品异常')) const actionTitle = computed(() =>
const canOperate = computed(() => !!listing.value && listing.value.status !== 'rented' && !['offline', 'abnormal'].includes(listing.value.status)) actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
)
const canOperate = computed(
() =>
!!listing.value &&
listing.value.status !== 'rented' &&
!['offline', 'abnormal'].includes(listing.value.status)
)
onMounted(loadListing) onMounted(loadListing)
@@ -100,8 +112,12 @@ function readError(error: unknown, fallback: string) {
<RouterLink to="/admin/listings"> <RouterLink to="/admin/listings">
<el-button>返回列表</el-button> <el-button>返回列表</el-button>
</RouterLink> </RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')">强制下架</el-button> <el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button> >强制下架</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
</div> </div>
</div> </div>
@@ -164,10 +180,22 @@ function readError(error: unknown, fallback: string) {
<p v-else>暂无截图</p> <p v-else>暂无截图</p>
</div> </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"> <div v-if="listing" class="dialog-body">
<p><strong>{{ listing.title }}</strong></p> <p>
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写后台操作原因,会写入审计日志并通知号主" /> <strong>{{ listing.title }}</strong>
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写后台操作原因,会写入审计日志并通知号主"
/>
</div> </div>
<template #footer> <template #footer>
<el-button @click="actionType = ''">取消</el-button> <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 { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files' 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 { import {
assetRegions, assetRegions,
formatHafCoinM, formatHafCoinM,
@@ -26,7 +32,13 @@ interface RiskItem {
} }
const defaultScreenshotURL = '/api/listings/default-upload-screenshot' const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常'] const rejectReasonOptions = [
'默认截图,需补充真实截图',
'账号资产信息不完整',
'价格或押金异常',
'封禁记录需补充说明',
'联系方式异常',
]
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -50,10 +62,12 @@ const filters = reactive({
const previewURLs = reactive<Record<string, string>>({}) const previewURLs = reactive<Record<string, string>>({})
const createdObjectURLs = new Set<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 filteredListings = computed(() => {
const keyword = filters.keyword.trim().toLowerCase() 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 === 'external' && !isExternalUpload(item)) return false
if (filters.risk === 'defaultImage' && !hasDefaultScreenshot(item)) return false if (filters.risk === 'defaultImage' && !hasDefaultScreenshot(item)) return false
if (filters.risk === 'ban' && !hasBanRecord(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 activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : [])) const selectedResources = computed(() =>
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : [])) selectedListing.value ? getListingResources(selectedListing.value) : []
const priceAdjustPreview = computed(() => (priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null)) )
const selectedSkins = computed(() =>
selectedListing.value ? getSkinNames(selectedListing.value) : []
)
const priceAdjustPreview = computed(() =>
priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null
)
watch( watch(
() => selectedListing.value, () => selectedListing.value,
async (listing) => { async listing => {
if (!listing) return if (!listing) return
selectedID.value = listing.id selectedID.value = listing.id
await nextTick() await nextTick()
loadScreenshotPreviews(listing) loadScreenshotPreviews(listing)
}, },
{ immediate: true }, { immediate: true }
) )
watch(priceAdjustMode, (mode, previousMode) => { watch(priceAdjustMode, (mode, previousMode) => {
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode) const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
if (mode === 'price') { if (mode === 'price') {
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value) priceAdjustForm.buyer_total_price =
preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
} else { } else {
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value) priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
} }
@@ -89,14 +110,14 @@ watch(priceAdjustMode, (mode, previousMode) => {
onMounted(loadListings) onMounted(loadListings)
onBeforeUnmount(() => { onBeforeUnmount(() => {
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url)) createdObjectURLs.forEach(url => URL.revokeObjectURL(url))
}) })
async function loadListings() { async function loadListings() {
loading.value = true loading.value = true
try { try {
listings.value = await fetchPendingReviewListings() 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 selectedID.value = listings.value[0]?.id || null
} }
} finally { } finally {
@@ -109,7 +130,7 @@ function selectListing(row: Listing) {
} }
function replaceListing(next: 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) { if (index >= 0) {
listings.value.splice(index, 1, next) listings.value.splice(index, 1, next)
} else { } else {
@@ -157,8 +178,14 @@ async function handleSavePriceAdjust() {
if (!priceAdjustListing.value) return if (!priceAdjustListing.value) return
const payload = const payload =
priceAdjustMode.value === 'ratio' 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) { if ((payload.buyer_ratio || payload.buyer_total_price || 0) <= 0) {
ElMessage.warning('请填写有效的加价后比例或价格') ElMessage.warning('请填写有效的加价后比例或价格')
return return
@@ -233,7 +260,9 @@ function assetNumberText(row: Listing, key: string) {
function priceBreakdown(row: Listing) { function priceBreakdown(row: Listing) {
const breakdown = row.asset_summary?.price_breakdown 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) { function breakdownNumber(row: Listing, key: string) {
@@ -278,7 +307,8 @@ function sellerCoinBasePrice(row: Listing) {
} }
function sellerRatio(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 if (value > 0) return value
const base = sellerCoinBasePrice(row) const base = sellerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0 return base > 0 ? getCoinWan(row) / base : 0
@@ -302,7 +332,10 @@ function buyerRatio(row: Listing) {
return base > 0 ? getCoinWan(row) / base : 0 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 consumablePrice = getListingConsumablePrice(row)
const coinWan = getCoinWan(row) const coinWan = getCoinWan(row)
if (mode === 'price') { if (mode === 'price') {
@@ -313,7 +346,8 @@ function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = pri
} }
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0) const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 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 const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio } return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
} }
@@ -412,7 +446,7 @@ function isExternalUpload(row: Listing) {
} }
function hasDefaultScreenshot(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) { function isDefaultScreenshot(url: string) {
@@ -425,12 +459,18 @@ function riskItems(row: Listing): RiskItem[] {
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' }) if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' }) if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' }) 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' }) items.push({ label: '押金不高于消耗品价值', level: 'danger' })
} }
if (readAssetNumber(row, 'daily_loss_m') <= 0) items.push({ label: '缺少每日损耗', level: 'warning' }) if (readAssetNumber(row, 'daily_loss_m') <= 0)
if (readAssetNumber(row, 'fire_level') <= 40) items.push({ label: '烽火等级接近下限', level: 'warning' }) items.push({ label: '缺少每日损耗', level: 'warning' })
if (!readAssetString(row, 'season_insurance')) 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' }) if (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
return items return items
} }
@@ -525,19 +565,35 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<div class="review-summary"> <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> <span>待审核</span>
<strong>{{ listings.length }}</strong> <strong>{{ listings.length }}</strong>
</button> </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> <span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong> <strong>{{ listings.filter(isExternalUpload).length }}</strong>
</button> </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> <span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong> <strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</button> </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> <span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong> <strong>{{ listings.filter(hasBanRecord).length }}</strong>
</button> </button>
@@ -546,7 +602,12 @@ function readError(error: unknown, fallback: string) {
<div class="review-workbench"> <div class="review-workbench">
<aside class="review-queue"> <aside class="review-queue">
<div class="queue-toolbar"> <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-select v-model="filters.risk" placeholder="风险筛选">
<el-option label="全部待审" value="all" /> <el-option label="全部待审" value="all" />
<el-option label="外部上传" value="external" /> <el-option label="外部上传" value="external" />
@@ -571,16 +632,23 @@ function readError(error: unknown, fallback: string) {
<div class="queue-meta"> <div class="queue-meta">
<span>{{ item.rank_level || '-' }}</span> <span>{{ item.rank_level || '-' }}</span>
<span>{{ assetText(item, 'season_insurance') }}</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> <span>KD {{ assetNumberText(item, 'secret_kd') }}</span>
</div> </div>
<div class="queue-footer"> <div class="queue-footer">
<span>{{ money(item.price) }} / {{ money(item.deposit_amount) }}</span> <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> <el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
</div> </div>
</button> </button>
<el-empty v-if="!loading && !filteredListings.length" description="暂无符合条件的待审核商品" /> <el-empty
v-if="!loading && !filteredListings.length"
description="暂无符合条件的待审核商品"
/>
</div> </div>
</aside> </aside>
@@ -595,12 +663,29 @@ function readError(error: unknown, fallback: string) {
<RouterLink :to="`/admin/listings/${selectedListing.id}`"> <RouterLink :to="`/admin/listings/${selectedListing.id}`">
<el-button>详情页</el-button> <el-button>详情页</el-button>
</RouterLink> </RouterLink>
<el-button :icon="Close" type="danger" :loading="submitting" @click="openReject(selectedListing)">拒绝</el-button> <el-button
<el-button :icon="Check" type="primary" :loading="submitting" @click="handleApprove(selectedListing)">通过</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> </div>
<div class="risk-strip"> <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 }} {{ risk.label }}
</el-tag> </el-tag>
</div> </div>
@@ -612,14 +697,19 @@ function readError(error: unknown, fallback: string) {
<h3>价格审核</h3> <h3>价格审核</h3>
<p>核对卖家提交价格平台加价规则和最终买家展示价</p> <p>核对卖家提交价格平台加价规则和最终买家展示价</p>
</div> </div>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)">调整价格</el-button> <el-button type="primary" plain @click="openPriceAdjust(selectedListing)"
>调整价格</el-button
>
</div> </div>
<div class="price-decision-grid"> <div class="price-decision-grid">
<div class="price-decision-card seller"> <div class="price-decision-card seller">
<span>卖家发布</span> <span>卖家发布</span>
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong> <strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p> <p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
<small>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }}</small> <small
>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品
{{ money(getListingConsumablePrice(selectedListing)) }}</small
>
</div> </div>
<div class="price-decision-card rule"> <div class="price-decision-card rule">
<span>当前加价规则</span> <span>当前加价规则</span>
@@ -648,29 +738,82 @@ function readError(error: unknown, fallback: string) {
<h3>账号属性</h3> <h3>账号属性</h3>
</div> </div>
<dl class="detail-list"> <dl class="detail-list">
<div><dt>上号方式</dt><dd>{{ selectedListing.login_platform || '-' }}</dd></div> <div>
<div><dt>区服</dt><dd>{{ selectedListing.server_region || '-' }}</dd></div> <dt>上号方式</dt>
<div><dt>段位</dt><dd>{{ selectedListing.rank_level || '-' }}</dd></div> <dd>{{ selectedListing.login_platform || '-' }}</dd>
<div><dt>烽火等级</dt><dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd></div> </div>
<div><dt>保险格数</dt><dd>{{ assetText(selectedListing, 'season_insurance') }}</dd></div> <div>
<div><dt>绝密KD</dt><dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd></div> <dt>区服</dt>
<div><dt>体力/负重</dt><dd>{{ assetText(selectedListing, 'stamina_level') }} / {{ assetText(selectedListing, 'load_level') }}</dd></div> <dd>{{ selectedListing.server_region || '-' }}</dd>
<div><dt>封禁记录</dt><dd>{{ banRecordText(selectedListing) }}</dd></div> </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> </dl>
</div> </div>
<div class="review-panel"> <div class="review-panel">
<div class="panel-title"> <div class="panel-title">
<h3>上传信息</h3> <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> </div>
<dl class="detail-list"> <dl class="detail-list">
<div><dt>上传人</dt><dd>{{ uploaderName(selectedListing) }}</dd></div> <div>
<div><dt>号主</dt><dd>{{ selectedListing.owner_phone || selectedListing.owner_nickname || selectedListing.owner_id }}</dd></div> <dt>上传人</dt>
<div><dt>联系电话</dt><dd>{{ contactPhone(selectedListing) }}</dd></div> <dd>{{ uploaderName(selectedListing) }}</dd>
<div><dt>常用地区</dt><dd>{{ commonRegionText(selectedListing) }}</dd></div> </div>
<div><dt>在线时间</dt><dd>{{ ownerOnlineText(selectedListing) }}</dd></div> <div>
<div><dt>提交时间</dt><dd>{{ formatDateTime(selectedListing.updated_at) }}</dd></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> </dl>
</div> </div>
</section> </section>
@@ -681,9 +824,16 @@ function readError(error: unknown, fallback: string) {
<span>额外消耗品约 {{ money(getListingConsumablePrice(selectedListing)) }}</span> <span>额外消耗品约 {{ money(getListingConsumablePrice(selectedListing)) }}</span>
</div> </div>
<div class="inventory-grid"> <div class="inventory-grid">
<div><span>AWM子弹</span><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong></div> <div>
<div><span>6</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong></div> <span>AWM子弹</span
<div><span>6</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong></div> ><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>
<div v-if="selectedResources.length" class="resource-table"> <div v-if="selectedResources.length" class="resource-table">
<div v-for="resource in selectedResources" :key="resource.key"> <div v-for="resource in selectedResources" :key="resource.key">
@@ -693,7 +843,9 @@ function readError(error: unknown, fallback: string) {
</div> </div>
</div> </div>
<div class="skin-list"> <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> <span v-if="!selectedSkins.length">暂无皮肤数据</span>
</div> </div>
</section> </section>
@@ -701,10 +853,18 @@ function readError(error: unknown, fallback: string) {
<section class="review-panel"> <section class="review-panel">
<div class="panel-title"> <div class="panel-title">
<h3>账号截图</h3> <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>
<div v-if="selectedListing.screenshot_urls?.length" class="screenshot-grid"> <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="账号截图" /> <img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span> <span v-if="isDefaultScreenshot(url)">默认图片</span>
</button> </button>
@@ -721,15 +881,32 @@ function readError(error: unknown, fallback: string) {
</main> </main>
</div> </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"> <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"> <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 }} {{ reason }}
</el-button> </el-button>
</div> </div>
<el-input v-model="rejectReason" type="textarea" :rows="5" placeholder="填写拒绝原因,号主会在通知中看到审核结果" /> <el-input
v-model="rejectReason"
type="textarea"
:rows="5"
placeholder="填写拒绝原因,号主会在通知中看到审核结果"
/>
</div> </div>
<template #footer> <template #footer>
<el-button @click="activeListing = null">取消</el-button> <el-button @click="activeListing = null">取消</el-button>
@@ -737,7 +914,12 @@ function readError(error: unknown, fallback: string) {
</template> </template>
</el-dialog> </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 v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
<div class="adjust-current"> <div class="adjust-current">
<div> <div>
@@ -746,11 +928,15 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<div> <div>
<span>调整后买家价</span> <span>调整后买家价</span>
<strong class="preview-value">{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}</strong> <strong class="preview-value">{{
previewMoney(priceAdjustPreview?.buyerTotalPrice || 0)
}}</strong>
</div> </div>
<div> <div>
<span>调整后买家比例</span> <span>调整后买家比例</span>
<strong class="preview-value">{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}</strong> <strong class="preview-value">{{
ratioText(priceAdjustPreview?.buyerRatio || 0)
}}</strong>
</div> </div>
</div> </div>
<el-radio-group v-model="priceAdjustMode" class="adjust-mode"> <el-radio-group v-model="priceAdjustMode" class="adjust-mode">
@@ -759,28 +945,61 @@ function readError(error: unknown, fallback: string) {
</el-radio-group> </el-radio-group>
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field"> <label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
<span>加价后比例</span> <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>
<label v-else class="adjust-field"> <label v-else class="adjust-field">
<span>加价后价格</span> <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>
<label class="adjust-field"> <label class="adjust-field">
<span>调价原因</span> <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> </label>
</div> </div>
<template #footer> <template #footer>
<el-button @click="priceAdjustListing = null">取消</el-button> <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> </template>
</el-dialog> </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"> <div v-if="evidenceListing" class="dialog-body">
<p><strong>{{ evidenceListing.title }}</strong></p> <p>
<div v-if="evidenceListing.screenshot_urls?.length" class="screenshot-grid dialog-screenshots"> <strong>{{ evidenceListing.title }}</strong>
<button v-for="url in evidenceListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)"> </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="账号截图" /> <img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span> <span v-if="isDefaultScreenshot(url)">默认图片</span>
</button> </button>
@@ -911,7 +1130,10 @@ function readError(error: unknown, fallback: string) {
padding: 12px; padding: 12px;
text-align: left; text-align: left;
cursor: pointer; 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, .queue-item:hover,
@@ -3,7 +3,12 @@ import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue' 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 { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { useMoney } from '@/shared/composables/useMoney' import { useMoney } from '@/shared/composables/useMoney'
import { import {
@@ -29,7 +34,11 @@ const filters = reactive<AdminListingQuery>({
const pageSize = ref(10) const pageSize = ref(10)
const currentPage = ref(1) const currentPage = ref(1)
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({ const {
loading,
data: listingPage,
load: loadListings,
} = useAdminTable<AdminListingPage>({
fetchFn: () => fetchFn: () =>
fetchAdminListings({ fetchAdminListings({
...filters, ...filters,
@@ -46,9 +55,13 @@ const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminLi
const listings = computed(() => listingPage.value.items) const listings = computed(() => listingPage.value.items)
const totalListings = computed(() => listingPage.value.total) const totalListings = computed(() => listingPage.value.total)
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length) const publishedCount = computed(
const rentedCount = computed(() => listings.value.filter((item) => item.status === 'rented').length) () => listings.value.filter(item => item.status === 'published').length
const pendingCount = computed(() => listings.value.filter((item) => item.review_status === 'pending').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 totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
const screenshotColumns = [ 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, 'helmet6') },
{ label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') }, { label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') },
{ label: '特殊刀皮', width: 110, read: (row: Listing) => skinGroupText(row, 'melee') }, { 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: 96, read: (row: Listing) => rentAndDepositText(row) },
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(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 ] as const
async function queryListings() { async function queryListings() {
@@ -90,7 +111,9 @@ function setStatusFilter(status: string) {
} }
function setReviewStatusFilter(reviewStatus: 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 = '' filters.status = ''
void queryListings() void queryListings()
} }
@@ -143,7 +166,7 @@ async function downloadTableScreenshot() {
async function createTableScreenshotBlob() { async function createTableScreenshotBlob() {
if (!listings.value.length) throw new Error('当前页暂无可截图数据') if (!listings.value.length) throw new Error('当前页暂无可截图数据')
const canvas = renderTableScreenshotCanvas(listings.value) 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('截图生成失败') if (!blob) throw new Error('截图生成失败')
return blob return blob
} }
@@ -165,14 +188,17 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
if (!measureCtx) throw new Error('截图画布初始化失败') if (!measureCtx) throw new Error('截图画布初始化失败')
measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif' measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif'
const rowLines = rows.map((row) => const rowLines = rows.map(row =>
screenshotColumns.map((column) => wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)), screenshotColumns.map(column =>
wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)
)
) )
const rowHeights = rowLines.map((lineGroups) => { const rowHeights = rowLines.map(lineGroups => {
const maxLines = Math.max(...lineGroups.map((lines) => lines.length), 1) const maxLines = Math.max(...lineGroups.map(lines => lines.length), 1)
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2) 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') const canvas = document.createElement('canvas')
canvas.width = Math.round(canvasWidth * ratio) canvas.width = Math.round(canvasWidth * ratio)
canvas.height = Math.round(canvasHeight * ratio) canvas.height = Math.round(canvasHeight * ratio)
@@ -189,7 +215,11 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
ctx.fillText('商品管理', paddingX, paddingY + 20) ctx.fillText('商品管理', paddingX, paddingY + 20)
ctx.fillStyle = '#8f9bba' ctx.fillStyle = '#8f9bba'
ctx.font = '12px Arial, "Microsoft YaHei", sans-serif' 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 let y = paddingY + titleHeight
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe') drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
@@ -211,7 +241,10 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
for (const [columnIndex, column] of screenshotColumns.entries()) { for (const [columnIndex, column] of screenshotColumns.entries()) {
const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-'] const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-']
ctx.fillStyle = columnIndex === 0 ? '#4b5563' : '#3f4654' 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) drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
x += column.width x += column.width
} }
@@ -240,18 +273,37 @@ function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: n
return lines 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) => { lines.forEach((line, index) => {
ctx.fillText(line, x, y + index * lineHeight) 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.fillStyle = color
ctx.fillRect(x, y, width, height) 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.strokeStyle = '#e6eaf2'
ctx.lineWidth = 1 ctx.lineWidth = 1
ctx.beginPath() ctx.beginPath()
@@ -311,7 +363,7 @@ function characterAndWeaponSkinText(row: Listing) {
{ label: '武器', key: 'weapon' }, { label: '武器', key: 'weapon' },
] ]
const parts = groups const parts = groups
.map((group) => { .map(group => {
const names = getSkinGroup(row, group.key) const names = getSkinGroup(row, group.key)
return names.length ? `${group.label}${names.join('、')}` : '' return names.length ? `${group.label}${names.join('、')}` : ''
}) })
@@ -421,9 +473,13 @@ function formatQuantity(value: number) {
</el-form> </el-form>
<div class="listing-action-strip"> <div class="listing-action-strip">
<el-button @click="resetFilters">重置</el-button> <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="copyTableScreenshot">复制截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button> <el-button :disabled="!listings.length" @click="downloadTableScreenshot"
>下载截图</el-button
>
</div> </div>
</div> </div>
</div> </div>
@@ -504,7 +560,9 @@ function formatQuantity(value: number) {
</el-table> </el-table>
<div class="table-pagination"> <div class="table-pagination">
<span class="pagination-summary">当前页 {{ listings.length }} {{ totalListings }} </span> <span class="pagination-summary"
>当前页 {{ listings.length }} {{ totalListings }} </span
>
<div class="pagination-controls"> <div class="pagination-controls">
<span class="pagination-size-label">每页</span> <span class="pagination-size-label">每页</span>
<el-select <el-select
@@ -520,7 +578,9 @@ function formatQuantity(value: number) {
</el-select> </el-select>
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span> <span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span>
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上页</el-button> <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>
</div> </div>
</section> </section>
@@ -1,63 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from "element-plus"; import { ElMessage } from 'element-plus'
import { Lock, User } from "@element-plus/icons-vue"; import { Lock, User } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from 'vue'
import { useRouter } from "vue-router"; import { useRouter } from 'vue-router'
import { fetchAdminCaptcha, type AdminCaptcha } from "@/features/admin"; import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
import { useAdminSessionStore } from "@/stores/adminSession"; import { useAdminSessionStore } from '@/stores/adminSession'
const router = useRouter(); const router = useRouter()
const adminSession = useAdminSessionStore(); const adminSession = useAdminSessionStore()
const loading = ref(false); const loading = ref(false)
const captchaLoading = ref(false); const captchaLoading = ref(false)
const captcha = ref<AdminCaptcha | null>(null); const captcha = ref<AdminCaptcha | null>(null)
const form = reactive({ const form = reactive({
username: "admin", username: 'admin',
password: "admin123456", password: 'admin123456',
captchaCode: "", captchaCode: '',
}); })
onMounted(loadCaptcha); onMounted(loadCaptcha)
async function loadCaptcha() { async function loadCaptcha() {
captchaLoading.value = true; captchaLoading.value = true
try { try {
captcha.value = await fetchAdminCaptcha(); captcha.value = await fetchAdminCaptcha()
form.captchaCode = ""; form.captchaCode = ''
} catch (error) { } catch (error) {
ElMessage.error(readError(error, "验证码加载失败")); ElMessage.error(readError(error, '验证码加载失败'))
} finally { } finally {
captchaLoading.value = false; captchaLoading.value = false
} }
} }
async function handleLogin() { async function handleLogin() {
loading.value = true; loading.value = true
try { try {
await adminSession.login( await adminSession.login(
form.username, form.username,
form.password, form.password,
captcha.value?.captcha_id || "", captcha.value?.captcha_id || '',
form.captchaCode form.captchaCode
); )
ElMessage.success("后台登录成功"); ElMessage.success('后台登录成功')
await router.push("/admin/dashboard"); await router.push('/admin/dashboard')
} catch (error) { } catch (error) {
ElMessage.error(readError(error, "后台登录失败")); ElMessage.error(readError(error, '后台登录失败'))
await loadCaptcha(); await loadCaptcha()
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }) const response = (error as { response?: { data?: { message?: string } } }).response
.response; return response?.data?.message || fallback
return response?.data?.message || fallback;
} }
return fallback; return fallback
} }
</script> </script>
@@ -151,16 +150,9 @@ function readError(error: unknown, fallback: string) {
min-height: 100vh; min-height: 100vh;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
background: radial-gradient( background:
circle at 18% 20%, radial-gradient(circle at 18% 20%, rgba(20, 119, 255, 0.14), transparent 42%),
rgba(20, 119, 255, 0.14), radial-gradient(circle at 82% 80%, rgba(109, 40, 217, 0.12), transparent 42%),
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%); linear-gradient(180deg, #0b1120 0%, #0f172a 60%, #111827 100%);
padding: 24px; padding: 24px;
} }
@@ -172,11 +164,7 @@ function readError(error: unknown, fallback: string) {
width: 50vw; width: 50vw;
height: 50vw; height: 50vw;
border-radius: 50%; border-radius: 50%;
background: radial-gradient( background: radial-gradient(circle, rgba(20, 119, 255, 0.22), transparent 65%);
circle,
rgba(20, 119, 255, 0.22),
transparent 65%
);
filter: blur(80px); filter: blur(80px);
pointer-events: none; pointer-events: none;
} }
@@ -185,11 +173,7 @@ function readError(error: unknown, fallback: string) {
left: auto; left: auto;
right: -10%; right: -10%;
bottom: -10%; bottom: -10%;
background: radial-gradient( background: radial-gradient(circle, rgba(109, 40, 217, 0.18), transparent 65%);
circle,
rgba(109, 40, 217, 0.18),
transparent 65%
);
} }
.admin-login-card { .admin-login-card {
@@ -203,7 +187,8 @@ function readError(error: unknown, fallback: string) {
border-radius: 20px; border-radius: 20px;
background: rgba(15, 23, 42, 0.55); background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(24px); 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); inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden; overflow: hidden;
} }
@@ -214,11 +199,8 @@ function readError(error: unknown, fallback: string) {
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: 40px 36px; padding: 40px 36px;
background: radial-gradient( background:
circle at 30% 20%, radial-gradient(circle at 30% 20%, rgba(20, 119, 255, 0.12), transparent 50%),
rgba(20, 119, 255, 0.12),
transparent 50%
),
radial-gradient(circle at 80% 90%, rgba(109, 40, 217, 0.1), 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)); 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; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
border-radius: 10px; border-radius: 10px;
padding: 0 12px; 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) { .admin-form :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.admin-form :deep(.el-input__wrapper.is-focus) { .admin-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.06); 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); 0 0 0 3px rgba(20, 119, 255, 0.08);
} }
.admin-form :deep(.el-input__inner) { .admin-form :deep(.el-input__inner) {
@@ -352,7 +337,9 @@ function readError(error: unknown, fallback: string) {
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: background 0.2s, border-color 0.2s; transition:
background 0.2s,
border-color 0.2s;
} }
.captcha-image-button:hover { .captcha-image-button:hover {
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.07);
@@ -383,7 +370,9 @@ function readError(error: unknown, fallback: string) {
background: linear-gradient(135deg, #1477ff, #2f8dff); background: linear-gradient(135deg, #1477ff, #2f8dff);
border: none; border: none;
box-shadow: 0 10px 28px rgba(20, 119, 255, 0.25); 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 { .admin-login-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -2,7 +2,12 @@
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue' 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 { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
@@ -19,7 +24,15 @@ const passwordAdmin = ref<AdminMgrUser | null>(null)
const passwordForm = ref({ old_password: '', new_password: '' }) const passwordForm = ref({ old_password: '', new_password: '' })
const passwordSubmitting = ref(false) 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, fetchFn: fetchAdminMgrUsers,
}) })
@@ -46,11 +59,15 @@ function openPassword(row: AdminMgrUser) {
async function handleDelete(row: AdminMgrUser) { async function handleDelete(row: AdminMgrUser) {
try { try {
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', { await ElMessageBox.confirm(
confirmButtonText: '确认删除', `确定要删除管理员「${row.username}」吗?此操作不可撤销。`,
cancelButtonText: '取消', '删除确认',
type: 'warning', {
}) confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await deleteAdminMgrUser(row.id) await deleteAdminMgrUser(row.id)
ElMessage.success('管理员已删除') ElMessage.success('管理员已删除')
await loadAdmins() await loadAdmins()
@@ -130,7 +147,9 @@ const statusLabel: Record<string, string> = {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="最后登录" min-width="170"> <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>
<el-table-column label="操作" width="300" fixed="right"> <el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
@@ -152,18 +171,10 @@ const statusLabel: Record<string, string> = {
/> />
<!-- 新建/编辑对话框 --> <!-- 新建/编辑对话框 -->
<AdminUserDialog <AdminUserDialog v-model="showDialog" :admin="editingAdmin" @saved="loadAdmins" />
v-model="showDialog"
:admin="editingAdmin"
@saved="loadAdmins"
/>
<!-- 角色分配对话框 --> <!-- 角色分配对话框 -->
<AssignRolesDialog <AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
v-model="showRolesDialog"
:admin="rolesAdmin"
@saved="loadAdmins"
/>
<!-- 修改密码对话框 --> <!-- 修改密码对话框 -->
<el-dialog <el-dialog
@@ -174,15 +185,27 @@ const statusLabel: Record<string, string> = {
> >
<div class="dialog-body"> <div class="dialog-body">
<el-form-item label="原密码" class="full-control"> <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>
<el-form-item label="新密码" class="full-control"> <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> </el-form-item>
</div> </div>
<template #footer> <template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button> <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> </template>
</el-dialog> </el-dialog>
</section> </section>
@@ -3,7 +3,17 @@ import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' 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 { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' 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 snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常')) 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) onMounted(loadOrder)
@@ -138,9 +150,18 @@ function formatHandoffRecordType(type: string) {
<RouterLink to="/admin/orders"> <RouterLink to="/admin/orders">
<el-button>返回列表</el-button> <el-button>返回列表</el-button>
</RouterLink> </RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button> <el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button> >标记异常</el-button
<el-button type="primary" :loading="refunding" :disabled="refundStatus?.refund_status === 'refunded'" @click="handleRefund"> >
<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' ? '已退款' : '人工退款' }} {{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
</el-button> </el-button>
</div> </div>
@@ -170,7 +191,9 @@ function formatHandoffRecordType(type: string) {
<div v-if="refundStatus" class="metric-card"> <div v-if="refundStatus" class="metric-card">
<span>退款状态</span> <span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong> <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>
</div> </div>
@@ -199,10 +222,22 @@ function formatHandoffRecordType(type: string) {
<pre>{{ snapshotText }}</pre> <pre>{{ snapshotText }}</pre>
</div> </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"> <div v-if="order" class="dialog-body">
<p><strong>{{ order.order_no }}</strong> · {{ order.title }}</p> <p>
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写客服操作原因,会写入审计日志并通知双方" /> <strong>{{ order.order_no }}</strong> · {{ order.title }}
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写客服操作原因,会写入审计日志并通知双方"
/>
</div> </div>
<template #footer> <template #footer>
<el-button @click="actionType = ''">取消</el-button> <el-button @click="actionType = ''">取消</el-button>
@@ -32,7 +32,7 @@ async function handlePageChange() {
const filteredOrders = computed(() => { const filteredOrders = computed(() => {
if (!status.value) return orders.value 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> </script>
@@ -117,11 +117,15 @@ async function handleDelete(row: PaymentConfig) {
async function handleExportBackup() { async function handleExportBackup() {
try { try {
await ElMessageBox.confirm('备份文件会包含支付密钥明文,请妥善保管。确定导出吗?', '导出支付配置备份', { await ElMessageBox.confirm(
type: 'warning', '备份文件会包含支付密钥明文,请妥善保管。确定导出吗?',
confirmButtonText: '导出', '导出支付配置备份',
cancelButtonText: '取消', {
}) type: 'warning',
confirmButtonText: '导出',
cancelButtonText: '取消',
}
)
exporting.value = true exporting.value = true
const { blob, filename } = await exportPaymentConfigBackup() const { blob, filename } = await exportPaymentConfigBackup()
@@ -162,7 +166,7 @@ async function handleImportFile(event: Event) {
type: 'warning', type: 'warning',
confirmButtonText: '导入', confirmButtonText: '导入',
cancelButtonText: '取消', cancelButtonText: '取消',
}, }
) )
importing.value = true importing.value = true
@@ -256,18 +260,52 @@ function deleteDisabledReason(row: PaymentConfig) {
</div> </div>
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="filterProvider" placeholder="支付服务商" style="width: 150px" @change="loadConfigs"> <el-select
<el-option v-for="opt in providerOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> 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>
<el-select v-model="filterStatus" placeholder="状态" style="width: 120px" @change="loadConfigs"> <el-select
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> 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>
<el-select v-model="filterEnvironment" placeholder="环境" style="width: 120px" @change="loadConfigs"> <el-select
<el-option v-for="opt in environmentOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> 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-select>
<el-button :icon="Refresh" @click="loadConfigs">刷新</el-button> <el-button :icon="Refresh" @click="loadConfigs">刷新</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup">导入备份</el-button> <el-button :icon="Upload" :loading="importing" @click="handleImportBackup"
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup">导出备份</el-button> >导入备份</el-button
>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup"
>导出备份</el-button
>
<el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button> <el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button>
</div> </div>
@@ -279,12 +317,7 @@ function deleteDisabledReason(row: PaymentConfig) {
@change="handleImportFile" @change="handleImportFile"
/> />
<el-table <el-table :data="filteredConfigs" v-loading="loading" class="payment-config-table" stripe>
: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="name" label="配置名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="provider" label="服务商" width="120"> <el-table-column prop="provider" label="服务商" width="120">
<template #default="{ row }"> <template #default="{ row }">
@@ -327,10 +360,20 @@ function deleteDisabledReason(row: PaymentConfig) {
<el-table-column label="操作" width="226" class-name="operation-column"> <el-table-column label="操作" width="226" class-name="operation-column">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <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>
<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-button>
<el-tooltip :content="deleteDisabledReason(row)" placement="top"> <el-tooltip :content="deleteDisabledReason(row)" placement="top">
@@ -13,7 +13,11 @@ const editingRole = ref<Role | null>(null)
const showPermsDialog = ref(false) const showPermsDialog = ref(false)
const permsRole = ref<Role | null>(null) const permsRole = ref<Role | null>(null)
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({ const {
loading,
data: roles,
load: loadRoles,
} = useAdminTable<Role[]>({
fetchFn: fetchRoles, fetchFn: fetchRoles,
}) })
@@ -34,11 +38,15 @@ function openPerms(row: Role) {
async function handleDelete(row: Role) { async function handleDelete(row: Role) {
try { try {
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', { await ElMessageBox.confirm(
confirmButtonText: '确认删除', `确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`,
cancelButtonText: '取消', '删除确认',
type: 'warning', {
}) confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await deleteRole(row.id) await deleteRole(row.id)
ElMessage.success('角色已删除') ElMessage.success('角色已删除')
await loadRoles() await loadRoles()
@@ -85,18 +93,10 @@ async function handleDelete(row: Role) {
</el-table> </el-table>
<!-- 新建/编辑对话框 --> <!-- 新建/编辑对话框 -->
<RoleDialog <RoleDialog v-model="showDialog" :role="editingRole" @saved="loadRoles" />
v-model="showDialog"
:role="editingRole"
@saved="loadRoles"
/>
<!-- 权限分配对话框 --> <!-- 权限分配对话框 -->
<AssignPermissionsDialog <AssignPermissionsDialog v-model="showPermsDialog" :role="permsRole" @saved="loadRoles" />
v-model="showPermsDialog"
:role="permsRole"
@saved="loadRoles"
/>
</section> </section>
</template> </template>
@@ -69,25 +69,39 @@ const agreementsVisible = ref(false)
const postRentalNoticeVisible = ref(false) const postRentalNoticeVisible = ref(false)
const generalVisible = ref(false) const generalVisible = ref(false)
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null) const publishConfig = computed(
const salePriceConfig = computed(() => configs.value.find((item) => item.key === 'listing.sale_price_config') || null) () => configs.value.find(item => item.key === 'listing.publish_options') || 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 salePriceConfig = computed(
const listingPublishAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_agreements') || null) () => configs.value.find(item => item.key === 'listing.sale_price_config') || 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 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(() => const regularConfigs = computed(() =>
configs.value.filter( configs.value.filter(
(item) => item =>
item.key !== 'listing.publish_options' && item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' && item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners' && item.key !== 'mobile.home_banners' &&
item.key !== 'listing.publish_agreements' && item.key !== 'listing.publish_agreements' &&
item.key !== 'order.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 listingPublishAgreementStats = computed(() => {
const agreements = parseListingPublishAgreements(listingPublishAgreementsConfig.value?.value || '') const agreements = parseListingPublishAgreements(
listingPublishAgreementsConfig.value?.value || ''
)
return { return {
saleTitle: agreements.virtual_asset_sale.title || '出售协议', saleTitle: agreements.virtual_asset_sale.title || '出售协议',
sellerTitle: agreements.seller_agreement.title || '号主协议', sellerTitle: agreements.seller_agreement.title || '号主协议',
@@ -199,12 +215,24 @@ function parseOrderAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultOrderAgreements) const parsed = safeParseJSON(raw, defaultOrderAgreements)
return { return {
virtual_asset_purchase: { virtual_asset_purchase: {
title: readText(parsed.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title), title: readText(
content: readText(parsed.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content), parsed.virtual_asset_purchase?.title,
defaultOrderAgreements.virtual_asset_purchase.title
),
content: readText(
parsed.virtual_asset_purchase?.content,
defaultOrderAgreements.virtual_asset_purchase.content
),
}, },
renter_agreement: { renter_agreement: {
title: readText(parsed.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title), title: readText(
content: readText(parsed.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content), 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) const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
return { return {
virtual_asset_sale: { virtual_asset_sale: {
title: readText(parsed.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title), title: readText(
content: readText(parsed.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content), parsed.virtual_asset_sale?.title,
defaultListingPublishAgreements.virtual_asset_sale.title
),
content: readText(
parsed.virtual_asset_sale?.content,
defaultListingPublishAgreements.virtual_asset_sale.content
),
}, },
seller_agreement: { seller_agreement: {
title: readText(parsed.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title), title: readText(
content: readText(parsed.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content), 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> <span>管理首页公告滚动内容和顶部轮播图保存后移动端会从接口读取最新配置</span>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)">编辑公告</el-button> <el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)"
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)">编辑轮播图</el-button> >编辑公告</el-button
>
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)"
>编辑轮播图</el-button
>
</div> </div>
</div> </div>
<div class="publish-stat-grid home-stat-grid"> <div class="publish-stat-grid home-stat-grid">
@@ -378,7 +422,9 @@ function formatConfigValue(row: SystemConfig) {
<h2>发布协议配置</h2> <h2>发布协议配置</h2>
<span>管理发布账号前必须勾选确认的虚拟资产出售协议和号主协议</span> <span>管理发布账号前必须勾选确认的虚拟资产出售协议和号主协议</span>
</div> </div>
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)">编辑发布协议</el-button> <el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)"
>编辑发布协议</el-button
>
</div> </div>
<div class="publish-stat-grid home-stat-grid"> <div class="publish-stat-grid home-stat-grid">
<div class="publish-stat"> <div class="publish-stat">
@@ -2,7 +2,12 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ref } from 'vue' 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 { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels' import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
@@ -12,7 +17,15 @@ const submitting = ref(false)
const activeUser = ref<AdminUserItem | null>(null) const activeUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('') 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, fetchFn: fetchAdminUsers,
}) })
@@ -81,10 +94,23 @@ function readError(error: unknown, fallback: string) {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150"> <el-table-column label="操作" width="150">
<template #default="{ row }"> <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>
<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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -98,10 +124,22 @@ function readError(error: unknown, fallback: string) {
@page-change="loadUsers" @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"> <div v-if="activeUser" class="dialog-body">
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p> <p>
<el-input v-model="freezeReason" type="textarea" :rows="4" placeholder="填写冻结原因,便于审计追踪" /> <strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}
</p>
<el-input
v-model="freezeReason"
type="textarea"
:rows="4"
placeholder="填写冻结原因,便于审计追踪"
/>
</div> </div>
<template #footer> <template #footer>
<el-button @click="activeUser = null">取消</el-button> <el-button @click="activeUser = null">取消</el-button>
@@ -19,10 +19,14 @@ const filters = reactive({
}) })
const inAmount = computed(() => 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(() => 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) onMounted(loadLedger)
@@ -89,7 +93,9 @@ function directionLabel(direction: string) {
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button> <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>
</div> </div>
@@ -134,7 +140,9 @@ function directionLabel(direction: string) {
</el-table-column> </el-table-column>
<el-table-column label="订单" min-width="160"> <el-table-column label="订单" min-width="160">
<template #default="{ row }"> <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> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -104,7 +104,7 @@ async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
cancelButtonText: '取消', cancelButtonText: '取消',
inputType: 'textarea', inputType: 'textarea',
inputPlaceholder: '输入打款备注...', inputPlaceholder: '输入打款备注...',
inputValidator: (value) => { inputValidator: value => {
return value && value.trim().length > 0 return value && value.trim().length > 0
}, },
inputErrorMessage: '请输入打款备注', inputErrorMessage: '请输入打款备注',
@@ -202,19 +202,13 @@ function onDetailDialogSaved() {
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleFilter"> <el-button type="primary" @click="handleFilter"> 查询 </el-button>
查询
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<!-- 提现列表 --> <!-- 提现列表 -->
<el-table <el-table v-loading="loading" class="table-panel" :data="withdrawals">
v-loading="loading"
class="table-panel"
:data="withdrawals"
>
<el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" /> <el-table-column prop="withdraw_no" label="提现单号" min-width="180" />
<el-table-column label="用户" min-width="140"> <el-table-column label="用户" min-width="140">
@@ -225,9 +219,7 @@ function onDetailDialogSaved() {
</el-table-column> </el-table-column>
<el-table-column label="提现金额" width="120" align="right"> <el-table-column label="提现金额" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600"> <span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span>
¥{{ row.amount.toFixed(2) }}
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="收款方式" min-width="180"> <el-table-column label="收款方式" min-width="180">
@@ -257,9 +249,7 @@ function onDetailDialogSaved() {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openDetail(row)"> <el-button size="small" @click="openDetail(row)"> 详情 </el-button>
详情
</el-button>
<el-button <el-button
v-if="row.status === 'pending'" v-if="row.status === 'pending'"
size="small" size="small"
@@ -29,8 +29,13 @@ export interface PaginatedResult<T> {
page_size: number page_size: number
} }
export async function fetchAnnouncements(params: AnnouncementListParams = {}): Promise<PaginatedResult<Announcement>> { export async function fetchAnnouncements(
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>('/announcements', { params }) params: AnnouncementListParams = {}
): Promise<PaginatedResult<Announcement>> {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>(
'/announcements',
{ params }
)
return data.data return data.data
} }
@@ -1,7 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
import { marked } from 'marked' import { marked } from 'marked'
@@ -82,14 +90,20 @@ function getCategoryLabel(category: string) {
class="category-tag" class="category-tag"
:style="{ :style="{
borderColor: getCategoryInfo(announcement.category)?.color || '#94a3b8', 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) }} {{ getCategoryLabel(announcement.category) }}
</el-tag> </el-tag>
<el-tag v-if="announcement.is_pinned" type="warning" effect="plain" size="small">置顶</el-tag> <el-tag v-if="announcement.is_pinned" type="warning" effect="plain" size="small"
<el-tag v-if="announcement.is_important" type="danger" effect="plain" size="small">重要</el-tag> >置顶</el-tag
>
<el-tag v-if="announcement.is_important" type="danger" effect="plain" size="small"
>重要</el-tag
>
</div> </div>
<h1 class="detail-title">{{ announcement.title }}</h1> <h1 class="detail-title">{{ announcement.title }}</h1>
@@ -1,7 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' 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 { fetchAnnouncements, type Announcement } from '@/features/announcement'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
@@ -114,7 +121,10 @@ function getCategoryLabel(category: string) {
effect="plain" effect="plain"
size="small" size="small"
class="category-tag" 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> <el-icon><component :is="getCategoryInfo(item.category)?.icon || Bell" /></el-icon>
{{ getCategoryLabel(item.category) }} {{ 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_pinned" type="warning" effect="plain" size="small">置顶</el-tag>
<el-tag v-if="item.is_important" type="danger" effect="plain" size="small">重要</el-tag> <el-tag v-if="item.is_important" type="danger" effect="plain" size="small">重要</el-tag>
</div> </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> </div>
<h3 class="announcement-title">{{ item.title }}</h3> <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"> <div class="announcement-footer">
<span class="view-count"> <span class="view-count">
<el-icon><Tickets /></el-icon> <el-icon><Tickets /></el-icon>
@@ -159,9 +173,7 @@ function getCategoryLabel(category: string) {
padding: 32px; padding: 32px;
border: 1px solid #e6eaf2; border: 1px solid #e6eaf2;
border-radius: 12px; border-radius: 12px;
background: background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(99, 102, 241, 0.06)), #ffffff;
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); box-shadow: 0 14px 36px rgba(17, 24, 39, 0.06);
} }
+6 -3
View File
@@ -26,9 +26,12 @@ export interface LoginData {
} }
export async function sendSmsCode(phone: string) { 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 }>>(
phone, '/auth/sms/send',
}) {
phone,
}
)
return data.data return data.data
} }
@@ -15,9 +15,12 @@ export interface NotificationItem {
} }
export async function fetchNotifications(page = 1, pageSize = 20) { 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>>>(
params: { page, page_size: pageSize }, '/notifications',
}) {
params: { page, page_size: pageSize },
}
)
return data.data return data.data
} }
+58 -64
View File
@@ -1,79 +1,78 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from "element-plus"; import { ElMessage } from 'element-plus'
import { ChatLineRound, Iphone } from "@element-plus/icons-vue"; import { ChatLineRound, Iphone } from '@element-plus/icons-vue'
import { onUnmounted, reactive, ref } from "vue"; import { onUnmounted, reactive, ref } from 'vue'
import { useRouter } from "vue-router"; import { useRouter } from 'vue-router'
import { sendSmsCode } from "@/features/auth/api/auth"; import { sendSmsCode } from '@/features/auth/api/auth'
import { useSessionStore } from "@/stores/session"; import { useSessionStore } from '@/stores/session'
const router = useRouter(); const router = useRouter()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const sending = ref(false); const sending = ref(false)
const countDown = ref(0); const countDown = ref(0)
const form = reactive({ const form = reactive({
phone: "", phone: '',
code: "", code: '',
}); })
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null
function startCountDown() { function startCountDown() {
countDown.value = 60; countDown.value = 60
timer = setInterval(() => { timer = setInterval(() => {
countDown.value--; countDown.value--
if (countDown.value <= 0) { if (countDown.value <= 0) {
clearInterval(timer!); clearInterval(timer!)
timer = null; timer = null
} }
}, 1000); }, 1000)
} }
onUnmounted(() => { onUnmounted(() => {
if (timer) { if (timer) {
clearInterval(timer); clearInterval(timer)
timer = null; timer = null
} }
}); })
async function handleSendCode() { async function handleSendCode() {
if (!form.phone.trim()) { if (!form.phone.trim()) {
ElMessage.warning("请输入手机号"); ElMessage.warning('请输入手机号')
return; return
} }
sending.value = true; sending.value = true
try { try {
await sendSmsCode(form.phone); await sendSmsCode(form.phone)
ElMessage.success("验证码已发送,请注意查收"); ElMessage.success('验证码已发送,请注意查收')
startCountDown(); startCountDown()
} catch (error) { } catch (error) {
ElMessage.error(readError(error, "验证码发送失败")); ElMessage.error(readError(error, '验证码发送失败'))
} finally { } finally {
sending.value = false; sending.value = false
} }
} }
async function handleLogin() { async function handleLogin() {
loading.value = true; loading.value = true
try { try {
await session.login(form.phone, form.code); await session.login(form.phone, form.code)
ElMessage.success("登录成功"); ElMessage.success('登录成功')
await router.push("/"); await router.push('/')
} catch (error) { } catch (error) {
ElMessage.error(readError(error, "登录失败")); ElMessage.error(readError(error, '登录失败'))
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }) const response = (error as { response?: { data?: { message?: string } } }).response
.response; return response?.data?.message || fallback
return response?.data?.message || fallback;
} }
return fallback; return fallback
} }
</script> </script>
@@ -131,13 +130,7 @@ function readError(error: unknown, fallback: string) {
:loading="sending" :loading="sending"
@click="handleSendCode" @click="handleSendCode"
> >
{{ {{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
@@ -179,7 +172,8 @@ function readError(error: unknown, fallback: string) {
border-radius: 20px; border-radius: 20px;
background: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(20px); 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; 0 1px 0 rgba(255, 255, 255, 0.6) inset;
overflow: hidden; overflow: hidden;
} }
@@ -190,16 +184,9 @@ function readError(error: unknown, fallback: string) {
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: 40px 36px; padding: 40px 36px;
background: radial-gradient( background:
circle at 30% 20%, radial-gradient(circle at 30% 20%, rgba(20, 119, 255, 0.08), transparent 50%),
rgba(20, 119, 255, 0.08), radial-gradient(circle at 80% 90%, rgba(109, 40, 217, 0.06), transparent 50%),
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)); 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; box-shadow: 0 0 0 1px #e2e8f0 inset;
border-radius: 10px; border-radius: 10px;
padding: 0 12px; 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) { .user-form :deep(.el-input__wrapper:hover) {
background: #ffffff; background: #ffffff;
} }
.user-form :deep(.el-input__wrapper.is-focus) { .user-form :deep(.el-input__wrapper.is-focus) {
background: #ffffff; 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); 0 0 0 3px rgba(20, 119, 255, 0.08);
} }
.user-form :deep(.el-input__inner) { .user-form :deep(.el-input__inner) {
@@ -337,7 +327,9 @@ function readError(error: unknown, fallback: string) {
border-color: #1477ff; border-color: #1477ff;
color: #1477ff; color: #1477ff;
box-shadow: none; 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) { .user-code-row .el-button:hover:not(:disabled) {
background: #1477ff; background: #1477ff;
@@ -360,7 +352,9 @@ function readError(error: unknown, fallback: string) {
background: linear-gradient(135deg, #1477ff, #2f8dff); background: linear-gradient(135deg, #1477ff, #2f8dff);
border: none; border: none;
box-shadow: 0 10px 28px rgba(20, 119, 255, 0.22); 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 { .user-login-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -1,41 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from "vue-router"; import { RouterLink, useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from "vant"; import { showToast, showDialog } from 'vant'
import { useSessionStore } from "@/stores/session"; import { useSessionStore } from '@/stores/session'
import { useSmsCountdown } from "@/shared/composables/useSmsCountdown"; import { useSmsCountdown } from '@/shared/composables/useSmsCountdown'
const router = useRouter(); const router = useRouter()
const route = useRoute(); const route = useRoute()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const agreed = ref(false); const agreed = ref(false)
const form = reactive({ const form = reactive({
phone: "", phone: '',
code: "", code: '',
}); })
const { countDown, sending, handleSendCode, readError } = useSmsCountdown(); const { countDown, sending, handleSendCode, readError } = useSmsCountdown()
async function handleLogin() { async function handleLogin() {
if (!agreed.value) { if (!agreed.value) {
showDialog({ showDialog({
title: "提示", title: '提示',
message: "请先阅读并同意用户协议和隐私政策", message: '请先阅读并同意用户协议和隐私政策',
}); })
return; return
} }
loading.value = true; loading.value = true
try { try {
await session.login(form.phone, form.code); await session.login(form.phone, form.code)
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/m/profile"; const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/m/profile'
await router.replace(redirect); await router.replace(redirect)
} catch { } catch {
showToast({ message: "登录失败,请检查手机号和验证码", icon: "cross" }); showToast({ message: '登录失败,请检查手机号和验证码', icon: 'cross' })
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
</script> </script>
@@ -58,12 +58,7 @@ async function handleLogin() {
<h1>登录</h1> <h1>登录</h1>
<label class="auth-input-row"> <label class="auth-input-row">
<input <input v-model="form.phone" type="tel" maxlength="11" placeholder="手机号" />
v-model="form.phone"
type="tel"
maxlength="11"
placeholder="手机号"
/>
</label> </label>
<label class="auth-input-row code-row"> <label class="auth-input-row code-row">
@@ -83,13 +78,7 @@ async function handleLogin() {
class="code-btn" class="code-btn"
@click="handleSendCode(form.phone)" @click="handleSendCode(form.phone)"
> >
{{ {{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
</van-button> </van-button>
</span> </span>
</label> </label>
@@ -116,9 +105,7 @@ async function handleLogin() {
登录 登录
</van-button> </van-button>
<RouterLink to="/m/register" class="secondary-entry"> <RouterLink to="/m/register" class="secondary-entry"> 还没有账号创建一个 </RouterLink>
还没有账号创建一个
</RouterLink>
</div> </div>
</section> </section>
</main> </main>
@@ -1,256 +1,256 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from 'vue-router'
import { useSessionStore } from "@/stores/session"; import { useSessionStore } from '@/stores/session'
import { showDialog, showToast } from "vant"; import { showDialog, showToast } from 'vant'
import MobileBottomNav from "@/components/MobileBottomNav.vue"; import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchWalletBalance, fetchWalletLedger, type WalletLedger } from "@/features/wallet/api/wallet"; import {
import { fetchPostRentalNotice, type PostRentalNotice } from "@/features/orders/api/orders"; fetchWalletBalance,
import { formatDateMinute } from "@/utils/time"; fetchWalletLedger,
import { uploadFile } from "@/shared/api/files"; 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 session = useSessionStore()
const router = useRouter(); const router = useRouter()
const route = useRoute(); const route = useRoute()
/** 数据项 */ /** 数据项 */
const balance = ref(0); const balance = ref(0)
const showSettings = ref(false); const showSettings = ref(false)
const showProfileEditor = ref(false); const showProfileEditor = ref(false)
const savingProfile = ref(false); const savingProfile = ref(false)
const profileForm = reactive({ const profileForm = reactive({
nickname: "", nickname: '',
avatar_url: "", avatar_url: '',
}); })
const avatarFileInput = ref<HTMLInputElement | null>(null); const avatarFileInput = ref<HTMLInputElement | null>(null)
const uploadingAvatar = ref(false); const uploadingAvatar = ref(false)
function triggerAvatarUpload() { function triggerAvatarUpload() {
avatarFileInput.value?.click(); avatarFileInput.value?.click()
} }
async function handleAvatarFileChange(event: Event) { async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement
const file = input.files?.[0]; const file = input.files?.[0]
input.value = ""; input.value = ''
if (!file) return; if (!file) return
uploadingAvatar.value = true; uploadingAvatar.value = true
const toast = showToast({ const toast = showToast({
type: "loading", type: 'loading',
message: "上传中...", message: '上传中...',
forbidClick: true, forbidClick: true,
duration: 0, duration: 0,
}); })
try { try {
const uploaded = await uploadFile(file, "avatar"); const uploaded = await uploadFile(file, 'avatar')
profileForm.avatar_url = uploaded.url; profileForm.avatar_url = uploaded.url
showToast({ message: "上传成功", icon: "passed" }); showToast({ message: '上传成功', icon: 'passed' })
} catch (error) { } catch (error) {
showToast({ message: readError(error, "上传失败"), icon: "cross" }); showToast({ message: readError(error, '上传失败'), icon: 'cross' })
} finally { } finally {
uploadingAvatar.value = false; uploadingAvatar.value = false
} }
} }
// 收支明细列表 // 收支明细列表
const showLedgers = ref(false); const showLedgers = ref(false)
const loadingLedgers = ref(false); const loadingLedgers = ref(false)
const ledgers = ref<WalletLedger[]>([]); const ledgers = ref<WalletLedger[]>([])
// 租后须知 // 租后须知
const showPostRentalNotice = ref(false); const showPostRentalNotice = ref(false)
const loadingPostRentalNotice = ref(false); const loadingPostRentalNotice = ref(false)
const postRentalNotice = ref<PostRentalNotice | null>(null); const postRentalNotice = ref<PostRentalNotice | null>(null)
const settingsGroups = [ const settingsGroups = [
{ {
title: "账号与安全", title: '账号与安全',
items: [ items: [
{ label: "资料更改", icon: "edit", action: "profile" }, { label: '资料更改', icon: 'edit', action: 'profile' },
{ label: "实名认证", icon: "idcard", action: "realname" }, { label: '实名认证', icon: 'idcard', action: 'realname' },
{ label: "注销账号", icon: "delete-o", action: "cancel-account" }, { label: '注销账号', icon: 'delete-o', action: 'cancel-account' },
], ],
}, },
{ {
title: "法律与隐私", title: '法律与隐私',
items: [ items: [
{ label: "隐私政策", icon: "shield-o", action: "privacy" }, { label: '隐私政策', icon: 'shield-o', action: 'privacy' },
{ label: "用户协议", icon: "description", action: "terms" }, { label: '用户协议', icon: 'description', action: 'terms' },
], ],
}, },
]; ]
onMounted(() => { onMounted(() => {
if (!isLoggedIn.value) { if (!isLoggedIn.value) {
router.replace({ path: "/m/login", query: { redirect: route.fullPath } }); router.replace({ path: '/m/login', query: { redirect: route.fullPath } })
} else { } else {
loadBalance(); loadBalance()
} }
}); })
async function loadBalance() { async function loadBalance() {
try { try {
const wallet = await fetchWalletBalance(); const wallet = await fetchWalletBalance()
balance.value = wallet.available_balance; balance.value = wallet.available_balance
} catch { } catch {
// 失败静默处理,显示为默认0 // 失败静默处理,显示为默认0
} }
} }
async function openLedgers() { async function openLedgers() {
showLedgers.value = true; showLedgers.value = true
loadingLedgers.value = true; loadingLedgers.value = true
try { try {
const res = await fetchWalletLedger(1, 20); const res = await fetchWalletLedger(1, 20)
ledgers.value = res.items; ledgers.value = res.items
} catch { } catch {
showToast({ message: "无法获取账单明细", icon: "cross" }); showToast({ message: '无法获取账单明细', icon: 'cross' })
} finally { } finally {
loadingLedgers.value = false; loadingLedgers.value = false
} }
} }
async function openPostRentalNotice() { async function openPostRentalNotice() {
showPostRentalNotice.value = true; showPostRentalNotice.value = true
if (!postRentalNotice.value) { if (!postRentalNotice.value) {
loadingPostRentalNotice.value = true; loadingPostRentalNotice.value = true
try { try {
postRentalNotice.value = await fetchPostRentalNotice(); postRentalNotice.value = await fetchPostRentalNotice()
} catch { } catch {
showToast({ message: "无法获取租后须知", icon: "cross" }); showToast({ message: '无法获取租后须知', icon: 'cross' })
} finally { } finally {
loadingPostRentalNotice.value = false; loadingPostRentalNotice.value = false
} }
} }
} }
function handleWithdraw() { function handleWithdraw() {
showDialog({ showDialog({
title: "提现提示", title: '提现提示',
message: "为了您的资金安全,提现请前往电脑端网页版进行操作。", message: '为了您的资金安全,提现请前往电脑端网页版进行操作。',
}); })
} }
function goOrders(tabKey: string) { function goOrders(tabKey: string) {
router.push({ path: "/m/orders", query: { tab: tabKey } }); router.push({ path: '/m/orders', query: { tab: tabKey } })
} }
function onSettingClick(action: string) { function onSettingClick(action: string) {
showSettings.value = false; showSettings.value = false
switch (action) { switch (action) {
case "profile": case 'profile':
openProfileEditor(); openProfileEditor()
break; break
case "realname": case 'realname':
router.push("/m/realname"); router.push('/m/realname')
break; break
case "cancel-account": case 'cancel-account':
showDialog({ showDialog({
title: "注销账号", title: '注销账号',
message: "注销后账号数据将无法恢复,确认注销吗?", message: '注销后账号数据将无法恢复,确认注销吗?',
showCancelButton: true, showCancelButton: true,
confirmButtonText: "确认注销", confirmButtonText: '确认注销',
confirmButtonColor: "#ee0a24", confirmButtonColor: '#ee0a24',
cancelButtonText: "再想想", cancelButtonText: '再想想',
}) })
.then(() => { .then(() => {
showToast({ message: "注销功能开发中", icon: "info-o" }); showToast({ message: '注销功能开发中', icon: 'info-o' })
}) })
.catch(() => {}); .catch(() => {})
break; break
case "privacy": case 'privacy':
showToast({ message: "隐私政策页面开发中", icon: "info-o" }); showToast({ message: '隐私政策页面开发中', icon: 'info-o' })
break; break
case "terms": case 'terms':
showToast({ message: "用户协议页面开发中", icon: "info-o" }); showToast({ message: '用户协议页面开发中', icon: 'info-o' })
break; break
} }
} }
function openProfileEditor() { function openProfileEditor() {
profileForm.nickname = session.nickname || defaultName.value; profileForm.nickname = session.nickname || defaultName.value
profileForm.avatar_url = session.avatarUrl || ""; profileForm.avatar_url = session.avatarUrl || ''
showProfileEditor.value = true; showProfileEditor.value = true
} }
async function saveProfile() { async function saveProfile() {
const nickname = profileForm.nickname.trim(); const nickname = profileForm.nickname.trim()
const avatarURL = profileForm.avatar_url.trim(); const avatarURL = profileForm.avatar_url.trim()
if (!nickname) { if (!nickname) {
showToast({ message: "请输入昵称", icon: "warning-o" }); showToast({ message: '请输入昵称', icon: 'warning-o' })
return; return
} }
if (nickname.length > 24) { if (nickname.length > 24) {
showToast({ message: "昵称不能超过 24 个字符", icon: "warning-o" }); showToast({ message: '昵称不能超过 24 个字符', icon: 'warning-o' })
return; return
} }
savingProfile.value = true; savingProfile.value = true
try { try {
await session.updateProfile({ nickname, avatar_url: avatarURL }); await session.updateProfile({ nickname, avatar_url: avatarURL })
showProfileEditor.value = false; showProfileEditor.value = false
showToast({ message: "资料已更新", icon: "passed" }); showToast({ message: '资料已更新', icon: 'passed' })
await loadBalance(); await loadBalance()
} catch (error) { } catch (error) {
showToast({ message: readError(error, "资料更新失败"), icon: "cross" }); showToast({ message: readError(error, '资料更新失败'), icon: 'cross' })
} finally { } finally {
savingProfile.value = false; savingProfile.value = false
} }
} }
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response; const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback; return response?.data?.message || fallback
} }
return fallback; return fallback
} }
function confirmLogout() { function confirmLogout() {
showSettings.value = false; showSettings.value = false
showDialog({ showDialog({
title: "退出登录", title: '退出登录',
message: "确定要退出当前账号吗?", message: '确定要退出当前账号吗?',
showCancelButton: true, showCancelButton: true,
confirmButtonText: "退出", confirmButtonText: '退出',
confirmButtonColor: "#ee0a24", confirmButtonColor: '#ee0a24',
cancelButtonText: "取消", cancelButtonText: '取消',
}) })
.then(() => { .then(() => {
session.logout(); session.logout()
router.replace("/m"); router.replace('/m')
}) })
.catch(() => {}); .catch(() => {})
} }
const isLoggedIn = computed(() => Boolean(session.token)); const isLoggedIn = computed(() => Boolean(session.token))
const isRealnameVerified = computed(() => session.realnameStatus === "verified"); const isRealnameVerified = computed(() => session.realnameStatus === 'verified')
const isRealnamePending = computed(() => session.realnameStatus === "pending"); const isRealnamePending = computed(() => session.realnameStatus === 'pending')
const defaultName = computed(() => const defaultName = computed(() =>
session.phone session.phone ? `用户${session.phone.slice(-6)}` : `用户${session.userId || 883099}`
? `用户${session.phone.slice(-6)}` )
: `用户${session.userId || 883099}` const displayName = computed(() => session.nickname || defaultName.value)
); const displayId = computed(() => session.userId || 138865)
const displayName = computed(() => session.nickname || defaultName.value);
const displayId = computed(() => session.userId || 138865);
const maskedPhone = computed(() => const maskedPhone = computed(() =>
session.phone session.phone ? `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}` : '登录后查看手机号'
? `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}` )
: "登录后查看手机号" const avatarText = computed(() => displayName.value.slice(0, 1))
);
const avatarText = computed(() => displayName.value.slice(0, 1));
function resolveAvatarURL(url: string | undefined | null) { function resolveAvatarURL(url: string | undefined | null) {
if (!url) return ""; if (!url) return ''
if (url.includes("/api/files/object?key=avatar/")) { if (url.includes('/api/files/object?key=avatar/')) {
return url.replace("/api/files/object", "/api/public/files/object"); return url.replace('/api/files/object', '/api/public/files/object')
} }
return url; return url
} }
</script> </script>
@@ -276,13 +276,32 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="hero-user"> <div class="hero-user">
<div class="name-row"> <div class="name-row">
<h1 class="user-nickname">{{ displayName }}</h1> <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-icon name="shield" /> 已实名
</van-tag> </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>
<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> </van-tag>
</div> </div>
@@ -416,7 +435,9 @@ function resolveAvatarURL(url: string | undefined | null) {
<button class="popup-close" @click="showPostRentalNotice = false"></button> <button class="popup-close" @click="showPostRentalNotice = false"></button>
</header> </header>
<div class="popup-body"> <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"> <div v-else-if="postRentalNotice" class="notice-content">
{{ postRentalNotice.content }} {{ postRentalNotice.content }}
</div> </div>
@@ -438,11 +459,7 @@ function resolveAvatarURL(url: string | undefined | null) {
</button> </button>
</header> </header>
<div <div v-for="group in settingsGroups" :key="group.title" class="settings-group">
v-for="group in settingsGroups"
:key="group.title"
class="settings-group"
>
<h3 class="settings-group-title">{{ group.title }}</h3> <h3 class="settings-group-title">{{ group.title }}</h3>
<van-cell-group :border="false"> <van-cell-group :border="false">
<van-cell <van-cell
@@ -457,9 +474,7 @@ function resolveAvatarURL(url: string | undefined | null) {
</div> </div>
<div class="settings-logout"> <div class="settings-logout">
<van-button plain type="danger" block round @click="confirmLogout"> <van-button plain type="danger" block round @click="confirmLogout"> 退出登录 </van-button>
退出登录
</van-button>
</div> </div>
</div> </div>
</van-popup> </van-popup>
@@ -481,7 +496,11 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="profile-preview"> <div class="profile-preview">
<div class="profile-preview-avatar clickable" @click="triggerAvatarUpload"> <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> <span v-else>{{ (profileForm.nickname || defaultName).slice(0, 1) }}</span>
<div class="avatar-upload-overlay"> <div class="avatar-upload-overlay">
<van-icon name="photograph" :size="16" /> <van-icon name="photograph" :size="16" />
@@ -516,11 +535,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<label class="editor-field"> <label class="editor-field">
<span>昵称</span> <span>昵称</span>
<input <input v-model="profileForm.nickname" maxlength="24" placeholder="请输入昵称" />
v-model="profileForm.nickname"
maxlength="24"
placeholder="请输入昵称"
/>
</label> </label>
<label class="editor-field"> <label class="editor-field">
@@ -640,7 +655,8 @@ function resolveAvatarURL(url: string | undefined | null) {
color: #111827; color: #111827;
} }
.verified-tag, .unverified-tag { .verified-tag,
.unverified-tag {
font-size: 10px !important; font-size: 10px !important;
height: 18px; height: 18px;
padding: 0 6px; padding: 0 6px;
@@ -660,7 +676,9 @@ function resolveAvatarURL(url: string | undefined | null) {
padding: 16px; padding: 16px;
border-radius: 16px; border-radius: 16px;
background: #ffffff; 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); border: 1px solid rgba(243, 244, 246, 0.9);
} }
@@ -699,7 +717,9 @@ function resolveAvatarURL(url: string | undefined | null) {
padding: 0 16px; padding: 0 16px;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition:
background 0.15s,
color 0.15s;
} }
.withdraw-btn:active { .withdraw-btn:active {
@@ -732,7 +752,9 @@ function resolveAvatarURL(url: string | undefined | null) {
background: #ffffff; background: #ffffff;
border-radius: 16px; border-radius: 16px;
padding: 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); 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 */ /* Vibrant pastel colors for menu icon wraps */
.icon-wrap.warning { color: #f59e0b; background: rgba(245, 158, 11, 0.08); } .icon-wrap.warning {
.icon-wrap.info { color: #2563eb; background: rgba(37, 99, 235, 0.08); } color: #f59e0b;
.icon-wrap.primary { color: #1477ff; background: rgba(20, 119, 255, 0.08); } background: rgba(245, 158, 11, 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.info {
.icon-wrap.purple { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); } color: #2563eb;
.icon-wrap.teal { color: #0d9488; background: rgba(13, 148, 136, 0.08); } 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 { .grid-item span {
font-size: 11px; font-size: 11px;
@@ -1,81 +1,76 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from "vant"; import { showToast, showDialog } from 'vant'
import { import { getRealnameStatus, startRealname, type RealnameStatus } from '@/features/auth/api/realname'
getRealnameStatus, import { realnameStatusLabel } from '@/utils/statusLabels'
startRealname, import { useSessionStore } from '@/stores/session'
type RealnameStatus, import { formatDateTime } from '@/utils/time'
} from "@/features/auth/api/realname";
import { realnameStatusLabel } from "@/utils/statusLabels";
import { useSessionStore } from "@/stores/session";
import { formatDateTime } from "@/utils/time";
const session = useSessionStore(); const session = useSessionStore()
const router = useRouter(); const router = useRouter()
const route = useRoute(); const route = useRoute()
const loading = ref(false); const loading = ref(false)
const status = ref<RealnameStatus | null>(null); const status = ref<RealnameStatus | null>(null)
const form = reactive({ const form = reactive({
name: "", name: '',
idNo: "", idNo: '',
}); })
onMounted(loadStatus); onMounted(loadStatus)
function redirectAfterVerified() { function redirectAfterVerified() {
const redirect = const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
typeof route.query.redirect === "string" ? route.query.redirect : "";
if (redirect) { if (redirect) {
router.replace(redirect); router.replace(redirect)
} }
} }
async function loadStatus() { async function loadStatus() {
if (!session.token) return; if (!session.token) return
try { try {
status.value = await getRealnameStatus(); status.value = await getRealnameStatus()
if (status.value.status === "verified") { if (status.value.status === 'verified') {
await session.loadMe(); await session.loadMe()
redirectAfterVerified(); redirectAfterVerified()
} }
} catch { } catch {
status.value = null; status.value = null
} }
} }
async function handleSubmit() { async function handleSubmit() {
if (!form.name.trim()) { if (!form.name.trim()) {
showToast({ message: "请输入真实姓名", icon: "warning-o" }); showToast({ message: '请输入真实姓名', icon: 'warning-o' })
return; return
} }
if (!form.idNo.trim() || form.idNo.length < 15) { if (!form.idNo.trim() || form.idNo.length < 15) {
showToast({ message: "请输入有效的证件号", icon: "warning-o" }); showToast({ message: '请输入有效的证件号', icon: 'warning-o' })
return; return
} }
loading.value = true; loading.value = true
try { try {
status.value = await startRealname(form.name, form.idNo); status.value = await startRealname(form.name, form.idNo)
await session.loadMe(); await session.loadMe()
showDialog({ showDialog({
title: "认证成功", title: '认证成功',
message: "实名认证已通过", message: '实名认证已通过',
confirmButtonText: "好的", confirmButtonText: '好的',
}).then(() => { }).then(() => {
redirectAfterVerified(); redirectAfterVerified()
}); })
} catch (error: unknown) { } catch (error: unknown) {
const msg = const msg =
typeof error === "object" && error && "response" in error typeof error === 'object' && error && 'response' in error
? ( ? (
error as { error as {
response?: { data?: { message?: string } }; response?: { data?: { message?: string } }
} }
).response?.data?.message || "实名认证失败" ).response?.data?.message || '实名认证失败'
: "实名认证失败"; : '实名认证失败'
showToast({ message: msg, icon: "cross" }); showToast({ message: msg, icon: 'cross' })
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
</script> </script>
@@ -91,31 +86,19 @@ async function handleSubmit() {
</header> </header>
<!-- 未登录提示 --> <!-- 未登录提示 -->
<van-empty <van-empty v-if="!session.token" description="请先登录后再实名认证" image="search" />
v-if="!session.token"
description="请先登录后再实名认证"
image="search"
/>
<template v-else> <template v-else>
<!-- 认证状态 --> <!-- 认证状态 -->
<section v-if="status" class="status-card"> <section v-if="status" class="status-card">
<div class="status-row"> <div class="status-row">
<span class="status-label">当前状态</span> <span class="status-label">当前状态</span>
<van-tag <van-tag :type="status.status === 'verified' ? 'success' : 'warning'" size="medium" round>
:type="status.status === 'verified' ? 'success' : 'warning'"
size="medium"
round
>
{{ realnameStatusLabel(status.status) }} {{ realnameStatusLabel(status.status) }}
</van-tag> </van-tag>
</div> </div>
<p v-if="status.masked_name" class="status-detail"> <p v-if="status.masked_name" class="status-detail">姓名{{ status.masked_name }}</p>
姓名{{ status.masked_name }} <p v-if="status.masked_id_no" class="status-detail">证件号{{ status.masked_id_no }}</p>
</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"> <p v-if="status.verified_at" class="status-detail">
通过时间{{ formatDateTime(status.verified_at) }} 通过时间{{ formatDateTime(status.verified_at) }}
</p> </p>
@@ -124,9 +107,7 @@ async function handleSubmit() {
<!-- 认证表单 --> <!-- 认证表单 -->
<section v-if="status?.status !== 'verified'" class="form-card"> <section v-if="status?.status !== 'verified'" class="form-card">
<h2>提交认证</h2> <h2>提交认证</h2>
<p class="form-hint"> <p class="form-hint">号主发布前必须完成实名认证提交合法姓名和身份证号即可认证</p>
号主发布前必须完成实名认证提交合法姓名和身份证号即可认证
</p>
<van-field <van-field
v-model="form.name" v-model="form.name"
label="姓名" label="姓名"
@@ -1,42 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from "vue-router"; import { RouterLink, useRoute, useRouter } from 'vue-router'
import { showToast } from "vant"; import { showToast } from 'vant'
import { useSessionStore } from "@/stores/session"; import { useSessionStore } from '@/stores/session'
import { useSmsCountdown } from "@/shared/composables/useSmsCountdown"; import { useSmsCountdown } from '@/shared/composables/useSmsCountdown'
const router = useRouter(); const router = useRouter()
const route = useRoute(); const route = useRoute()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const agreed = ref(false); const agreed = ref(false)
const form = reactive({ const form = reactive({
phone: "", phone: '',
code: "", code: '',
inviteCode: "", inviteCode: '',
}); })
const { countDown, sending, handleSendCode, readError } = useSmsCountdown(); const { countDown, sending, handleSendCode, readError } = useSmsCountdown()
async function handleRegister() { async function handleRegister() {
if (!agreed.value) { if (!agreed.value) {
showToast({ showToast({
message: "请先阅读并同意用户协议和隐私政策", message: '请先阅读并同意用户协议和隐私政策',
icon: "warning-o", icon: 'warning-o',
}); })
return; return
} }
loading.value = true; loading.value = true
try { try {
await session.login(form.phone, form.code); await session.login(form.phone, form.code)
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/m/profile"; const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/m/profile'
await router.replace(redirect); await router.replace(redirect)
} catch { } catch {
showToast({ message: "注册失败,请检查手机号和验证码", icon: "cross" }); showToast({ message: '注册失败,请检查手机号和验证码', icon: 'cross' })
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
</script> </script>
@@ -85,23 +85,13 @@ async function handleRegister() {
class="code-btn" class="code-btn"
@click="handleSendCode(form.phone)" @click="handleSendCode(form.phone)"
> >
{{ {{ countDown > 0 ? `${countDown}s` : sending ? '发送中' : '发送验证码' }}
countDown > 0
? `${countDown}s`
: sending
? "发送中"
: "发送验证码"
}}
</van-button> </van-button>
</span> </span>
</label> </label>
<label class="auth-input-row"> <label class="auth-input-row">
<input <input v-model="form.inviteCode" maxlength="16" placeholder="邀请码(选填)" />
v-model="form.inviteCode"
maxlength="16"
placeholder="邀请码(选填)"
/>
</label> </label>
<div class="assist-row"> <div class="assist-row">
@@ -126,9 +116,7 @@ async function handleRegister() {
注册并登录 注册并登录
</van-button> </van-button>
<RouterLink to="/m/login" class="secondary-entry"> <RouterLink to="/m/login" class="secondary-entry"> 返回登录 </RouterLink>
返回登录
</RouterLink>
</div> </div>
</section> </section>
</main> </main>
@@ -2,7 +2,11 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue' 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' import { formatDateTime } from '@/utils/time'
const loading = ref(false) const loading = ref(false)
@@ -46,7 +50,12 @@ async function markRead(id: number) {
<el-empty v-if="!loading && notifications.length === 0" description="暂无站内信" /> <el-empty v-if="!loading && notifications.length === 0" description="暂无站内信" />
<div v-else v-loading="loading" class="notification-list"> <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> <div>
<span>{{ item.type }}</span> <span>{{ item.type }}</span>
<h2>{{ item.title }}</h2> <h2>{{ item.title }}</h2>
@@ -54,10 +63,15 @@ async function markRead(id: number) {
<small>{{ formatDateTime(item.created_at) }}</small> <small>{{ formatDateTime(item.created_at) }}</small>
</div> </div>
<div class="notification-actions"> <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> <el-button size="small">查看订单</el-button>
</RouterLink> </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> </div>
</div> </div>
@@ -35,10 +35,30 @@ const form = reactive({
avatar_url: '', avatar_url: '',
}) })
const buyerServices = [ 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: '待支付',
{ label: '使用中', icon: VideoPlay, tone: 'primary', to: { path: '/orders', query: { tab: 'renting' } } }, icon: Coin,
{ label: '已完成', icon: Finished, tone: 'success', to: { path: '/orders', query: { tab: 'completed' } } }, 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 = [ const sellerServices = [
{ label: '发布商品', icon: CirclePlus, tone: 'orange', to: '/seller/listings/create' }, { label: '发布商品', icon: CirclePlus, tone: 'orange', to: '/seller/listings/create' },
@@ -146,8 +166,15 @@ function readError(error: unknown, fallback: string) {
<h1>个人资料</h1> <h1>个人资料</h1>
<p>{{ displayName }} · {{ maskedPhone }}</p> <p>{{ displayName }} · {{ maskedPhone }}</p>
</div> </div>
<button class="hero-realname" :class="`is-${realnameTone}`" type="button" @click="router.push('/realname')"> <button
<el-icon><CircleCheckFilled v-if="session.realnameStatus === 'verified'" /><WarningFilled v-else /></el-icon> 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> <span>{{ realnameStatusLabel(session.realnameStatus) }}</span>
</button> </button>
</div> </div>
@@ -168,19 +195,35 @@ function readError(error: unknown, fallback: string) {
<button class="avatar-preview" type="button" @click="triggerAvatarUpload"> <button class="avatar-preview" type="button" @click="triggerAvatarUpload">
<img v-if="form.avatar_url" :src="resolveAvatarURL(form.avatar_url)" alt="" /> <img v-if="form.avatar_url" :src="resolveAvatarURL(form.avatar_url)" alt="" />
<span v-else>{{ avatarText }}</span> <span v-else>{{ avatarText }}</span>
<i><el-icon><Camera /></el-icon></i> <i
><el-icon><Camera /></el-icon
></i>
</button> </button>
<div class="avatar-actions"> <div class="avatar-actions">
<strong>{{ form.nickname || displayName }}</strong> <strong>{{ form.nickname || displayName }}</strong>
<p>支持上传本地图片也可以直接填写头像 URL</p> <p>支持上传本地图片也可以直接填写头像 URL</p>
<el-button :icon="Camera" :loading="uploadingAvatar" @click="triggerAvatarUpload">上传头像</el-button> <el-button :icon="Camera" :loading="uploadingAvatar" @click="triggerAvatarUpload"
<input ref="avatarFileInput" type="file" accept="image/*" hidden @change="handleAvatarFileChange" /> >上传头像</el-button
>
<input
ref="avatarFileInput"
type="file"
accept="image/*"
hidden
@change="handleAvatarFileChange"
/>
</div> </div>
</div> </div>
<el-form class="profile-form" label-position="top"> <el-form class="profile-form" label-position="top">
<el-form-item label="昵称"> <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>
<el-form-item label="头像地址"> <el-form-item label="头像地址">
<el-input <el-input
@@ -193,7 +236,14 @@ function readError(error: unknown, fallback: string) {
</el-form-item> </el-form-item>
<div class="form-actions"> <div class="form-actions">
<el-button size="large" :icon="RefreshRight" @click="resetForm">重置</el-button> <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> </div>
</el-form> </el-form>
</section> </section>
@@ -231,7 +281,12 @@ function readError(error: unknown, fallback: string) {
</RouterLink> </RouterLink>
</div> </div>
<div class="service-grid buyer-grid"> <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}`"> <span class="service-icon" :class="`is-${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon> <el-icon><component :is="item.icon" /></el-icon>
</span> </span>
@@ -249,7 +304,12 @@ function readError(error: unknown, fallback: string) {
</RouterLink> </RouterLink>
</div> </div>
<div class="service-grid seller-grid"> <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}`"> <span class="service-icon" :class="`is-${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon> <el-icon><component :is="item.icon" /></el-icon>
</span> </span>
@@ -275,9 +335,7 @@ function readError(error: unknown, fallback: string) {
padding: 30px 34px; padding: 30px 34px;
border: 1px solid #eef1f5; border: 1px solid #eef1f5;
border-radius: 18px; border-radius: 18px;
background: background: linear-gradient(135deg, rgba(255, 106, 0, 0.12), rgba(37, 99, 235, 0.08)), #ffffff;
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); 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) { .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 { .form-actions {
@@ -150,7 +150,13 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<div> <div>
<h2>{{ currentStatus === 'verified' ? '实名信息' : '提交认证' }}</h2> <h2>{{ currentStatus === 'verified' ? '实名信息' : '提交认证' }}</h2>
<p>{{ currentStatus === 'verified' ? '实名信息已完成脱敏展示。' : '请使用本人真实身份信息完成核验。' }}</p> <p>
{{
currentStatus === 'verified'
? '实名信息已完成脱敏展示。'
: '请使用本人真实身份信息完成核验。'
}}
</p>
</div> </div>
</div> </div>
@@ -160,13 +166,20 @@ function readError(error: unknown, fallback: string) {
</div> </div>
<div class="verified-copy"> <div class="verified-copy">
<strong>认证通过</strong> <strong>认证通过</strong>
<span>{{ status?.verified_at ? formatDateTime(status.verified_at) : '已完成实名核验' }}</span> <span>{{
status?.verified_at ? formatDateTime(status.verified_at) : '已完成实名核验'
}}</span>
</div> </div>
</div> </div>
<el-form v-else class="verify-form" label-position="top"> <el-form v-else class="verify-form" label-position="top">
<el-form-item label="姓名"> <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>
<el-form-item label="身份证号"> <el-form-item label="身份证号">
<el-input <el-input
@@ -248,7 +261,12 @@ function readError(error: unknown, fallback: string) {
border: 1px solid #eef1f5; border: 1px solid #eef1f5;
border-radius: 18px; border-radius: 18px;
background: 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; #ffffff;
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08); 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) { .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 { .verify-form .el-button {
+57 -20
View File
@@ -68,9 +68,12 @@ export async function ensureSupportChat() {
} }
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) { 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>>>(
params: { page, page_size: pageSize }, `/chats/${id}/messages`,
}) {
params: { page, page_size: pageSize },
}
)
return data.data return data.data
} }
@@ -88,9 +91,12 @@ export async function markChatRead(id: number) {
} }
export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') { 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>>>(
params: { page, page_size: pageSize, filter }, '/admin/chats',
}) {
params: { page, page_size: pageSize, filter },
}
)
return data.data return data.data
} }
@@ -100,13 +106,20 @@ export async function fetchAdminChat(id: number) {
} }
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) { 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>>>(
params: { page, page_size: pageSize }, `/admin/chats/${id}/messages`,
}) {
params: { page, page_size: pageSize },
}
)
return data.data 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`, { const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, {
content, content,
attachment_urls: attachmentUrls, attachment_urls: attachmentUrls,
@@ -132,14 +145,20 @@ export async function fetchSupportAdmins() {
} }
export async function transferChat(id: number, toAdminId: number) { 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 }>>(
to_admin_id: toAdminId, `/admin/chats/${id}/transfer`,
}) {
to_admin_id: toAdminId,
}
)
return data.data return data.data
} }
export async function updateChatRemark(id: number, remark: string) { 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 return data.data
} }
@@ -157,7 +176,12 @@ export async function fetchQuickReplies() {
return data.data 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', { const { data } = await apiClient.post<ApiResponse<QuickReply>>('/admin/chats/quick-replies', {
title, title,
content, content,
@@ -167,22 +191,35 @@ export async function createQuickReply(title: string, content: string, sortOrder
return data.data return data.data
} }
export async function updateQuickReply(id: number, updates: { title?: string; content?: string; sort_order?: number }) { export async function updateQuickReply(
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/chats/quick-replies/${id}`, updates) 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 return data.data
} }
export async function deleteQuickReply(id: number) { 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 return data.data
} }
export async function fetchAutoWelcomeMessage() { 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 return data.data.message
} }
export async function updateAutoWelcomeMessage(message: string) { 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 return data.data
} }
@@ -34,9 +34,8 @@ async function loadImage() {
} }
try { try {
const key = extractObjectKey(props.source) const key = extractObjectKey(props.source)
const blob = props.admin && key const blob =
? await fetchAdminFileBlob(key) props.admin && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(props.source)
: await fetchFileBlobByURL(props.source)
objectURL.value = URL.createObjectURL(blob) objectURL.value = URL.createObjectURL(blob)
} catch { } catch {
failed.value = true failed.value = true
@@ -55,7 +54,7 @@ onBeforeUnmount(revokeCurrentURL)
<template> <template>
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage"> <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> </button>
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span> <span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</template> </template>
@@ -49,18 +49,22 @@ export function useChatSSE(scope: AuthScope, endpoint: string) {
connected.value = true connected.value = true
}) })
source.addEventListener('new_message', (e) => { source.addEventListener('new_message', e => {
try { try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data)) handlers.forEach(h => h(data))
} catch { /* ignore */ } } catch {
/* ignore */
}
}) })
source.addEventListener('conversation_updated', (e) => { source.addEventListener('conversation_updated', e => {
try { try {
const data = JSON.parse((e as MessageEvent).data) as ChatEvent const data = JSON.parse((e as MessageEvent).data) as ChatEvent
handlers.forEach(h => h(data)) handlers.forEach(h => h(data))
} catch { /* ignore */ } } catch {
/* ignore */
}
}) })
source.onerror = async () => { source.onerror = async () => {
@@ -68,10 +68,15 @@ function roleLabel(role: string) {
} }
function previewText(item: ChatConversation) { 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> </script>
<template> <template>
@@ -113,7 +118,9 @@ const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum
<div class="conversation-main"> <div class="conversation-main">
<div class="conversation-title-row"> <div class="conversation-title-row">
<h2>{{ item.title }}</h2> <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>
<div class="conversation-meta"> <div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span> <span class="role-chip">{{ roleLabel(item.role) }}</span>
+12 -3
View File
@@ -22,7 +22,10 @@ export interface Dispute {
updated_at: string 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) const { data } = await apiClient.post<ApiResponse<Dispute>>(`/orders/${orderId}/dispute`, payload)
return data.data return data.data
} }
@@ -41,7 +44,13 @@ export async function fetchAdminDisputes(page = 1, pageSize = 20) {
return data.data return data.data
} }
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) { export async function arbitrateDispute(
const { data } = await apiClient.post<ApiResponse<Dispute>>(`/admin/disputes/${id}/arbitrate`, payload) id: number,
payload: { result: string; remark: string; amount?: number }
) {
const { data } = await apiClient.post<ApiResponse<Dispute>>(
`/admin/disputes/${id}/arbitrate`,
payload
)
return data.data return data.data
} }
@@ -1,9 +1,6 @@
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types' import type { ApiResponse } from '@/shared/types/types'
import { import { mergeListingPublishOptions, type ListingPublishOptions } from './listingOptions'
mergeListingPublishOptions,
type ListingPublishOptions,
} from './listingOptions'
export interface HomeBannerSlide { export interface HomeBannerSlide {
eyebrow: string eyebrow: string
@@ -61,11 +58,11 @@ export async function fetchMobileHomeConfig() {
export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeConfig { export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeConfig {
const announcements = const announcements =
config?.announcements config?.announcements
?.map((item) => (typeof item === 'string' ? item.trim() : '')) ?.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean) || [] .filter(Boolean) || []
const banners = const banners =
config?.banners config?.banners
?.map((item) => { ?.map(item => {
const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>) const banner = isBannerLike(item) ? item : ({} as Partial<HomeBannerSlide>)
return { return {
eyebrow: banner.eyebrow?.trim() || '', eyebrow: banner.eyebrow?.trim() || '',
@@ -76,7 +73,7 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
image_url: banner.image_url?.trim() || '', image_url: banner.image_url?.trim() || '',
} }
}) })
.filter((item) => item.title || item.image_url) || [] .filter(item => item.title || item.image_url) || []
return { return {
announcements: announcements.length ? announcements : defaultHomeAnnouncements, announcements: announcements.length ? announcements : defaultHomeAnnouncements,
@@ -182,21 +182,29 @@ export const emptyListingPublishAgreements: ListingPublishAgreements = {
} }
export async function fetchListingPublishOptions() { 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) return mergeListingPublishOptions(data.data)
} }
export async function fetchListingPublishAgreements() { 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) return mergeListingPublishAgreements(data.data)
} }
export async function fetchListingSalePriceConfig() { 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) return mergeListingSalePriceConfig(data.data)
} }
export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions { export function mergeListingPublishOptions(
options?: Partial<ListingPublishOptions>
): ListingPublishOptions {
return { return {
server_options: normalizeStringList(options?.server_options), server_options: normalizeStringList(options?.server_options),
face_options: normalizeStringList(options?.face_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) return normalizeSalePriceConfig(options)
} }
export function mergeListingPublishAgreements(options?: Partial<ListingPublishAgreements>): ListingPublishAgreements { export function mergeListingPublishAgreements(
options?: Partial<ListingPublishAgreements>
): ListingPublishAgreements {
return { return {
virtual_asset_sale: normalizeAgreementContent(options?.virtual_asset_sale, emptyListingPublishAgreements.virtual_asset_sale), virtual_asset_sale: normalizeAgreementContent(
seller_agreement: normalizeAgreementContent(options?.seller_agreement, emptyListingPublishAgreements.seller_agreement), options?.virtual_asset_sale,
emptyListingPublishAgreements.virtual_asset_sale
),
seller_agreement: normalizeAgreementContent(
options?.seller_agreement,
emptyListingPublishAgreements.seller_agreement
),
} }
} }
function normalizeAgreementContent(value: unknown, fallback: AgreementContent): AgreementContent { function normalizeAgreementContent(value: unknown, fallback: AgreementContent): AgreementContent {
const row = isRecord(value) ? value : {} const row = isRecord(value) ? value : {}
const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : fallback.title const title =
const content = typeof row.content === 'string' && row.content.trim() ? row.content.trim() : fallback.content 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 } return { title, content }
} }
function normalizeStringList(values?: unknown[]) { function normalizeStringList(values?: unknown[]) {
return Array.isArray(values) 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[] { function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] {
if (!Array.isArray(values)) return [] if (!Array.isArray(values)) return []
return values return values
.map((item) => { .map(item => {
const group = isRecord(item) ? item : {} const group = isRecord(item) ? item : {}
return { return {
key: typeof group.key === 'string' ? group.key.trim() : '', 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 : []), 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[] { function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
if (!Array.isArray(values)) return [] if (!Array.isArray(values)) return []
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
key: typeof row.key === 'string' ? row.key.trim() : '', 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, 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[] { function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] {
if (!Array.isArray(values)) return [] if (!Array.isArray(values)) return []
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
key: typeof row.key === 'string' ? row.key.trim() : '', 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() : '', 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 { function normalizePriceConfig(value?: unknown): PublishPriceConfig {
const row = isRecord(value) ? value : {} const row = isRecord(value) ? value : {}
return { 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: deposit_hint:
typeof row.deposit_hint === 'string' && row.deposit_hint.trim() typeof row.deposit_hint === 'string' && row.deposit_hint.trim()
? row.deposit_hint.trim() ? row.deposit_hint.trim()
: emptyListingPublishOptions.price_config.deposit_hint, : emptyListingPublishOptions.price_config.deposit_hint,
price_placeholder: typeof row.price_placeholder === 'string' ? row.price_placeholder.trim() : '', price_placeholder:
ratio_description: typeof row.ratio_description === 'string' ? row.ratio_description.trim() : '', 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 = { const config = {
base_amount: readNumber(row.base_amount), base_amount: readNumber(row.base_amount),
skin_group_rules: normalizeDepositSkinGroupRules( 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) { if (config.base_amount <= 0) {
config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount
} }
if (config.skin_group_rules.length === 0) { 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 return config
} }
function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] { function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGroupRule[] {
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '', 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), 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 { function normalizeRatioConfig(value?: unknown): PublishRatioConfig {
const row = isRecord(value) ? value : {} const row = isRecord(value) ? value : {}
return { return {
insurance_base_ratios: normalizeInsuranceBaseRatios( 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( config_items: normalizeRatioConfigItems(
Array.isArray(row.config_items) ? row.config_items : [], Array.isArray(row.config_items) ? row.config_items : []
), ),
coin_corrections: normalizeCoinCorrections( 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 row = isRecord(value) ? value : {}
const config = { const config = {
fixed_markup_rules: normalizeSaleFixedMarkupRules( 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( 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) { 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[] { function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRatio[] {
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '', insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '',
ratio: readNumber(row.ratio), ratio: readNumber(row.ratio),
} }
}) })
.filter((item) => item.insurance && item.ratio > 0) .filter(item => item.insurance && item.ratio > 0)
} }
function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] { function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] {
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
key: typeof row.key === 'string' ? row.key.trim() : '', key: typeof row.key === 'string' ? row.key.trim() : '',
@@ -383,24 +408,24 @@ function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[]
missing_penalty: readNumber(row.missing_penalty), 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[] { function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] {
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
threshold_m: readNumber(row.threshold_m), threshold_m: readNumber(row.threshold_m),
correction: readNumber(row.correction), 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[] { function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] {
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
min_m: readNumber(row.min_m), min_m: readNumber(row.min_m),
@@ -408,12 +433,12 @@ function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarku
markup_amount: readNumber(row.markup_amount), 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[] { function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] {
return values return values
.map((item) => { .map(item => {
const row = isRecord(item) ? item : {} const row = isRecord(item) ? item : {}
return { return {
min_m: readNumber(row.min_m), min_m: readNumber(row.min_m),
@@ -422,10 +447,10 @@ function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioA
} }
}) })
.filter( .filter(
(item) => item =>
item.min_m >= 0 && item.min_m >= 0 &&
(item.max_m === 0 || item.max_m >= item.min_m) && (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 = {}) { export async function fetchListingsPage(query: PublicListingQuery = {}) {
const params = Object.fromEntries( 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) 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 : [] const items = Array.isArray(data.items) ? data.items : []
return { return {
items, items,
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length, 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: 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 : {}, 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 = {}) { 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 }) const { data } = await apiClient.get<ApiResponse<AdminListingPage>>('/admin/listings', { params })
return normalizeAdminListingPage(data.data, query) 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 : [] const items = Array.isArray(data.items) ? data.items : []
return { return {
items, items,
total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length, 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: 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) { 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 return data.data
} }
export async function adminMarkListingAbnormal(id: number, reason: string) { 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 return data.data
} }
@@ -196,12 +217,20 @@ export interface AdminListingPriceAdjustPayload {
reason?: string reason?: string
} }
export async function adjustListingReviewPrice(id: number, payload: AdminListingPriceAdjustPayload) { export async function adjustListingReviewPrice(
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/adjust-price`, payload) id: number,
payload: AdminListingPriceAdjustPayload
) {
const { data } = await apiClient.post<ApiResponse<Listing>>(
`/admin/listings/${id}/adjust-price`,
payload
)
return data.data return data.data
} }
export async function rejectListing(id: number, reason: string) { 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 return data.data
} }
@@ -19,10 +19,7 @@ defineProps<Props>()
<section class="hero-board"> <section class="hero-board">
<el-carousel height="240px" indicator-position="outside" :interval="3600"> <el-carousel height="240px" indicator-position="outside" :interval="3600">
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url"> <el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
<div <div class="hero-slide" :class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]">
class="hero-slide"
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
>
<img <img
v-if="slide.image_url" v-if="slide.image_url"
:src="slide.image_url" :src="slide.image_url"
@@ -95,7 +92,12 @@ defineProps<Props>()
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; 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 { .hero-copy {
@@ -28,9 +28,9 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:filters': [filters: Partial<HomeFilters>] 'update:filters': [filters: Partial<HomeFilters>]
'reset': [] reset: []
'setFilterPopover': [key: FilterPopoverKey, visible: boolean] setFilterPopover: [key: FilterPopoverKey, visible: boolean]
'closeFilterPopover': [] closeFilterPopover: []
}>() }>()
const filterPopoverBaseProps = { const filterPopoverBaseProps = {
@@ -67,9 +67,7 @@ function closePopover() {
<strong>筛选大厅</strong> <strong>筛选大厅</strong>
<span>{{ totalListings }} 个结果</span> <span>{{ totalListings }} 个结果</span>
</div> </div>
<el-button :icon="Refresh" link @click="emit('reset')"> <el-button :icon="Refresh" link @click="emit('reset')"> 重置全部条件 </el-button>
重置全部条件
</el-button>
</div> </div>
<div class="filter-chip-row"> <div class="filter-chip-row">
@@ -65,7 +65,11 @@ const coverURL = computed(() => props.listing.cover_url || props.listing.screens
</div> </div>
<div class="stat-row"> <div class="stat-row">
<label>体力/负重</label> <label>体力/负重</label>
<span>{{ readAssetString(listing, 'stamina_level') }}/{{ readAssetString(listing, 'load_level') }}</span> <span
>{{ readAssetString(listing, 'stamina_level') }}/{{
readAssetString(listing, 'load_level')
}}</span
>
</div> </div>
</div> </div>
@@ -79,12 +83,16 @@ const coverURL = computed(() => props.listing.cover_url || props.listing.screens
<div class="stat-row"> <div class="stat-row">
<label>六级头甲</label> <label>六级头甲</label>
<span> <span>
{{ getResourceQuantity(listing, 'helmet6') }} / {{ getResourceQuantity(listing, 'armor6') }} {{ getResourceQuantity(listing, 'helmet6') }} /
{{ getResourceQuantity(listing, 'armor6') }}
</span> </span>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<label>其他重器</label> <label>其他重器</label>
<span>巴雷特 {{ getResourceQuantity(listing, 'barrett') }} / 喷子 {{ getResourceQuantity(listing, 'shotgun') }}</span> <span
>巴雷特 {{ getResourceQuantity(listing, 'barrett') }} / 喷子
{{ getResourceQuantity(listing, 'shotgun') }}</span
>
</div> </div>
</div> </div>
@@ -29,12 +29,12 @@ const emit = defineEmits<{
const minValue = computed({ const minValue = computed({
get: () => props.modelMin, get: () => props.modelMin,
set: (val) => emit('update:modelMin', val), set: val => emit('update:modelMin', val),
}) })
const maxValue = computed({ const maxValue = computed({
get: () => props.modelMax, get: () => props.modelMax,
set: (val) => emit('update:modelMax', val), set: val => emit('update:modelMax', val),
}) })
const isActive = computed(() => { const isActive = computed(() => {
@@ -59,11 +59,7 @@ function isSelected(item: RangeOption) {
<template> <template>
<el-popover v-bind="popoverProps" :width="350"> <el-popover v-bind="popoverProps" :width="350">
<template #reference> <template #reference>
<button <button class="filter-chip" :class="{ active: isActive, wide }" type="button">
class="filter-chip"
:class="{ active: isActive, wide }"
type="button"
>
<span>{{ chipLabel }}</span> <span>{{ chipLabel }}</span>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</button> </button>
@@ -79,19 +75,9 @@ function isSelected(item: RangeOption) {
{{ item.label }} {{ item.label }}
</button> </button>
<div class="range-manual"> <div class="range-manual">
<el-input-number <el-input-number v-model="minValue" :controls="false" :min="0" placeholder="最小值" />
v-model="minValue"
:controls="false"
:min="0"
placeholder="最小值"
/>
<span>-</span> <span>-</span>
<el-input-number <el-input-number v-model="maxValue" :controls="false" :min="0" placeholder="最大值" />
v-model="maxValue"
:controls="false"
:min="0"
placeholder="最大值"
/>
</div> </div>
</div> </div>
</el-popover> </el-popover>
@@ -31,7 +31,7 @@ const isActive = computed(() => {
const chipLabel = computed(() => { const chipLabel = computed(() => {
if (props.skinName) return props.skinName if (props.skinName) return props.skinName
if (props.skinGroup) { if (props.skinGroup) {
return props.groups.find((g) => g.key === props.skinGroup)?.title || '皮肤' return props.groups.find(g => g.key === props.skinGroup)?.title || '皮肤'
} }
return '皮肤' return '皮肤'
}) })
@@ -36,21 +36,13 @@ function selectOption(value: string) {
<template> <template>
<el-popover v-bind="popoverProps" :width="220"> <el-popover v-bind="popoverProps" :width="220">
<template #reference> <template #reference>
<button <button class="filter-chip" :class="{ active: isActive, wide }" type="button">
class="filter-chip"
:class="{ active: isActive, wide }"
type="button"
>
<span>{{ displayLabel }}</span> <span>{{ displayLabel }}</span>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</button> </button>
</template> </template>
<div class="filter-menu"> <div class="filter-menu">
<button <button type="button" :class="{ active: !modelValue }" @click="selectOption('')">
type="button"
:class="{ active: !modelValue }"
@click="selectOption('')"
>
{{ placeholder }} {{ placeholder }}
</button> </button>
<button <button
@@ -36,7 +36,10 @@ describe('useHomeFilters', () => {
const publishOptions = ref(emptyListingPublishOptions) const publishOptions = ref(emptyListingPublishOptions)
const listings = ref([]) const listings = ref([])
const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(publishOptions, listings) const { filters, setStringFilter, closeFilterPopover } = useHomeFilters(
publishOptions,
listings
)
setStringFilter('region', '上海') setStringFilter('region', '上海')
@@ -30,11 +30,7 @@ export const fireLevelRangeOptions = [
{ label: '70+', min: 70, max: undefined }, { label: '70+', min: 70, max: undefined },
] ]
export function rangeLabel( export function rangeLabel(min: number | undefined, max: number | undefined, fallback: string) {
min: number | undefined,
max: number | undefined,
fallback: string
) {
if (min !== undefined && max !== undefined) return `${min}-${max}` if (min !== undefined && max !== undefined) return `${min}-${max}`
if (min !== undefined) return `${min}+` if (min !== undefined) return `${min}+`
if (max !== undefined) return `${max}` if (max !== undefined) return `${max}`
@@ -17,13 +17,7 @@ export type FilterPopoverKey =
| 'fireLevel' | 'fireLevel'
| 'loginMethod' | 'loginMethod'
export type StringFilterKey = export type StringFilterKey = 'insurance' | 'stamina' | 'load' | 'region' | 'rank' | 'loginMethod'
| 'insurance'
| 'stamina'
| 'load'
| 'region'
| 'rank'
| 'loginMethod'
export interface HomeFilters { export interface HomeFilters {
keyword: string keyword: string
@@ -80,35 +74,33 @@ export function useHomeFilters(
const regionOptions = computed(() => const regionOptions = computed(() =>
uniqueOptions([ uniqueOptions([
...publishOptions.value.region_options, ...publishOptions.value.region_options,
...listings.value.flatMap((item) => assetRegions(item)), ...listings.value.flatMap(item => assetRegions(item)),
]) ])
) )
const loginMethodOptions = computed(() => const loginMethodOptions = computed(() =>
uniqueOptions( uniqueOptions(
publishOptions.value.login_method_options publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean)
.map((item) => item.trim())
.filter(Boolean)
) )
) )
const skinFilterGroups = computed(() => { const skinFilterGroups = computed(() => {
const preferred = ['operatorRed', 'operatorGold'] const preferred = ['operatorRed', 'operatorGold']
return preferred 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)) .filter((group): group is ListingPublishOptions['skin_groups'][number] => Boolean(group))
}) })
const skinChipLabel = computed(() => { const skinChipLabel = computed(() => {
if (filters.skinName) return filters.skinName if (filters.skinName) return filters.skinName
if (filters.skinGroup) { if (filters.skinGroup) {
return skinFilterGroups.value.find((group) => group.key === filters.skinGroup)?.title || '皮肤' return skinFilterGroups.value.find(group => group.key === filters.skinGroup)?.title || '皮肤'
} }
return '皮肤' return '皮肤'
}) })
function uniqueOptions(values: string[]) { 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() { function resetFilters() {
@@ -102,12 +102,15 @@ export function useListingQuery(
// 使用防抖优化搜索 // 使用防抖优化搜索
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => listingQuerySignature(), () => { watch(
if (debounceTimer) clearTimeout(debounceTimer) () => listingQuerySignature(),
debounceTimer = setTimeout(() => { () => {
loadListingsPage(true) if (debounceTimer) clearTimeout(debounceTimer)
}, 300) debounceTimer = setTimeout(() => {
}) loadListingsPage(true)
}, 300)
}
)
return { return {
loading, loading,
@@ -35,7 +35,10 @@ const {
resetFilters, resetFilters,
setFilterPopover, setFilterPopover,
closeFilterPopover, closeFilterPopover,
} = useHomeFilters(publishOptions, computed(() => listings.value)) } = useHomeFilters(
publishOptions,
computed(() => listings.value)
)
const { const {
loading, loading,
@@ -104,10 +107,7 @@ const zoneOptions = computed(() => [
async function loadHome() { async function loadHome() {
loading.value = true loading.value = true
try { try {
const [, config] = await Promise.all([ const [, config] = await Promise.all([loadListingsPage(true), fetchMobileHomeConfig()])
loadListingsPage(true),
fetchMobileHomeConfig(),
])
announcements.value = config.announcements announcements.value = config.announcements
banners.value = config.banners banners.value = config.banners
publishOptions.value = config.publish_options publishOptions.value = config.publish_options
@@ -179,11 +179,7 @@ loadHome()
<button type="button" @click="handleResetFilters">重置条件</button> <button type="button" @click="handleResetFilters">重置条件</button>
</div> </div>
<div v-else v-loading="loading" class="enhanced-desktop-list"> <div v-else v-loading="loading" class="enhanced-desktop-list">
<ListingCard <ListingCard v-for="item in listings" :key="item.id" :listing="item" />
v-for="item in listings"
:key="item.id"
:listing="item"
/>
</div> </div>
<div v-if="!loading && listings.length" class="infinite-load-state"> <div v-if="!loading && listings.length" class="infinite-load-state">
@@ -1,12 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from 'vue-router'
import { fetchListing, type Listing } from "@/features/listings/api/listings"; import { fetchListing, type Listing } from '@/features/listings/api/listings'
import { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders"; import {
import { useSessionStore } from "@/stores/session"; createOrder,
import { roundMoney, formatMoney } from "@/shared/utils/money"; fetchOrderAgreements,
type OrderAgreements,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { roundMoney, formatMoney } from '@/shared/utils/money'
import { import {
assetRegions, assetRegions,
formatEstimatedRentalDuration, formatEstimatedRentalDuration,
@@ -26,45 +30,45 @@ import {
getServerRegion, getServerRegion,
readAssetNumber, readAssetNumber,
readAssetString, readAssetString,
} from "@/utils/listingDisplay"; } from '@/utils/listingDisplay'
const route = useRoute(); const route = useRoute()
const router = useRouter(); const router = useRouter()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const ordering = ref(false); const ordering = ref(false)
const agreementsLoading = ref(false); const agreementsLoading = ref(false)
const agreementVisible = ref(false); const agreementVisible = ref(false)
const agreements = ref<OrderAgreements | null>(null); const agreements = ref<OrderAgreements | null>(null)
const virtualAgreementRead = ref(false); const virtualAgreementRead = ref(false)
const renterAgreementRead = ref(false); const renterAgreementRead = ref(false)
const virtualAgreementChecked = ref(false); const virtualAgreementChecked = ref(false)
const renterAgreementChecked = ref(false); const renterAgreementChecked = ref(false)
const virtualAgreementRef = ref<HTMLElement | null>(null); const virtualAgreementRef = ref<HTMLElement | null>(null)
const renterAgreementRef = ref<HTMLElement | null>(null); const renterAgreementRef = ref<HTMLElement | null>(null)
const canCreateOrderAfterAgreement = computed( const canCreateOrderAfterAgreement = computed(
() => () =>
virtualAgreementRead.value && virtualAgreementRead.value &&
renterAgreementRead.value && renterAgreementRead.value &&
virtualAgreementChecked.value && virtualAgreementChecked.value &&
renterAgreementChecked.value, renterAgreementChecked.value
); )
const listing = ref<Listing | null>(null); const listing = ref<Listing | null>(null)
onMounted(async () => { onMounted(async () => {
loading.value = true; loading.value = true
try { try {
listing.value = await fetchListing(String(route.params.id)); listing.value = await fetchListing(String(route.params.id))
} finally { } finally {
loading.value = false; loading.value = false
} }
}); })
const orderTotal = computed(() => { const orderTotal = computed(() => {
if (!listing.value) return "0"; if (!listing.value) return '0'
return formatMoney(getListingDisplayPrice(listing.value)); return formatMoney(getListingDisplayPrice(listing.value))
}); })
const orderPriceBreakdown = computed(() => { const orderPriceBreakdown = computed(() => {
if (!listing.value) { if (!listing.value) {
@@ -72,260 +76,253 @@ const orderPriceBreakdown = computed(() => {
rent: 0, rent: 0,
consumable: 0, consumable: 0,
total: 0, total: 0,
}; }
} }
return { return {
rent: getListingRentPrice(listing.value), rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value), consumable: getListingConsumablePrice(listing.value),
total: roundMoney(getListingDisplayPrice(listing.value)), total: roundMoney(getListingDisplayPrice(listing.value)),
}; }
}); })
const coverURL = computed(() => { const coverURL = computed(() => {
if (!listing.value) return ""; if (!listing.value) return ''
return listing.value.cover_url || listing.value.screenshot_urls?.[0] || ""; return listing.value.cover_url || listing.value.screenshot_urls?.[0] || ''
}); })
const detailMetrics = computed(() => { const detailMetrics = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value); const dailyLoss = getDailyLoss(listing.value)
return [ return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" }, { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{ {
label: "日损耗", label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : "--", value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: "coin", tone: 'coin',
}, },
{ label: "价格", value: `¥${orderTotal.value}`, tone: "price" }, { label: '价格', value: `¥${orderTotal.value}`, tone: 'price' },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" }, { label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
]; ]
}); })
const detailScreenshots = computed(() => { const detailScreenshots = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value); const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots; if (groupedScreenshots.length) return groupedScreenshots
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"]; const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({ return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`, label: labels[index] || `账号截图${index + 1}`,
url, url,
})); }))
}); })
function readGroupedScreenshots(item: Listing) { function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups; const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== "object" || groups === null) return []; if (typeof groups !== 'object' || groups === null) return []
const slots = [ const slots = [
{ key: "coin", label: "纯币截图" }, { key: 'coin', label: '纯币截图' },
{ key: "gameId", label: "游戏ID截图" }, { key: 'gameId', label: '游戏ID截图' },
{ key: "totalAsset", label: "总资产截图" }, { key: 'totalAsset', label: '总资产截图' },
{ key: "tencentSecurity", label: "腾讯安全中心截图" }, { key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: "skin", label: "皮肤截图" }, { key: 'skin', label: '皮肤截图' },
]; ]
return slots.flatMap((slot) => { return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]; const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []; if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url)); const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({ return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label, label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url, url,
})); }))
}); })
} }
const detailSkinGroups = computed(() => { const detailSkinGroups = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const groups = listing.value.asset_summary?.skin_groups; const groups = listing.value.asset_summary?.skin_groups
if (typeof groups !== "object" || groups === null) return []; if (typeof groups !== 'object' || groups === null) return []
const titles: Record<string, string> = { const titles: Record<string, string> = {
melee: "近战皮肤", melee: '近战皮肤',
operator: "干员皮肤", operator: '干员皮肤',
operatorGold: "干员金皮", operatorGold: '干员金皮',
operatorRed: "干员红皮", operatorRed: '干员红皮',
weapon: "武器皮肤", weapon: '武器皮肤',
}; }
return Object.entries(groups as Record<string, unknown>) return Object.entries(groups as Record<string, unknown>)
.map(([key, value]) => ({ .map(([key, value]) => ({
key, key,
title: titles[key] || key, title: titles[key] || key,
options: Array.isArray(value) 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(() => { const accountRows = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const regions = assetRegions(listing.value); const regions = assetRegions(listing.value)
return [ return [
{ label: "所属区服", value: getServerRegion(listing.value) || "--" }, { label: '所属区服', value: getServerRegion(listing.value) || '--' },
{ label: "上号方式", value: getLoginMethod(listing.value) || "--" }, { label: '上号方式', value: getLoginMethod(listing.value) || '--' },
{ label: "游戏段位", value: listing.value.rank_level || "--" }, { label: '游戏段位', value: listing.value.rank_level || '--' },
{ label: "M单价", value: formatRatio(listing.value) }, { label: 'M单价', value: formatRatio(listing.value) },
{ label: "方便上号", value: getOnlineTimeText(listing.value) || "--" }, { label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
{ label: "预计可租", value: formatEstimatedRentalDuration(listing.value) }, { label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
{ label: "常用登录地", value: regions.length ? regions.join("、") : "--" }, { label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
{ label: "封禁记录", value: readAssetString(listing.value, "ban_record") || "无" }, { label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无' },
]; ]
}); })
async function handleCreateOrder() { async function handleCreateOrder() {
if (!listing.value) return; if (!listing.value) return
if (!session.token) { if (!session.token) {
await router.push({ path: "/login", query: { redirect: route.fullPath } }); await router.push({ path: '/login', query: { redirect: route.fullPath } })
return; return
} }
if (session.realnameStatus !== "verified") { if (session.realnameStatus !== 'verified') {
try { try {
await session.loadMe(); await session.loadMe()
} catch { } catch {
// 登录态失效时由全局请求拦截处理。 // 登录态失效时由全局请求拦截处理。
} }
} }
if (session.realnameStatus !== "verified") { if (session.realnameStatus !== 'verified') {
try { try {
await ElMessageBox.confirm("租号下单前需要完成实名认证。", "请先实名认证", { await ElMessageBox.confirm('租号下单前需要完成实名认证。', '请先实名认证', {
confirmButtonText: "去认证", confirmButtonText: '去认证',
cancelButtonText: "取消", cancelButtonText: '取消',
type: "warning", type: 'warning',
}); })
await router.push({ path: "/realname", query: { redirect: route.fullPath } }); await router.push({ path: '/realname', query: { redirect: route.fullPath } })
} catch { } catch {
// 用户取消认证时停留在当前页面。 // 用户取消认证时停留在当前页面。
} }
return; return
} }
await openAgreementBeforeOrder(); await openAgreementBeforeOrder()
} }
async function openAgreementBeforeOrder() { async function openAgreementBeforeOrder() {
agreementsLoading.value = true; agreementsLoading.value = true
try { try {
agreements.value = await fetchOrderAgreements(); agreements.value = await fetchOrderAgreements()
virtualAgreementRead.value = false; virtualAgreementRead.value = false
renterAgreementRead.value = false; renterAgreementRead.value = false
virtualAgreementChecked.value = false; virtualAgreementChecked.value = false
renterAgreementChecked.value = false; renterAgreementChecked.value = false
agreementVisible.value = true; agreementVisible.value = true
await nextTick(); await nextTick()
updateAgreementReadState("virtual"); updateAgreementReadState('virtual')
updateAgreementReadState("renter"); updateAgreementReadState('renter')
} catch (error) { } catch (error) {
ElMessage.error(readError(error, "协议加载失败")); ElMessage.error(readError(error, '协议加载失败'))
} finally { } finally {
agreementsLoading.value = false; agreementsLoading.value = false
} }
} }
async function handleConfirmAgreementAndCreateOrder() { async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) { if (!canCreateOrderAfterAgreement.value) {
ElMessage.warning("请先阅读并勾选两份协议"); ElMessage.warning('请先阅读并勾选两份协议')
return; return
} }
agreementVisible.value = false; agreementVisible.value = false
await submitOrder(); await submitOrder()
} }
async function submitOrder() { async function submitOrder() {
if (!listing.value) return; if (!listing.value) return
ordering.value = true; ordering.value = true
try { try {
const order = await createOrder(listing.value.id); const order = await createOrder(listing.value.id)
ElMessage.success("订单已创建,请完成支付"); ElMessage.success('订单已创建,请完成支付')
await router.push(`/orders/${order.id}`); await router.push(`/orders/${order.id}`)
} catch (error) { } catch (error) {
ElMessage.error(readError(error, "下单失败")); ElMessage.error(readError(error, '下单失败'))
} finally { } finally {
ordering.value = false; ordering.value = false
} }
} }
function handleAgreementScroll(type: "virtual" | "renter") { function handleAgreementScroll(type: 'virtual' | 'renter') {
updateAgreementReadState(type); updateAgreementReadState(type)
} }
function updateAgreementReadState(type: "virtual" | "renter") { function updateAgreementReadState(type: 'virtual' | 'renter') {
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value; const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
if (!el) return; if (!el) return
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8; const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
if (type === "virtual") { if (type === 'virtual') {
virtualAgreementRead.value = read; virtualAgreementRead.value = read
} else { } else {
renterAgreementRead.value = read; renterAgreementRead.value = read
} }
} }
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }) const response = (error as { response?: { data?: { message?: string } } }).response
.response; return response?.data?.message || fallback
return response?.data?.message || fallback;
} }
return fallback; return fallback
} }
// 手动锁定/解锁背景滚动 // 手动锁定/解锁背景滚动
watch(agreementVisible, (visible) => { watch(agreementVisible, visible => {
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏 // 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
const pcMain = document.querySelector('.pc-main') as HTMLElement; const pcMain = document.querySelector('.pc-main') as HTMLElement
if (!pcMain) return; if (!pcMain) return
if (visible) { if (visible) {
// 保存当前滚动位置 // 保存当前滚动位置
const scrollTop = pcMain.scrollTop || 0; const scrollTop = pcMain.scrollTop || 0
// 锁定主容器滚动 // 锁定主容器滚动
pcMain.style.overflow = 'hidden'; pcMain.style.overflow = 'hidden'
pcMain.style.position = 'fixed'; pcMain.style.position = 'fixed'
pcMain.style.top = `-${scrollTop}px`; pcMain.style.top = `-${scrollTop}px`
pcMain.style.left = '0'; pcMain.style.left = '0'
pcMain.style.right = '0'; pcMain.style.right = '0'
pcMain.style.width = '100%'; pcMain.style.width = '100%'
// 保存滚动位置供恢复使用 // 保存滚动位置供恢复使用
pcMain.dataset.scrollTop = String(scrollTop); pcMain.dataset.scrollTop = String(scrollTop)
} else { } else {
// 恢复主容器滚动 // 恢复主容器滚动
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10); const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10)
pcMain.style.overflow = ''; pcMain.style.overflow = ''
pcMain.style.position = ''; pcMain.style.position = ''
pcMain.style.top = ''; pcMain.style.top = ''
pcMain.style.left = ''; pcMain.style.left = ''
pcMain.style.right = ''; pcMain.style.right = ''
pcMain.style.width = ''; pcMain.style.width = ''
// 恢复滚动位置 // 恢复滚动位置
pcMain.scrollTop = scrollTop; pcMain.scrollTop = scrollTop
delete pcMain.dataset.scrollTop; delete pcMain.dataset.scrollTop
} }
}); })
function listingPrice(item: Listing) { function listingPrice(item: Listing) {
return formatMoney(getListingDisplayPrice(item)); return formatMoney(getListingDisplayPrice(item))
} }
</script> </script>
<template> <template>
<section class="pc-detail page" v-loading="loading"> <section class="pc-detail page" v-loading="loading">
<div class="anti-fraud-strip compact"> <div class="anti-fraud-strip compact">
<span <span>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span>
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
</div> </div>
<div v-if="listing" class="pc-detail-layout"> <div v-if="listing" class="pc-detail-layout">
<section class="pc-detail-main"> <section class="pc-detail-main">
<div class="detail-hero-card"> <div class="detail-hero-card">
<img <img v-if="coverURL" :src="coverURL" :alt="getListingTitle(listing)" />
v-if="coverURL"
:src="coverURL"
:alt="getListingTitle(listing)"
/>
<span v-else>HFB ACCOUNT</span> <span v-else>HFB ACCOUNT</span>
<div class="detail-hero-overlay"> <div class="detail-hero-overlay">
<div class="detail-tags"> <div class="detail-tags">
@@ -340,7 +337,12 @@ function listingPrice(item: Listing) {
<div class="detail-body"> <div class="detail-body">
<div class="detail-summary-row"> <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> <span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong> <strong>{{ metric.value }}</strong>
</div> </div>
@@ -349,7 +351,7 @@ function listingPrice(item: Listing) {
<section class="detail-section"> <section class="detail-section">
<div class="detail-section-head"> <div class="detail-section-head">
<h2>账号资料</h2> <h2>账号资料</h2>
<span>{{ listing.game_name || "三角洲行动" }}</span> <span>{{ listing.game_name || '三角洲行动' }}</span>
</div> </div>
<div class="detail-chip-row"> <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">
@@ -370,13 +372,17 @@ function listingPrice(item: Listing) {
<span>{{ getListingResources(listing).length }} </span> <span>{{ getListingResources(listing).length }} </span>
</div> </div>
<div class="detail-resource-grid"> <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> <span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong> <strong>{{ resource.quantity }}</strong>
<em> <em>
<b>{{ resource.mode || "--" }}</b> <b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small> <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> <small v-else>无额外收费</small>
</em> </em>
</div> </div>
@@ -402,7 +408,7 @@ function listingPrice(item: Listing) {
<div class="detail-section-head"> <div class="detail-section-head">
<h2>号主备注</h2> <h2>号主备注</h2>
</div> </div>
<p class="detail-description">{{ listing.description || "号主暂未填写详细说明。" }}</p> <p class="detail-description">{{ listing.description || '号主暂未填写详细说明。' }}</p>
</section> </section>
<section v-if="detailScreenshots.length" class="detail-section"> <section v-if="detailScreenshots.length" class="detail-section">
@@ -444,11 +450,11 @@ function listingPrice(item: Listing) {
<dl class="order-check-list"> <dl class="order-check-list">
<div> <div>
<dt>账号区服</dt> <dt>账号区服</dt>
<dd>{{ getServerRegion(listing) || "--" }}</dd> <dd>{{ getServerRegion(listing) || '--' }}</dd>
</div> </div>
<div> <div>
<dt>上号方式</dt> <dt>上号方式</dt>
<dd>{{ getLoginMethod(listing) || "--" }}</dd> <dd>{{ getLoginMethod(listing) || '--' }}</dd>
</div> </div>
<div> <div>
<dt>预计可租</dt> <dt>预计可租</dt>
@@ -463,7 +469,7 @@ function listingPrice(item: Listing) {
class="full-control" class="full-control"
@click="handleCreateOrder" @click="handleCreateOrder"
> >
{{ listing.in_transaction ? "交易中" : "立即下单" }} {{ listing.in_transaction ? '交易中' : '立即下单' }}
</el-button> </el-button>
</el-form> </el-form>
</aside> </aside>
@@ -560,7 +566,7 @@ function listingPrice(item: Listing) {
.detail-hero-card::after { .detail-hero-card::after {
position: absolute; position: absolute;
inset: 0; inset: 0;
content: ""; content: '';
background: background:
linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)), 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%); linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%);
@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from 'vue'
import { import {
emptyListingPublishOptions, emptyListingPublishOptions,
type ListingPublishOptions, type ListingPublishOptions,
} from "@/features/listings/api/listingOptions"; } from '@/features/listings/api/listingOptions'
import { fetchListings, type Listing } from "@/features/listings/api/listings"; import { fetchListings, type Listing } from '@/features/listings/api/listings'
import { import {
defaultHomeAnnouncements, defaultHomeAnnouncements,
defaultHomeBanners, defaultHomeBanners,
fetchMobileHomeConfig, fetchMobileHomeConfig,
type HomeBannerSlide, type HomeBannerSlide,
} from "@/features/listings/api/homeConfig"; } from '@/features/listings/api/homeConfig'
import { import {
assetRegions, assetRegions,
formatHafCoinM, formatHafCoinM,
@@ -29,296 +29,285 @@ import {
hasGiftResources, hasGiftResources,
readAssetNumber, readAssetNumber,
readAssetString, readAssetString,
} from "@/utils/listingDisplay"; } from '@/utils/listingDisplay'
import { listingStatusLabel } from "@/utils/statusLabels"; import { listingStatusLabel } from '@/utils/statusLabels'
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 数据加载 */ /* 数据加载 */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const loading = ref(false); const loading = ref(false)
const loadFailed = ref(false); const loadFailed = ref(false)
const listings = ref<Listing[]>([]); const listings = ref<Listing[]>([])
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions); const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const announcements = ref<string[]>(defaultHomeAnnouncements); const announcements = ref<string[]>(defaultHomeAnnouncements)
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners); const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
onMounted(() => { onMounted(() => {
loadListings(); loadListings()
loadHomeConfig(); loadHomeConfig()
}); })
async function loadListings() { async function loadListings() {
loading.value = true; loading.value = true
loadFailed.value = false; loadFailed.value = false
try { try {
listings.value = await fetchListings(); listings.value = await fetchListings()
} catch { } catch {
loadFailed.value = true; loadFailed.value = true
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
async function loadHomeConfig() { async function loadHomeConfig() {
try { try {
const config = await fetchMobileHomeConfig(); const config = await fetchMobileHomeConfig()
announcements.value = config.announcements; announcements.value = config.announcements
bannerSlides.value = config.banners; bannerSlides.value = config.banners
publishOptions.value = config.publish_options; publishOptions.value = config.publish_options
} catch { } catch {
announcements.value = defaultHomeAnnouncements; announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners; bannerSlides.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions; publishOptions.value = emptyListingPublishOptions
} }
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 排序 */ /* 排序 */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const sortBy = ref("comprehensive"); const sortBy = ref('comprehensive')
const sortOptions = [ const sortOptions = [
{ key: "comprehensive", label: "综合排序" }, { key: 'comprehensive', label: '综合排序' },
{ key: "published", label: "发布时间" }, { key: 'published', label: '发布时间' },
{ key: "awmDesc", label: "AWM数量" }, { key: 'awmDesc', label: 'AWM数量' },
{ key: "priceAsc", label: "价格最低" }, { key: 'priceAsc', label: '价格最低' },
{ key: "priceDesc", label: "价格最高" }, { key: 'priceDesc', label: '价格最高' },
]; ]
const activeSortLabel = computed( const activeSortLabel = computed(
() => sortOptions.find((o) => o.key === sortBy.value)?.label || "综合排序" () => sortOptions.find(o => o.key === sortBy.value)?.label || '综合排序'
); )
function selectSort(key: string) { function selectSort(key: string) {
sortBy.value = key; sortBy.value = key
sortOpen.value = false; sortOpen.value = false
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 筛选 — 复用移动端 FilterSection 体系 */ /* 筛选 — 复用移动端 FilterSection 体系 */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
type SelectedFilters = Record<string, string[]>; type SelectedFilters = Record<string, string[]>
type RangeFilters = Record<string, { min: string; max: string }>; type RangeFilters = Record<string, { min: string; max: string }>
type RangePresets = Record< type RangePresets = Record<string, Array<{ label: string; min: string; max: string }>>
string,
Array<{ label: string; min: string; max: string }>
>;
const selectedFilters = ref<SelectedFilters>({}); const selectedFilters = ref<SelectedFilters>({})
const rangeFilters = ref<RangeFilters>({}); const rangeFilters = ref<RangeFilters>({})
const searchValue = ref(""); const searchValue = ref('')
const sortOpen = ref(false); const sortOpen = ref(false)
const filterOpen = ref(false); const filterOpen = ref(false)
const rangePresets: RangePresets = { const rangePresets: RangePresets = {
coin: [ coin: [
{ label: "50-100", min: "50", max: "100" }, { label: '50-100', min: '50', max: '100' },
{ label: "100-200", min: "100", max: "200" }, { label: '100-200', min: '100', max: '200' },
{ label: "200-300", min: "200", max: "300" }, { label: '200-300', min: '200', max: '300' },
{ label: "300-500", min: "300", max: "500" }, { label: '300-500', min: '300', max: '500' },
{ label: "500以上", min: "500", max: "" }, { label: '500以上', min: '500', max: '' },
], ],
resource_awmAmmo: [ resource_awmAmmo: [
{ label: "0-20", min: "0", max: "20" }, { label: '0-20', min: '0', max: '20' },
{ label: "20-50", min: "20", max: "50" }, { label: '20-50', min: '20', max: '50' },
{ label: "50-100", min: "50", max: "100" }, { label: '50-100', min: '50', max: '100' },
{ label: "100-200", min: "100", max: "200" }, { label: '100-200', min: '100', max: '200' },
{ label: "200以上", min: "200", max: "" }, { label: '200以上', min: '200', max: '' },
], ],
}; }
const serverFilterOptions = computed(() => const serverFilterOptions = computed(() =>
uniqueOptions( uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
publishOptions.value.server_options )
.map((item) => item.trim())
.filter(Boolean)
)
);
const loginMethodFilterOptions = computed(() => const loginMethodFilterOptions = computed(() =>
uniqueOptions( uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
publishOptions.value.login_method_options )
.map((item) => item.trim())
.filter(Boolean)
)
);
type FilterSection = type FilterSection =
| { | {
key: string; key: string
title: string; title: string
type: "range"; type: 'range'
unit?: string; unit?: string
minPlaceholder?: string; minPlaceholder?: string
maxPlaceholder?: string; maxPlaceholder?: string
} }
| { key: string; title: string; type: "chips"; options: string[] }; | { key: string; title: string; type: 'chips'; options: string[] }
const filterSections = computed<FilterSection[]>(() => [ const filterSections = computed<FilterSection[]>(() => [
{ {
key: "price", key: 'price',
title: "价格区间", title: '价格区间',
type: "range", type: 'range',
unit: "元", unit: '元',
minPlaceholder: "最低价", minPlaceholder: '最低价',
maxPlaceholder: "最高价", maxPlaceholder: '最高价',
}, },
{ {
key: "coin", key: 'coin',
title: "哈夫币数量", title: '哈夫币数量',
type: "range", type: 'range',
unit: "M", unit: 'M',
minPlaceholder: "最低", minPlaceholder: '最低',
maxPlaceholder: "最高", maxPlaceholder: '最高',
}, },
{ {
key: "server", key: 'server',
title: "区服", title: '区服',
type: "chips", type: 'chips',
options: serverFilterOptions.value, options: serverFilterOptions.value,
}, },
{ {
key: "login", key: 'login',
title: "上号方式", title: '上号方式',
type: "chips", type: 'chips',
options: loginMethodFilterOptions.value, options: loginMethodFilterOptions.value,
}, },
{ {
key: "insurance", key: 'insurance',
title: "保险", title: '保险',
type: "chips", type: 'chips',
options: publishOptions.value.insurance_options, options: publishOptions.value.insurance_options,
}, },
{ {
key: "stamina", key: 'stamina',
title: "体力", title: '体力',
type: "chips", type: 'chips',
options: publishOptions.value.level_options, options: publishOptions.value.level_options,
}, },
{ {
key: "load", key: 'load',
title: "负重", title: '负重',
type: "chips", type: 'chips',
options: publishOptions.value.level_options, options: publishOptions.value.level_options,
}, },
...publishOptions.value.quantity_items.map((item) => ({ ...publishOptions.value.quantity_items.map(item => ({
key: `resource_${item.key}`, key: `resource_${item.key}`,
title: item.label, title: item.label,
type: "range" as const, type: 'range' as const,
unit: parseQuantityUnit(item.price), unit: parseQuantityUnit(item.price),
minPlaceholder: "最低", minPlaceholder: '最低',
maxPlaceholder: "最高", maxPlaceholder: '最高',
})), })),
...publishOptions.value.skin_groups.map((group) => ({ ...publishOptions.value.skin_groups.map(group => ({
key: group.key, key: group.key,
title: group.title, title: group.title,
type: "chips" as const, type: 'chips' as const,
options: group.options, options: group.options,
})), })),
{ {
key: "secretKd", key: 'secretKd',
title: "绝密KD", title: '绝密KD',
type: "range", type: 'range',
minPlaceholder: "最低", minPlaceholder: '最低',
maxPlaceholder: "最高", maxPlaceholder: '最高',
}, },
{ {
key: "rank", key: 'rank',
title: "段位", title: '段位',
type: "chips", type: 'chips',
options: publishOptions.value.rank_options, options: publishOptions.value.rank_options,
}, },
{ {
key: "deposit", key: 'deposit',
title: "押金", title: '押金',
type: "range", type: 'range',
unit: "元", unit: '元',
minPlaceholder: "最低", minPlaceholder: '最低',
maxPlaceholder: "最高", maxPlaceholder: '最高',
}, },
]); ])
/* 筛选交互 */ /* 筛选交互 */
const activeFilterSection = ref("price"); const activeFilterSection = ref('price')
const filterContentRef = ref<HTMLElement | null>(null); const filterContentRef = ref<HTMLElement | null>(null)
const filterSectionRefs = new Map<string, HTMLElement>(); const filterSectionRefs = new Map<string, HTMLElement>()
const filterTabRefs = new Map<string, HTMLElement>(); const filterTabRefs = new Map<string, HTMLElement>()
function toggleChip(sectionKey: string, value: string) { function toggleChip(sectionKey: string, value: string) {
const selected = selectedFilters.value[sectionKey] || []; const selected = selectedFilters.value[sectionKey] || []
const next = selected.includes(value) const next = selected.includes(value)
? selected.filter((item) => item !== value) ? selected.filter(item => item !== value)
: [...selected, value]; : [...selected, value]
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next }; selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next }
} }
function updateRange(sectionKey: string, side: "min" | "max", value: string) { function updateRange(sectionKey: string, side: 'min' | 'max', value: string) {
const current = rangeFilters.value[sectionKey] || { min: "", max: "" }; const current = rangeFilters.value[sectionKey] || { min: '', max: '' }
rangeFilters.value = { rangeFilters.value = {
...rangeFilters.value, ...rangeFilters.value,
[sectionKey]: { ...current, [side]: value }, [sectionKey]: { ...current, [side]: value },
}; }
} }
function applyRangePreset(sectionKey: string, min: string, max: string) { function applyRangePreset(sectionKey: string, min: string, max: string) {
rangeFilters.value = { rangeFilters.value = {
...rangeFilters.value, ...rangeFilters.value,
[sectionKey]: { min, max }, [sectionKey]: { min, max },
}; }
} }
function isRangePresetActive(sectionKey: string, min: string, max: string) { function isRangePresetActive(sectionKey: string, min: string, max: string) {
const range = rangeFilters.value[sectionKey]; const range = rangeFilters.value[sectionKey]
return range?.min === min && range?.max === max; return range?.min === min && range?.max === max
} }
function isSkinGroupKey(key: string) { 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) { function setFilterSectionRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) filterSectionRefs.set(key, el); if (el instanceof HTMLElement) filterSectionRefs.set(key, el)
else filterSectionRefs.delete(key); else filterSectionRefs.delete(key)
} }
function setFilterTabRef(key: string, el: Element | null) { function setFilterTabRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) filterTabRefs.set(key, el); if (el instanceof HTMLElement) filterTabRefs.set(key, el)
else filterTabRefs.delete(key); else filterTabRefs.delete(key)
} }
function scrollToFilterSection(key: string) { function scrollToFilterSection(key: string) {
const container = filterContentRef.value; const container = filterContentRef.value
const target = filterSectionRefs.get(key); const target = filterSectionRefs.get(key)
if (!container || !target) return; if (!container || !target) return
activeFilterSection.value = key; activeFilterSection.value = key
container.scrollTo({ container.scrollTo({
top: Math.max(target.offsetTop - 8, 0), top: Math.max(target.offsetTop - 8, 0),
behavior: "smooth", behavior: 'smooth',
}); })
} }
function handleFilterScroll() { function handleFilterScroll() {
const container = filterContentRef.value; const container = filterContentRef.value
if (!container) return; if (!container) return
const anchorTop = container.scrollTop + 16; const anchorTop = container.scrollTop + 16
let activeKey = filterSections.value[0]?.key || ""; let activeKey = filterSections.value[0]?.key || ''
for (const section of filterSections.value) { for (const section of filterSections.value) {
const el = filterSectionRefs.get(section.key); const el = filterSectionRefs.get(section.key)
if (el && el.offsetTop <= anchorTop) activeKey = section.key; if (el && el.offsetTop <= anchorTop) activeKey = section.key
} }
if (activeKey && activeKey !== activeFilterSection.value) { if (activeKey && activeKey !== activeFilterSection.value) {
activeFilterSection.value = activeKey; activeFilterSection.value = activeKey
} }
} }
function toggleSortPanel() { function toggleSortPanel() {
sortOpen.value = !sortOpen.value; sortOpen.value = !sortOpen.value
} }
function clearFilters() { function clearFilters() {
selectedFilters.value = {}; selectedFilters.value = {}
rangeFilters.value = {}; rangeFilters.value = {}
searchValue.value = ""; searchValue.value = ''
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -328,64 +317,59 @@ const activeFilterCount = computed(() => {
const chipCount = Object.values(selectedFilters.value).reduce( const chipCount = Object.values(selectedFilters.value).reduce(
(sum, values) => sum + values.length, (sum, values) => sum + values.length,
0 0
); )
const rangeCount = Object.values(rangeFilters.value).filter( const rangeCount = Object.values(rangeFilters.value).filter(
(range) => range.min || range.max range => range.min || range.max
).length; ).length
return chipCount + rangeCount; return chipCount + rangeCount
}); })
const filteredListings = computed(() => { const filteredListings = computed(() => {
const keyword = searchValue.value.trim().toLowerCase(); const keyword = searchValue.value.trim().toLowerCase()
const filtered = listings.value.filter((item) => { const filtered = listings.value.filter(item => {
if (!matchesFilters(item)) return false; if (!matchesFilters(item)) return false
if (!keyword) return true; if (!keyword) return true
return searchText(item).includes(keyword); return searchText(item).includes(keyword)
}); })
return sortListings(filtered); return sortListings(filtered)
}); })
function matchesFilters(item: Listing) { function matchesFilters(item: Listing) {
const chipOk = Object.entries(selectedFilters.value).every( const chipOk = Object.entries(selectedFilters.value).every(([key, values]) => {
([key, values]) => { if (!values.length) return true
if (!values.length) return true; if (key === 'server') return values.includes(getServerRegion(item))
if (key === "server") return values.includes(getServerRegion(item)); if (key === 'login') return values.includes(getLoginMethod(item))
if (key === "login") return values.includes(getLoginMethod(item)); if (key === 'insurance') return values.includes(readAssetString(item, 'season_insurance'))
if (key === "insurance") if (key === 'stamina') return values.includes(readAssetString(item, 'stamina_level'))
return values.includes(readAssetString(item, "season_insurance")); if (key === 'load') return values.includes(readAssetString(item, 'load_level'))
if (key === "stamina") if (key === 'rank') return values.includes(item.rank_level)
return values.includes(readAssetString(item, "stamina_level")); if (isSkinGroupKey(key)) {
if (key === "load") return getSkinGroup(item, key).some(skin => values.includes(skin))
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 true;
} }
); return true
if (!chipOk) return false; })
if (!chipOk) return false
return Object.entries(rangeFilters.value).every(([key, range]) => { return Object.entries(rangeFilters.value).every(([key, range]) => {
if (!range.min && !range.max) return true; if (!range.min && !range.max) return true
let value = 0; let value = 0
if (key === "price") value = getListingDisplayPrice(item); if (key === 'price') value = getListingDisplayPrice(item)
if (key === "coin") value = getCoinM(item); if (key === 'coin') value = getCoinM(item)
if (key === "secretKd") value = readAssetNumber(item, "secret_kd"); if (key === 'secretKd') value = readAssetNumber(item, 'secret_kd')
if (key === "deposit") value = Number(item.deposit_amount || 0); if (key === 'deposit') value = Number(item.deposit_amount || 0)
if (key.startsWith("resource_")) { if (key.startsWith('resource_')) {
value = getResourceQuantity(item, key.replace("resource_", "")); value = getResourceQuantity(item, key.replace('resource_', ''))
} }
return inRange(value, range); return inRange(value, range)
}); })
} }
function inRange(value: number, range: { min: string; max: string }) { function inRange(value: number, range: { min: string; max: string }) {
const min = range.min === "" ? undefined : Number(range.min); const min = range.min === '' ? undefined : Number(range.min)
const max = range.max === "" ? undefined : Number(range.max); const max = range.max === '' ? undefined : Number(range.max)
if (min !== undefined && Number.isFinite(min) && value < min) return false; if (min !== undefined && Number.isFinite(min) && value < min) return false
if (max !== undefined && Number.isFinite(max) && value > max) return false; if (max !== undefined && Number.isFinite(max) && value > max) return false
return true; return true
} }
function searchText(item: Listing) { function searchText(item: Listing) {
@@ -395,53 +379,47 @@ function searchText(item: Listing) {
getServerRegion(item), getServerRegion(item),
getLoginMethod(item), getLoginMethod(item),
item.rank_level, item.rank_level,
readAssetString(item, "season_insurance"), readAssetString(item, 'season_insurance'),
readAssetString(item, "stamina_level"), readAssetString(item, 'stamina_level'),
readAssetString(item, "load_level"), readAssetString(item, 'load_level'),
formatHafCoinM(getCoinWan(item)), formatHafCoinM(getCoinWan(item)),
assetRegions(item).join(" "), assetRegions(item).join(' '),
...publishOptions.value.skin_groups.map((group) => ...publishOptions.value.skin_groups.map(group => getSkinGroup(item, group.key).join(' ')),
getSkinGroup(item, group.key).join(" ")
),
] ]
.join(" ") .join(' ')
.toLowerCase(); .toLowerCase()
} }
function sortListings(items: Listing[]) { function sortListings(items: Listing[]) {
const sorted = [...items]; const sorted = [...items]
if (sortBy.value === "comprehensive") return sorted; if (sortBy.value === 'comprehensive') return sorted
if (sortBy.value === "priceAsc") { if (sortBy.value === 'priceAsc') {
return sorted.sort( return sorted.sort((a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b))
(a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b)
);
} }
if (sortBy.value === "priceDesc") { if (sortBy.value === 'priceDesc') {
return sorted.sort( return sorted.sort((a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a))
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a)
);
} }
if (sortBy.value === "awmDesc") { if (sortBy.value === 'awmDesc') {
return sorted.sort( return sorted.sort(
(a, b) => (a, b) =>
getResourceQuantity(b, "awmAmmo") - getResourceQuantity(a, "awmAmmo") || getResourceQuantity(b, 'awmAmmo') - getResourceQuantity(a, 'awmAmmo') ||
getCoinWan(b) - getCoinWan(a) getCoinWan(b) - getCoinWan(a)
); )
} }
return sorted.sort((a, b) => { return sorted.sort((a, b) => {
const bTime = Date.parse(b.published_at || b.created_at || "") || 0; const bTime = Date.parse(b.published_at || b.created_at || '') || 0
const aTime = Date.parse(a.published_at || a.created_at || "") || 0; const aTime = Date.parse(a.published_at || a.created_at || '') || 0
return bTime - aTime || b.id - a.id; return bTime - aTime || b.id - a.id
}); })
} }
function uniqueOptions(values: string[]) { 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) { function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim(); const unit = price.split('/')[1]?.trim()
return unit || undefined; return unit || undefined
} }
</script> </script>
@@ -450,12 +428,7 @@ function parseQuantityUnit(price: string) {
<!-- 防骗提示 --> <!-- 防骗提示 -->
<div class="fraud-tip"> <div class="fraud-tip">
<span class="fraud-dot"></span> <span class="fraud-dot"></span>
<span <span v-for="(text, idx) in announcements" :key="idx" class="fraud-text">{{ text }}</span>
v-for="(text, idx) in announcements"
:key="idx"
class="fraud-text"
>{{ text }}</span
>
</div> </div>
<!-- 页头 --> <!-- 页头 -->
@@ -479,11 +452,7 @@ function parseQuantityUnit(price: string) {
<input v-model="searchValue" placeholder="搜区服 / 段位 / 哈夫币数量" /> <input v-model="searchValue" placeholder="搜区服 / 段位 / 哈夫币数量" />
</div> </div>
<button <button type="button" class="toolbar-btn sort-btn" @click="toggleSortPanel">
type="button"
class="toolbar-btn sort-btn"
@click="toggleSortPanel"
>
<span>{{ activeSortLabel }}</span> <span>{{ activeSortLabel }}</span>
<svg <svg
:class="{ rotated: sortOpen }" :class="{ rotated: sortOpen }"
@@ -500,11 +469,7 @@ function parseQuantityUnit(price: string) {
</svg> </svg>
</button> </button>
<button <button type="button" class="toolbar-btn filter-btn" @click="filterOpen = !filterOpen">
type="button"
class="toolbar-btn filter-btn"
@click="filterOpen = !filterOpen"
>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"> <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
@@ -550,13 +515,7 @@ function parseQuantityUnit(price: string) {
<aside v-if="filterOpen" class="filter-sidebar"> <aside v-if="filterOpen" class="filter-sidebar">
<header class="filter-sidebar-header"> <header class="filter-sidebar-header">
<h2>筛选</h2> <h2>筛选</h2>
<button <button type="button" class="filter-close-btn" @click="filterOpen = false"></button>
type="button"
class="filter-close-btn"
@click="filterOpen = false"
>
</button>
</header> </header>
<div class="filter-sidebar-body"> <div class="filter-sidebar-body">
@@ -564,7 +523,7 @@ function parseQuantityUnit(price: string) {
<button <button
v-for="section in filterSections" v-for="section in filterSections"
:key="section.key" :key="section.key"
:ref="(el) => setFilterTabRef(section.key, el as Element | null)" :ref="el => setFilterTabRef(section.key, el as Element | null)"
type="button" type="button"
:class="{ active: activeFilterSection === section.key }" :class="{ active: activeFilterSection === section.key }"
@click="scrollToFilterSection(section.key)" @click="scrollToFilterSection(section.key)"
@@ -581,14 +540,11 @@ function parseQuantityUnit(price: string) {
<div <div
v-for="section in filterSections" v-for="section in filterSections"
:key="section.key" :key="section.key"
:ref="(el) => setFilterSectionRef(section.key, el as Element | null)" :ref="el => setFilterSectionRef(section.key, el as Element | null)"
class="filter-block" class="filter-block"
> >
<h3>{{ section.title }}</h3> <h3>{{ section.title }}</h3>
<p <p v-if="section.type === 'range' && section.unit" class="filter-unit">
v-if="section.type === 'range' && section.unit"
class="filter-unit"
>
单位{{ section.unit }} 单位{{ section.unit }}
</p> </p>
@@ -600,11 +556,7 @@ function parseQuantityUnit(price: string) {
inputmode="decimal" inputmode="decimal"
:placeholder="section.minPlaceholder || '最低'" :placeholder="section.minPlaceholder || '最低'"
@input=" @input="
updateRange( updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)
section.key,
'min',
($event.target as HTMLInputElement).value
)
" "
/> />
<span class="range-sep"></span> <span class="range-sep"></span>
@@ -614,32 +566,19 @@ function parseQuantityUnit(price: string) {
inputmode="decimal" inputmode="decimal"
:placeholder="section.maxPlaceholder || '最高'" :placeholder="section.maxPlaceholder || '最高'"
@input=" @input="
updateRange( updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)
section.key,
'max',
($event.target as HTMLInputElement).value
)
" "
/> />
</div> </div>
<div <div v-if="rangePresets[section.key]?.length" class="chip-grid range-preset-grid">
v-if="rangePresets[section.key]?.length"
class="chip-grid range-preset-grid"
>
<button <button
v-for="preset in rangePresets[section.key]" v-for="preset in rangePresets[section.key]"
:key="`${section.key}-${preset.label}`" :key="`${section.key}-${preset.label}`"
type="button" type="button"
:class="{ :class="{
active: isRangePresetActive( active: isRangePresetActive(section.key, preset.min, preset.max),
section.key,
preset.min,
preset.max
),
}" }"
@click=" @click="applyRangePreset(section.key, preset.min, preset.max)"
applyRangePreset(section.key, preset.min, preset.max)
"
> >
{{ preset.label }} {{ preset.label }}
</button> </button>
@@ -664,35 +603,23 @@ function parseQuantityUnit(price: string) {
</div> </div>
<footer class="filter-sidebar-footer"> <footer class="filter-sidebar-footer">
<button type="button" class="reset-btn" @click="clearFilters"> <button type="button" class="reset-btn" @click="clearFilters">重置</button>
重置 <button type="button" class="confirm-btn" @click="filterOpen = false">确定</button>
</button>
<button type="button" class="confirm-btn" @click="filterOpen = false">
确定
</button>
</footer> </footer>
</aside> </aside>
</Transition> </Transition>
<!-- 筛选遮罩 --> <!-- 筛选遮罩 -->
<Transition name="overlay"> <Transition name="overlay">
<div <div v-if="filterOpen" class="filter-overlay" @click="filterOpen = false"></div>
v-if="filterOpen"
class="filter-overlay"
@click="filterOpen = false"
></div>
</Transition> </Transition>
<!-- 加载 / 错误 / 空状态 --> <!-- 加载 / 错误 / 空状态 -->
<div v-if="loading" class="state-loading">正在加载优质账号...</div> <div v-if="loading" class="state-loading">正在加载优质账号...</div>
<div v-else-if="loadFailed" class="state-error"> <div v-else-if="loadFailed" class="state-error">接口暂不可用请稍后刷新</div>
接口暂不可用请稍后刷新
</div>
<div v-else-if="filteredListings.length === 0" class="state-empty"> <div v-else-if="filteredListings.length === 0" class="state-empty">
<p>没有符合条件的账号</p> <p>没有符合条件的账号</p>
<button type="button" class="reset-btn" @click="clearFilters"> <button type="button" class="reset-btn" @click="clearFilters">重置条件</button>
重置条件
</button>
</div> </div>
<!-- 列表 --> <!-- 列表 -->
@@ -712,10 +639,7 @@ function parseQuantityUnit(price: string) {
decoding="async" decoding="async"
/> />
<span v-else></span> <span v-else></span>
<div <div v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)" class="cover-labels">
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="cover-labels"
>
<em v-if="hasGiftResources(item)">有赠送</em> <em v-if="hasGiftResources(item)">有赠送</em>
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em> <em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
</div> </div>
@@ -726,14 +650,10 @@ function parseQuantityUnit(price: string) {
<div class="resource-badge-row"> <div class="resource-badge-row">
<span class="trust-badge">押金秒退</span> <span class="trust-badge">押金秒退</span>
<span class="server-badge">{{ getServerRegion(item) }}</span> <span class="server-badge">{{ getServerRegion(item) }}</span>
<span v-if="getLoginMethod(item)" class="server-badge">{{ <span v-if="getLoginMethod(item)" class="server-badge">{{ getLoginMethod(item) }}</span>
getLoginMethod(item)
}}</span>
</div> </div>
<div class="card-chip-row"> <div class="card-chip-row">
<span <span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`"
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
>{{ chip.label }}:{{ chip.value }}</span >{{ chip.label }}:{{ chip.value }}</span
> >
</div> </div>
@@ -797,7 +717,7 @@ function parseQuantityUnit(price: string) {
} }
.fraud-text + .fraud-text::before { .fraud-text + .fraud-text::before {
content: " | "; content: ' | ';
color: #d4c47a; color: #d4c47a;
} }
@@ -848,7 +768,9 @@ function parseQuantityUnit(price: string) {
border-radius: 10px; border-radius: 10px;
background: #f4f6f8; background: #f4f6f8;
border: 1px solid transparent; 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 { .search-box:focus-within {
@@ -890,7 +812,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s; transition:
border-color 0.15s,
box-shadow 0.15s;
} }
.toolbar-btn:hover { .toolbar-btn:hover {
@@ -950,7 +874,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.12s, color 0.12s; transition:
background 0.12s,
color 0.12s;
} }
.sort-panel button:hover { .sort-panel button:hover {
@@ -972,7 +898,9 @@ function parseQuantityUnit(price: string) {
.sort-panel-enter-active, .sort-panel-enter-active,
.sort-panel-leave-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, .sort-panel-enter-from,
@@ -1051,7 +979,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.12s, color 0.12s; transition:
background 0.12s,
color 0.12s;
} }
.filter-tabs button:hover { .filter-tabs button:hover {
@@ -1114,7 +1044,9 @@ function parseQuantityUnit(price: string) {
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
outline: none; outline: none;
transition: border-color 0.15s, box-shadow 0.15s; transition:
border-color 0.15s,
box-shadow 0.15s;
} }
.range-editor input:focus { .range-editor input:focus {
@@ -1139,7 +1071,10 @@ function parseQuantityUnit(price: string) {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; 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 { .chip-grid button:hover {
@@ -1255,7 +1190,9 @@ function parseQuantityUnit(price: string) {
border: 1px solid #eef1f5; border: 1px solid #eef1f5;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
transition: box-shadow 0.15s, border-color 0.15s; transition:
box-shadow 0.15s,
border-color 0.15s;
} }
.resource-card:hover { .resource-card:hover {
@@ -36,14 +36,21 @@ const emit = defineEmits<{
}>() }>()
const activeCount = computed(() => { const activeCount = computed(() => {
const chipCount = Object.values(props.selectedFilters).reduce((sum, values) => sum + values.length, 0) const chipCount = Object.values(props.selectedFilters).reduce(
const rangeCount = Object.values(props.rangeFilters).filter((range) => range.min || range.max).length (sum, values) => sum + values.length,
0
)
const rangeCount = Object.values(props.rangeFilters).filter(
range => range.min || range.max
).length
return chipCount + rangeCount return chipCount + rangeCount
}) })
function toggleChip(sectionKey: string, value: string) { function toggleChip(sectionKey: string, value: string) {
const selected = props.selectedFilters[sectionKey] || [] 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 }) emit('update:selectedFilters', { ...props.selectedFilters, [sectionKey]: next })
} }
@@ -140,7 +140,7 @@
.mobile-banner.has-image::after { .mobile-banner.has-image::after {
position: absolute; position: absolute;
inset: 0; inset: 0;
content: ""; content: '';
background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.72)); background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.72));
} }
@@ -1,24 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRouter } from "vue-router"; import { RouterLink, useRouter } from 'vue-router'
import { showToast } from "vant"; import { showToast } from 'vant'
import MobileBottomNav from "@/components/MobileBottomNav.vue"; import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { ensureSupportChat } from "@/features/chats/api/chats"; import { ensureSupportChat } from '@/features/chats/api/chats'
import { import {
emptyListingPublishOptions, emptyListingPublishOptions,
type ListingPublishOptions, type ListingPublishOptions,
} from "@/features/listings/api/listingOptions"; } from '@/features/listings/api/listingOptions'
import { fetchListingsPage, type Listing, type PublicListingQuery } from "@/features/listings/api/listings"; import {
fetchListingsPage,
type Listing,
type PublicListingQuery,
} from '@/features/listings/api/listings'
import { import {
defaultHomeAnnouncements, defaultHomeAnnouncements,
defaultHomeBanners, defaultHomeBanners,
fetchMobileHomeConfig, fetchMobileHomeConfig,
type HomeBannerSlide, type HomeBannerSlide,
} from "@/features/listings/api/homeConfig"; } from '@/features/listings/api/homeConfig'
import MobileHomeFilterSheet, { import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
type FilterSection,
} from "./MobileHomeFilterSheet.vue";
import { import {
getListingChips, getListingChips,
getListingDisplayPrice, getListingDisplayPrice,
@@ -28,237 +30,256 @@ import {
getServerRegion, getServerRegion,
hasAcceleratedSaleRatio, hasAcceleratedSaleRatio,
hasGiftResources, hasGiftResources,
} from "@/utils/listingDisplay"; } from '@/utils/listingDisplay'
import { useSessionStore } from "@/stores/session"; import { useSessionStore } from '@/stores/session'
const router = useRouter(); const router = useRouter()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const loadingMore = ref(false); const loadingMore = ref(false)
const loadFailed = ref(false); const loadFailed = ref(false)
const listings = ref<Listing[]>([]); const listings = ref<Listing[]>([])
const totalListings = ref(0); const totalListings = ref(0)
const currentPage = ref(1); const currentPage = ref(1)
const hasMoreListings = ref(true); const hasMoreListings = ref(true)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions); const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortOpen = ref(false); const sortOpen = ref(false)
const activeSort = ref("comprehensive"); const activeSort = ref('comprehensive')
const filterOpen = ref(false); const filterOpen = ref(false)
const selectedFilters = ref<Record<string, string[]>>({}); const selectedFilters = ref<Record<string, string[]>>({})
const rangeFilters = ref<Record<string, { min: string; max: string }>>({}); const rangeFilters = ref<Record<string, { min: string; max: string }>>({})
const refreshing = ref(false); const refreshing = ref(false)
const searchValue = ref(""); const searchValue = ref('')
const announcements = ref<string[]>(defaultHomeAnnouncements); const announcements = ref<string[]>(defaultHomeAnnouncements)
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners); const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
const supportLoading = ref(false); const supportLoading = ref(false)
const mobilePageSize = 10; const mobilePageSize = 10
let listingRequestSeq = 0; let listingRequestSeq = 0
const sortOptions = [ const sortOptions = [
{ key: "comprehensive", label: "综合排序" }, { key: 'comprehensive', label: '综合排序' },
{ key: "published", label: "发布时间" }, { key: 'published', label: '发布时间' },
{ key: "awmDesc", label: "AWM数量" }, { key: 'awmDesc', label: 'AWM数量' },
{ key: "priceAsc", label: "价格最低" }, { key: 'priceAsc', label: '价格最低' },
{ key: "priceDesc", label: "价格最高" }, { key: 'priceDesc', label: '价格最高' },
]; ]
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = { const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
coin: [ coin: [
{ label: "50-100", min: "50", max: "100" }, { label: '50-100', min: '50', max: '100' },
{ label: "100-200", min: "100", max: "200" }, { label: '100-200', min: '100', max: '200' },
{ label: "200-300", min: "200", max: "300" }, { label: '200-300', min: '200', max: '300' },
{ label: "300-500", min: "300", max: "500" }, { label: '300-500', min: '300', max: '500' },
{ label: "500以上", min: "500", max: "" }, { label: '500以上', min: '500', max: '' },
], ],
resource_awmAmmo: [ resource_awmAmmo: [
{ label: "0-20", min: "0", max: "20" }, { label: '0-20', min: '0', max: '20' },
{ label: "20-50", min: "20", max: "50" }, { label: '20-50', min: '20', max: '50' },
{ label: "50-100", min: "50", max: "100" }, { label: '50-100', min: '50', max: '100' },
{ label: "100-200", min: "100", max: "200" }, { label: '100-200', min: '100', max: '200' },
{ label: "200以上", min: "200", max: "" }, { label: '200以上', min: '200', max: '' },
], ],
}; }
const activeSortLabel = computed( const activeSortLabel = computed(
() => () => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序'
sortOptions.find((option) => option.key === activeSort.value)?.label || )
"综合排序"
);
async function handleSupportClick() { async function handleSupportClick() {
if (!session.isLoggedIn) { if (!session.isLoggedIn) {
router.push({ path: "/m/login", query: { redirect: router.currentRoute.value.fullPath } }); router.push({ path: '/m/login', query: { redirect: router.currentRoute.value.fullPath } })
return; return
} }
if (supportLoading.value) return; if (supportLoading.value) return
supportLoading.value = true; supportLoading.value = true
try { try {
const chat = await ensureSupportChat(); const chat = await ensureSupportChat()
router.push(`/m/chats/${chat.id}`); router.push(`/m/chats/${chat.id}`)
} catch { } catch {
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" }); showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' })
} finally { } finally {
supportLoading.value = false; supportLoading.value = false
} }
} }
const serverFilterOptions = computed(() => const serverFilterOptions = computed(() =>
uniqueOptions( uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
publishOptions.value.server_options )
.map((item) => item.trim())
.filter(Boolean)
)
);
const loginMethodFilterOptions = computed(() => const loginMethodFilterOptions = computed(() =>
uniqueOptions( uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
publishOptions.value.login_method_options )
.map((item) => item.trim())
.filter(Boolean)
)
);
const filterSections = computed<FilterSection[]>(() => [ const filterSections = computed<FilterSection[]>(() => [
{ key: "price", title: "价格区间", type: "range", unit: "元", minPlaceholder: "最低价", maxPlaceholder: "最高价" }, {
{ key: "coin", title: "哈夫币数量", type: "range", unit: "M", minPlaceholder: "最低", maxPlaceholder: "最高" }, key: 'price',
{ key: "server", title: "区服", type: "chips", options: serverFilterOptions.value }, title: '价格区间',
{ key: "login", title: "上号方式", type: "chips", options: loginMethodFilterOptions.value }, type: 'range',
{ key: "insurance", title: "保险", type: "chips", options: publishOptions.value.insurance_options }, unit: '元',
{ key: "stamina", title: "体力", type: "chips", options: publishOptions.value.level_options }, minPlaceholder: '最低价',
{ key: "load", title: "负重", type: "chips", options: publishOptions.value.level_options }, maxPlaceholder: '最高价',
...publishOptions.value.quantity_items.map((item) => ({ },
{
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}`, key: `resource_${item.key}`,
title: item.label, title: item.label,
type: "range" as const, type: 'range' as const,
unit: parseQuantityUnit(item.price), unit: parseQuantityUnit(item.price),
minPlaceholder: "最低", minPlaceholder: '最低',
maxPlaceholder: "最高", maxPlaceholder: '最高',
})), })),
...publishOptions.value.skin_groups.map((group) => ({ ...publishOptions.value.skin_groups.map(group => ({
key: group.key, key: group.key,
title: group.title, title: group.title,
type: "chips" as const, type: 'chips' as const,
options: group.options, options: group.options,
})), })),
{ key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" }, {
{ key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options }, key: 'secretKd',
{ key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" }, 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 activeFilterCount = computed(() => {
const chipCount = Object.values(selectedFilters.value).reduce( const chipCount = Object.values(selectedFilters.value).reduce(
(sum, values) => sum + values.length, (sum, values) => sum + values.length,
0 0
); )
const rangeCount = Object.values(rangeFilters.value).filter( const rangeCount = Object.values(rangeFilters.value).filter(
(range) => range.min || range.max range => range.min || range.max
).length; ).length
return chipCount + rangeCount; return chipCount + rangeCount
}); })
const displayListings = computed(() => { const displayListings = computed(() => {
return listings.value; return listings.value
}); })
onMounted(() => { onMounted(() => {
loadListings(); loadListings()
loadHomeConfig(); loadHomeConfig()
window.addEventListener("scroll", handleWindowScroll, { passive: true }); window.addEventListener('scroll', handleWindowScroll, { passive: true })
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("scroll", handleWindowScroll); window.removeEventListener('scroll', handleWindowScroll)
}); })
watch( watch(
() => listingQuerySignature(), () => listingQuerySignature(),
() => { () => {
loadListings(true); loadListings(true)
} }
); )
async function loadListings(reset = true) { async function loadListings(reset = true) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return; if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return
const requestSeq = ++listingRequestSeq; const requestSeq = ++listingRequestSeq
if (reset) { if (reset) {
loading.value = true; loading.value = true
currentPage.value = 1; currentPage.value = 1
hasMoreListings.value = true; hasMoreListings.value = true
} }
loadingMore.value = true; loadingMore.value = true
loadFailed.value = false; loadFailed.value = false
try { try {
const page = await fetchListingsPage(buildListingQuery(currentPage.value)); const page = await fetchListingsPage(buildListingQuery(currentPage.value))
if (requestSeq !== listingRequestSeq) return; if (requestSeq !== listingRequestSeq) return
listings.value = reset ? page.items : [...listings.value, ...page.items]; listings.value = reset ? page.items : [...listings.value, ...page.items]
totalListings.value = page.total; totalListings.value = page.total
hasMoreListings.value = listings.value.length < page.total; hasMoreListings.value = listings.value.length < page.total
currentPage.value = page.page + 1; currentPage.value = page.page + 1
requestAnimationFrame(handleWindowScroll); requestAnimationFrame(handleWindowScroll)
} catch { } catch {
if (reset) { if (reset) {
listings.value = []; listings.value = []
totalListings.value = 0; totalListings.value = 0
hasMoreListings.value = false; hasMoreListings.value = false
loadFailed.value = true; loadFailed.value = true
} }
} finally { } finally {
if (requestSeq === listingRequestSeq) { if (requestSeq === listingRequestSeq) {
loading.value = false; loading.value = false
loadingMore.value = false; loadingMore.value = false
} }
} }
} }
async function loadHomeConfig() { async function loadHomeConfig() {
try { try {
const config = await fetchMobileHomeConfig(); const config = await fetchMobileHomeConfig()
announcements.value = config.announcements; announcements.value = config.announcements
bannerSlides.value = config.banners; bannerSlides.value = config.banners
publishOptions.value = config.publish_options; publishOptions.value = config.publish_options
} catch { } catch {
announcements.value = defaultHomeAnnouncements; announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners; bannerSlides.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions; publishOptions.value = emptyListingPublishOptions
} }
} }
async function onRefresh() { async function onRefresh() {
refreshing.value = true; refreshing.value = true
try { try {
const [, nextHomeConfig] = await Promise.all([ const [, nextHomeConfig] = await Promise.all([loadListings(true), fetchMobileHomeConfig()])
loadListings(true), announcements.value = nextHomeConfig.announcements
fetchMobileHomeConfig(), bannerSlides.value = nextHomeConfig.banners
]); publishOptions.value = nextHomeConfig.publish_options
announcements.value = nextHomeConfig.announcements; showToast({ message: '刷新成功', icon: 'passed' })
bannerSlides.value = nextHomeConfig.banners;
publishOptions.value = nextHomeConfig.publish_options;
showToast({ message: "刷新成功", icon: "passed" });
} catch { } catch {
// 静默处理 // 静默处理
} finally { } finally {
refreshing.value = false; refreshing.value = false
} }
} }
function openFilters() { function openFilters() {
sortOpen.value = false; sortOpen.value = false
filterOpen.value = true; filterOpen.value = true
} }
function toggleSortPanel() { function toggleSortPanel() {
sortOpen.value = !sortOpen.value; sortOpen.value = !sortOpen.value
} }
function selectSort(sortKey: string) { function selectSort(sortKey: string) {
activeSort.value = sortKey; activeSort.value = sortKey
sortOpen.value = false; sortOpen.value = false
} }
function clearFilters() { function clearFilters() {
selectedFilters.value = {}; selectedFilters.value = {}
rangeFilters.value = {}; rangeFilters.value = {}
searchValue.value = ""; searchValue.value = ''
} }
function buildListingQuery(page: number): PublicListingQuery { function buildListingQuery(page: number): PublicListingQuery {
@@ -267,79 +288,78 @@ function buildListingQuery(page: number): PublicListingQuery {
page_size: mobilePageSize, page_size: mobilePageSize,
keyword: searchValue.value.trim(), keyword: searchValue.value.trim(),
sort: activeSort.value, sort: activeSort.value,
}; }
const skinGroups: string[] = []; const skinGroups: string[] = []
const skinNames: string[] = []; const skinNames: string[] = []
for (const [key, values] of Object.entries(selectedFilters.value)) { for (const [key, values] of Object.entries(selectedFilters.value)) {
const value = values.filter(Boolean).join(","); const value = values.filter(Boolean).join(',')
if (!value) continue; if (!value) continue
if (key === "server") query.server = value; if (key === 'server') query.server = value
else if (key === "login") query.login_method = value; else if (key === 'login') query.login_method = value
else if (key === "insurance") query.insurance = value; else if (key === 'insurance') query.insurance = value
else if (key === "stamina") query.stamina = value; else if (key === 'stamina') query.stamina = value
else if (key === "load") query.load = value; else if (key === 'load') query.load = value
else if (key === "rank") query.rank = value; else if (key === 'rank') query.rank = value
else if (isSkinGroupKey(key)) { else if (isSkinGroupKey(key)) {
skinGroups.push(key); skinGroups.push(key)
skinNames.push(...values); skinNames.push(...values)
} }
} }
if (skinGroups.length) query.skin_group = skinGroups.join(","); if (skinGroups.length) query.skin_group = skinGroups.join(',')
if (skinNames.length) query.skin_name = skinNames.join(","); if (skinNames.length) query.skin_name = skinNames.join(',')
for (const [key, range] of Object.entries(rangeFilters.value)) { for (const [key, range] of Object.entries(rangeFilters.value)) {
if (!range.min && !range.max) continue; if (!range.min && !range.max) continue
const min = parseOptionalNumber(range.min); const min = parseOptionalNumber(range.min)
const max = parseOptionalNumber(range.max); const max = parseOptionalNumber(range.max)
if (key === "price") { if (key === 'price') {
query.min_price = min; query.min_price = min
query.max_price = max; query.max_price = max
} else if (key === "coin") { } else if (key === 'coin') {
query.min_coin = min; query.min_coin = min
query.max_coin = max; query.max_coin = max
} else if (key === "secretKd") { } else if (key === 'secretKd') {
query.min_secret_kd = min; query.min_secret_kd = min
query.max_secret_kd = max; query.max_secret_kd = max
} else if (key === "deposit") { } else if (key === 'deposit') {
query.min_deposit = min; query.min_deposit = min
query.max_deposit = max; query.max_deposit = max
} else if (key.startsWith("resource_")) { } else if (key.startsWith('resource_')) {
const resourceKey = key.replace("resource_", ""); const resourceKey = key.replace('resource_', '')
query[`resource_${resourceKey}_min`] = min; query[`resource_${resourceKey}_min`] = min
query[`resource_${resourceKey}_max`] = max; query[`resource_${resourceKey}_max`] = max
} }
} }
return query; return query
} }
function listingQuerySignature() { function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1)); return JSON.stringify(buildListingQuery(1))
} }
function parseOptionalNumber(value: string) { function parseOptionalNumber(value: string) {
if (value === "") return undefined; if (value === '') return undefined
const number = Number(value); const number = Number(value)
return Number.isFinite(number) ? number : undefined; return Number.isFinite(number) ? number : undefined
} }
function handleWindowScroll() { function handleWindowScroll() {
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return; if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
loadListings(false); loadListings(false)
} }
function isSkinGroupKey(key: string) { 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) { function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim(); const unit = price.split('/')[1]?.trim()
return unit || undefined; return unit || undefined
} }
function uniqueOptions(values: string[]) { 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> </script>
<template> <template>
@@ -360,8 +380,13 @@ function uniqueOptions(values: string[]) {
placeholder="搜区服 / 段位" placeholder="搜区服 / 段位"
class="home-search" class="home-search"
/> />
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick"> <button
{{ supportLoading ? "接入中" : "客服" }} class="mobile-service"
type="button"
:disabled="supportLoading"
@click="handleSupportClick"
>
{{ supportLoading ? '接入中' : '客服' }}
</button> </button>
</div> </div>
@@ -388,10 +413,7 @@ function uniqueOptions(values: string[]) {
<section class="mobile-content"> <section class="mobile-content">
<!-- Banner 轮播 --> <!-- Banner 轮播 -->
<van-swipe class="banner-swipe" :autoplay="3600" lazy-render> <van-swipe class="banner-swipe" :autoplay="3600" lazy-render>
<van-swipe-item <van-swipe-item v-for="slide in bannerSlides" :key="slide.title || slide.image_url">
v-for="slide in bannerSlides"
:key="slide.title || slide.image_url"
>
<div <div
class="mobile-banner" class="mobile-banner"
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]" :class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
@@ -432,11 +454,7 @@ function uniqueOptions(values: string[]) {
@click="selectSort(option.key)" @click="selectSort(option.key)"
> >
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
<van-icon <van-icon v-if="activeSort === option.key" name="success" :size="18" />
v-if="activeSort === option.key"
name="success"
:size="18"
/>
</button> </button>
</div> </div>
<div class="result-count"> <div class="result-count">
@@ -460,9 +478,7 @@ function uniqueOptions(values: string[]) {
image="search" image="search"
description="没有符合条件的账号" description="没有符合条件的账号"
> >
<van-button size="small" type="primary" @click="clearFilters"> <van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
重置条件
</van-button>
</van-empty> </van-empty>
<!-- 列表卡片全宽上下布局 --> <!-- 列表卡片全宽上下布局 -->
@@ -510,10 +526,7 @@ function uniqueOptions(values: string[]) {
</div> </div>
</div> </div>
<div class="card-chip-row"> <div class="card-chip-row">
<span <span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`">
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
>
{{ chip.label }}:{{ chip.value }} {{ chip.label }}:{{ chip.value }}
</span> </span>
</div> </div>
@@ -1,12 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue"; import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from "vant"; import { showToast, showDialog } from 'vant'
import { fetchListing, type Listing } from "@/features/listings/api/listings"; import { fetchListing, type Listing } from '@/features/listings/api/listings'
import { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders"; import {
import { useSessionStore } from "@/stores/session"; createOrder,
import { formatMoney } from "@/shared/utils/money"; fetchOrderAgreements,
type OrderAgreements,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { formatMoney } from '@/shared/utils/money'
import { import {
assetRegions, assetRegions,
formatHafCoinM, formatHafCoinM,
@@ -23,241 +27,240 @@ import {
getLoginMethod, getLoginMethod,
getServerRegion, getServerRegion,
readAssetString, readAssetString,
} from "@/utils/listingDisplay"; } from '@/utils/listingDisplay'
const route = useRoute(); const route = useRoute()
const router = useRouter(); const router = useRouter()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const ordering = ref(false); const ordering = ref(false)
const agreementsLoading = ref(false); const agreementsLoading = ref(false)
const agreementVisible = ref(false); const agreementVisible = ref(false)
const listing = ref<Listing | null>(null); const listing = ref<Listing | null>(null)
const agreements = ref<OrderAgreements | null>(null); const agreements = ref<OrderAgreements | null>(null)
const virtualAgreementRead = ref(false); const virtualAgreementRead = ref(false)
const renterAgreementRead = ref(false); const renterAgreementRead = ref(false)
const virtualAgreementChecked = ref(false); const virtualAgreementChecked = ref(false)
const renterAgreementChecked = ref(false); const renterAgreementChecked = ref(false)
const virtualAgreementRef = ref<HTMLElement | null>(null); const virtualAgreementRef = ref<HTMLElement | null>(null)
const renterAgreementRef = ref<HTMLElement | null>(null); const renterAgreementRef = ref<HTMLElement | null>(null)
const canCreateOrderAfterAgreement = computed( const canCreateOrderAfterAgreement = computed(
() => () =>
virtualAgreementRead.value && virtualAgreementRead.value &&
renterAgreementRead.value && renterAgreementRead.value &&
virtualAgreementChecked.value && virtualAgreementChecked.value &&
renterAgreementChecked.value, renterAgreementChecked.value
); )
onMounted(async () => { onMounted(async () => {
loading.value = true; loading.value = true
try { try {
listing.value = await fetchListing(String(route.params.id)); listing.value = await fetchListing(String(route.params.id))
} catch { } catch {
showToast({ message: "加载失败", icon: "warning-o" }); showToast({ message: '加载失败', icon: 'warning-o' })
} finally { } finally {
loading.value = false; loading.value = false
} }
}); })
const orderTotal = computed(() => { const orderTotal = computed(() => {
if (!listing.value) return "0.0"; if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value)); return formatMoney(getListingDisplayPrice(listing.value))
}); })
const orderPriceBreakdown = computed(() => { const orderPriceBreakdown = computed(() => {
if (!listing.value) { if (!listing.value) {
return { return {
rent: 0, rent: 0,
consumable: 0, consumable: 0,
}; }
} }
return { return {
rent: getListingRentPrice(listing.value), rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value), consumable: getListingConsumablePrice(listing.value),
}; }
}); })
const detailMetrics = computed(() => { const detailMetrics = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value); const dailyLoss = getDailyLoss(listing.value)
return [ return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" }, { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{ {
label: "日损耗", label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : "--", value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: "coin", tone: 'coin',
}, },
{ label: "价格", value: `¥${getListingDisplayPrice(listing.value)}`, tone: "price" }, { label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" }, { label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
]; ]
}); })
const detailScreenshots = computed(() => { const detailScreenshots = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value); const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots; if (groupedScreenshots.length) return groupedScreenshots
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"]; const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({ return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`, label: labels[index] || `账号截图${index + 1}`,
url, url,
})); }))
}); })
function readGroupedScreenshots(item: Listing) { function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups; const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== "object" || groups === null) return []; if (typeof groups !== 'object' || groups === null) return []
const slots = [ const slots = [
{ key: "coin", label: "纯币截图" }, { key: 'coin', label: '纯币截图' },
{ key: "gameId", label: "游戏ID截图" }, { key: 'gameId', label: '游戏ID截图' },
{ key: "totalAsset", label: "总资产截图" }, { key: 'totalAsset', label: '总资产截图' },
{ key: "tencentSecurity", label: "腾讯安全中心截图" }, { key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: "skin", label: "皮肤截图" }, { key: 'skin', label: '皮肤截图' },
]; ]
return slots.flatMap((slot) => { return slots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]; const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []; if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url)); const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({ return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label, label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url, url,
})); }))
}); })
} }
const detailSkinGroups = computed(() => { const detailSkinGroups = computed(() => {
if (!listing.value) return []; if (!listing.value) return []
const groups = listing.value.asset_summary?.skin_groups; const groups = listing.value.asset_summary?.skin_groups
if (typeof groups !== "object" || groups === null) return []; if (typeof groups !== 'object' || groups === null) return []
const titles: Record<string, string> = { const titles: Record<string, string> = {
melee: "近战皮肤", melee: '近战皮肤',
operator: "干员皮肤", operator: '干员皮肤',
operatorGold: "干员金皮", operatorGold: '干员金皮',
operatorRed: "干员红皮", operatorRed: '干员红皮',
weapon: "武器皮肤", weapon: '武器皮肤',
}; }
return Object.entries(groups as Record<string, unknown>) return Object.entries(groups as Record<string, unknown>)
.map(([key, value]) => ({ .map(([key, value]) => ({
key, key,
title: titles[key] || key, title: titles[key] || key,
options: Array.isArray(value) 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() { async function handleCreateOrder() {
if (!listing.value) return; if (!listing.value) return
if (!session.token) { if (!session.token) {
showDialog({ showDialog({
title: "请先登录", title: '请先登录',
message: "下单需要登录账号,是否前往登录?", message: '下单需要登录账号,是否前往登录?',
confirmButtonText: "去登录", confirmButtonText: '去登录',
cancelButtonText: "取消", cancelButtonText: '取消',
showCancelButton: true, showCancelButton: true,
}).then(() => { }).then(() => {
router.push({ path: "/m/login", query: { redirect: route.fullPath } }); router.push({ path: '/m/login', query: { redirect: route.fullPath } })
}); })
return; return
} }
if (session.realnameStatus !== "verified") { if (session.realnameStatus !== 'verified') {
try { try {
await session.loadMe(); await session.loadMe()
} catch { } catch {
// 401 会由全局拦截器处理。 // 401 会由全局拦截器处理。
} }
} }
if (session.realnameStatus !== "verified") { if (session.realnameStatus !== 'verified') {
showDialog({ showDialog({
title: "请先实名认证", title: '请先实名认证',
message: "租号下单前需要完成实名认证。", message: '租号下单前需要完成实名认证。',
confirmButtonText: "去认证", confirmButtonText: '去认证',
cancelButtonText: "取消", cancelButtonText: '取消',
showCancelButton: true, showCancelButton: true,
}).then(() => { }).then(() => {
router.push({ path: "/m/realname", query: { redirect: route.fullPath } }); router.push({ path: '/m/realname', query: { redirect: route.fullPath } })
}); })
return; return
} }
await openAgreementBeforeOrder(); await openAgreementBeforeOrder()
} }
async function openAgreementBeforeOrder() { async function openAgreementBeforeOrder() {
agreementsLoading.value = true; agreementsLoading.value = true
try { try {
agreements.value = await fetchOrderAgreements(); agreements.value = await fetchOrderAgreements()
virtualAgreementRead.value = false; virtualAgreementRead.value = false
renterAgreementRead.value = false; renterAgreementRead.value = false
virtualAgreementChecked.value = false; virtualAgreementChecked.value = false
renterAgreementChecked.value = false; renterAgreementChecked.value = false
agreementVisible.value = true; agreementVisible.value = true
await nextTick(); await nextTick()
updateAgreementReadState("virtual"); updateAgreementReadState('virtual')
updateAgreementReadState("renter"); updateAgreementReadState('renter')
} catch (error) { } catch (error) {
showToast({ message: readError(error, "协议加载失败"), icon: "cross" }); showToast({ message: readError(error, '协议加载失败'), icon: 'cross' })
} finally { } finally {
agreementsLoading.value = false; agreementsLoading.value = false
} }
} }
async function handleConfirmAgreementAndCreateOrder() { async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) { if (!canCreateOrderAfterAgreement.value) {
showToast("请先阅读并勾选两份协议"); showToast('请先阅读并勾选两份协议')
return; return
} }
agreementVisible.value = false; agreementVisible.value = false
await submitOrder(); await submitOrder()
} }
async function submitOrder() { async function submitOrder() {
if (!listing.value) return; if (!listing.value) return
ordering.value = true; ordering.value = true
try { try {
await createOrder(listing.value.id); await createOrder(listing.value.id)
showToast({ message: "订单已创建,请完成支付", icon: "passed" }); showToast({ message: '订单已创建,请完成支付', icon: 'passed' })
await router.push(`/m/orders`); await router.push(`/m/orders`)
} catch (error) { } catch (error) {
showToast({ message: readError(error, "下单失败"), icon: "cross" }); showToast({ message: readError(error, '下单失败'), icon: 'cross' })
} finally { } finally {
ordering.value = false; ordering.value = false
} }
} }
function handleAgreementScroll(type: "virtual" | "renter") { function handleAgreementScroll(type: 'virtual' | 'renter') {
updateAgreementReadState(type); updateAgreementReadState(type)
} }
function updateAgreementReadState(type: "virtual" | "renter") { function updateAgreementReadState(type: 'virtual' | 'renter') {
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value; const el = type === 'virtual' ? virtualAgreementRef.value : renterAgreementRef.value
if (!el) return; if (!el) return
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8; const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
if (type === "virtual") { if (type === 'virtual') {
virtualAgreementRead.value = read; virtualAgreementRead.value = read
} else { } else {
renterAgreementRead.value = read; renterAgreementRead.value = read
} }
} }
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }) const response = (error as { response?: { data?: { message?: string } } }).response
.response; return response?.data?.message || fallback
return response?.data?.message || fallback;
} }
return fallback; return fallback
} }
/** 判断当前底部导航是否激活 */ /** 判断当前底部导航是否激活 */
function isNavActive(path: string) { function isNavActive(path: string) {
if (path === "/m") return route.path === "/m"; if (path === '/m') return route.path === '/m'
return route.path.startsWith(path); return route.path.startsWith(path)
} }
</script> </script>
@@ -280,9 +283,7 @@ function isNavActive(path: string) {
<!-- 防骗提示 --> <!-- 防骗提示 -->
<div class="fraud-tip"> <div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" /> <van-icon name="shield-o" :size="14" color="#ff9800" />
<span <span>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span>
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
</div> </div>
<!-- 封面图 --> <!-- 封面图 -->
@@ -299,10 +300,7 @@ function isNavActive(path: string) {
<span>暂无截图</span> <span>暂无截图</span>
</div> </div>
<!-- 截图指示器 --> <!-- 截图指示器 -->
<div <div v-if="detailScreenshots.length > 1" class="cover-count">
v-if="detailScreenshots.length > 1"
class="cover-count"
>
{{ detailScreenshots.length }} {{ detailScreenshots.length }}
</div> </div>
</div> </div>
@@ -310,15 +308,11 @@ function isNavActive(path: string) {
<!-- 标题信息 --> <!-- 标题信息 -->
<div class="info-card"> <div class="info-card">
<div class="info-tag-row"> <div class="info-tag-row">
<van-tag plain type="primary" size="medium">{{ <van-tag plain type="primary" size="medium">{{ getServerRegion(listing) }}</van-tag>
getServerRegion(listing)
}}</van-tag>
<van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium"> <van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium">
{{ getLoginMethod(listing) }} {{ getLoginMethod(listing) }}
</van-tag> </van-tag>
<van-tag v-if="listing.rank_level" plain size="medium">{{ <van-tag v-if="listing.rank_level" plain size="medium">{{ listing.rank_level }}</van-tag>
listing.rank_level
}}</van-tag>
</div> </div>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2> <h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p> <p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
@@ -336,10 +330,7 @@ function isNavActive(path: string) {
<div class="info-card"> <div class="info-card">
<h3 class="card-subtitle">账号资料</h3> <h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row"> <div class="detail-chip-row">
<span <span v-for="chip in getListingChips(listing)" :key="chip.label">
v-for="chip in getListingChips(listing)"
:key="chip.label"
>
{{ chip.label }}:{{ chip.value }} {{ chip.label }}:{{ chip.value }}
</span> </span>
</div> </div>
@@ -349,11 +340,11 @@ function isNavActive(path: string) {
</div> </div>
<div class="info-line"> <div class="info-line">
<span class="info-label">常用登录地</span> <span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join("、") || "--" }}</span> <span class="info-text">{{ assetRegions(listing).join('、') || '--' }}</span>
</div> </div>
<div v-if="readAssetString(listing, 'ban_record')" class="info-line"> <div v-if="readAssetString(listing, 'ban_record')" class="info-line">
<span class="info-label">封禁记录</span> <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>
</div> </div>
@@ -368,9 +359,9 @@ function isNavActive(path: string) {
<span>{{ resource.label }}</span> <span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong> <strong>{{ resource.quantity }}</strong>
<em> <em>
<b>{{ resource.mode || "--" }}</b> <b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small> <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> <small v-else>无额外收费</small>
</em> </em>
</div> </div>
@@ -427,11 +418,18 @@ function isNavActive(path: string) {
loading-text="下单中..." loading-text="下单中..."
@click="handleCreateOrder" @click="handleCreateOrder"
> >
{{ listing.in_transaction ? "交易中" : "立即下单" }} {{ listing.in_transaction ? '交易中' : '立即下单' }}
</van-button> </van-button>
</div> </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"> <div v-if="agreements" class="agreement-popup-body">
<h3>下单协议确认</h3> <h3>下单协议确认</h3>
<p>请完整阅读并勾选以下两份协议后继续下单</p> <p>请完整阅读并勾选以下两份协议后继续下单</p>
@@ -444,7 +442,11 @@ function isNavActive(path: string) {
> >
{{ agreements.virtual_asset_purchase.content }} {{ agreements.virtual_asset_purchase.content }}
</div> </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 }} 我已阅读并同意{{ agreements.virtual_asset_purchase.title }}
</van-checkbox> </van-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span> <span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
@@ -458,7 +460,11 @@ function isNavActive(path: string) {
> >
{{ agreements.renter_agreement.content }} {{ agreements.renter_agreement.content }}
</div> </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 }} 我已阅读并同意{{ agreements.renter_agreement.title }}
</van-checkbox> </van-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span> <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) { 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 }, params: { page, page_size: pageSize },
}) })
return data.data return data.data
@@ -181,27 +183,39 @@ export async function cancelOrder(id: number) {
} }
export async function submitHandoff(id: number, content: string) { 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 return data.data
} }
export async function fetchHandoffRecords(id: string | number) { 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 return data.data.items
} }
export async function fetchAdminHandoffRecords(id: string | number) { 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 return data.data.items
} }
export async function adminCloseOrder(id: number, reason: string) { 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 return data.data
} }
export async function adminMarkOrderAbnormal(id: number, reason: string) { 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 return data.data
} }
@@ -220,7 +234,9 @@ export async function adminRefundOrder(id: number) {
} }
export async function adminRefundStatus(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 return data.data
} }
@@ -230,46 +246,67 @@ export interface StartOrderPaymentRequest {
} }
export async function startOrderPayment(orderId: number, req?: 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 return data.data
} }
export async function queryOrderPayment(orderId: number) { 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 return data.data
} }
export async function confirmReceive(id: number) { 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 return data.data
} }
export async function submitReturn(id: number, content: string) { 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 return data.data
} }
export async function confirmReturn(id: number) { 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 return data.data
} }
export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) { 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 return data.data
} }
export async function confirmCheckout(id: number) { 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 return data.data
} }
export async function counterCheckout(id: number, payload: CounterCheckoutPayload) { 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 return data.data
} }
export async function acceptCheckout(id: number) { 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 return data.data
} }
@@ -56,13 +56,21 @@ export function useOrderDetail() {
const canOpenDispute = computed(() => { const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false if (!order.value || (!isOwner.value && !isRenter.value)) return false
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes( return ![
order.value.status 'completed',
) 'cancelled',
'closed',
'disputing',
'checkout_disputing',
'abnormal',
].includes(order.value.status)
}) })
const isCheckoutDisputeStage = computed(() => { 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 // Methods
@@ -182,7 +190,7 @@ export function useOrderDetail() {
function linesToList(value: string) { function linesToList(value: string) {
return value return value
.split('\n') .split('\n')
.map((line) => line.trim()) .map(line => line.trim())
.filter(Boolean) .filter(Boolean)
} }
@@ -36,14 +36,14 @@ export function readSnapshotResources(order: Order | null): SnapshotResource[] {
const quantities = snapshot.quantities as Record<string, any>[] const quantities = snapshot.quantities as Record<string, any>[]
return quantities return quantities
.map((item) => ({ .map(item => ({
key: String(item.key || ''), key: String(item.key || ''),
label: String(item.label || ''), label: String(item.label || ''),
quantity: readNumber(item.quantity), quantity: readNumber(item.quantity),
unitPrice: readNumber(item.price), unitPrice: readNumber(item.price),
chargeMode: (item.charge_mode === '收费' ? '收费' : '赠送') as SnapshotResource['chargeMode'], 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 { export function isChargedResource(resource: SnapshotResource): boolean {
@@ -106,7 +106,7 @@ export function hydrateResourceUsageFromOrder(
const consumedResources = info.consumed_resources as Record<string, number> | undefined const consumedResources = info.consumed_resources as Record<string, number> | undefined
if (consumedResources) { if (consumedResources) {
resources.forEach((res) => { resources.forEach(res => {
if (res.key in consumedResources) { if (res.key in consumedResources) {
usage[res.key] = consumedResources[res.key] ?? 0 usage[res.key] = consumedResources[res.key] ?? 0
} }
@@ -147,7 +147,7 @@ export function useSettlement(order: Ref<Order | null>) {
function linesToList(value: string) { function linesToList(value: string) {
return value return value
.split('\n') .split('\n')
.map((line) => line.trim()) .map(line => line.trim())
.filter(Boolean) .filter(Boolean)
} }
File diff suppressed because it is too large Load Diff
@@ -1,139 +1,143 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from 'vue'
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from 'vue-router'
import { showDialog, showToast } from "vant"; import { showDialog, showToast } from 'vant'
import MobileBottomNav from "@/components/MobileBottomNav.vue"; import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchOrders, startOrderPayment, type Order, type PaymentOrder } from "@/features/orders/api/orders"; import {
import { useSessionStore } from "@/stores/session"; fetchOrders,
import { formatDateMinute } from "@/utils/time"; startOrderPayment,
type Order,
type PaymentOrder,
} from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/utils/time'
const router = useRouter(); const router = useRouter()
const route = useRoute(); const route = useRoute()
const session = useSessionStore(); const session = useSessionStore()
const loading = ref(false); const loading = ref(false)
const orders = ref<Order[]>([]); const orders = ref<Order[]>([])
const activeTab = ref("all"); const activeTab = ref('all')
const payingOrderId = ref<number | null>(null); const payingOrderId = ref<number | null>(null)
onMounted(() => { onMounted(() => {
loadOrders(); loadOrders()
if (route.query.tab) { if (route.query.tab) {
activeTab.value = String(route.query.tab); activeTab.value = String(route.query.tab)
} }
}); })
async function loadOrders() { async function loadOrders() {
loading.value = true; loading.value = true
try { try {
orders.value = await fetchOrders(); orders.value = await fetchOrders()
} catch { } catch {
showToast({ message: "订单加载失败,请稍后重试", icon: "warning-o" }); showToast({ message: '订单加载失败,请稍后重试', icon: 'warning-o' })
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
/* 状态筛选 */ /* 状态筛选 */
const statusTabs = [ const statusTabs = [
{ key: "all", label: "全部" }, { key: 'all', label: '全部' },
{ key: "pending_payment", label: "待支付" }, { key: 'pending_payment', label: '待支付' },
{ key: "pending_handoff", label: "待交接" }, { key: 'pending_handoff', label: '待交接' },
{ key: "renting", label: "使用中" }, { key: 'renting', label: '使用中' },
{ key: "pending_checkout_confirm", label: "待结账" }, { key: 'pending_checkout_confirm', label: '待结账' },
{ key: "completed", label: "已完成" }, { key: 'completed', label: '已完成' },
]; ]
const displayOrders = computed(() => { const displayOrders = computed(() => {
if (activeTab.value === "all") return orders.value; if (activeTab.value === 'all') return orders.value
return orders.value.filter((o) => o.status === activeTab.value); return orders.value.filter(o => o.status === activeTab.value)
}); })
function statusLabel(status: string) { function statusLabel(status: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
pending_payment: "待支付", pending_payment: '待支付',
pending_handoff: "待交接", pending_handoff: '待交接',
renting: "使用中", renting: '使用中',
overdue: "已逾期", overdue: '已逾期',
pending_return_confirm: "待结账", pending_return_confirm: '待结账',
pending_checkout_confirm: "待号主确认", pending_checkout_confirm: '待号主确认',
pending_checkout_accept: "待租客确认", pending_checkout_accept: '待租客确认',
checkout_disputing: "结账争议中", checkout_disputing: '结账争议中',
completed: "已完成", completed: '已完成',
cancelled: "已取消", cancelled: '已取消',
disputing: "申诉中", disputing: '申诉中',
abnormal: "异常", abnormal: '异常',
closed: "已关闭", closed: '已关闭',
}; }
return map[status] || status; return map[status] || status
} }
function goDetail(id: number) { function goDetail(id: number) {
router.push(`/m/orders/${id}`); router.push(`/m/orders/${id}`)
} }
async function handlePay(order: Order) { async function handlePay(order: Order) {
payingOrderId.value = order.id; payingOrderId.value = order.id
try { try {
const payment = await startOrderPayment(order.id); const payment = await startOrderPayment(order.id)
if (payment.paid) { if (payment.paid) {
showToast({ message: "支付成功,等待号主交接", icon: "passed" }); showToast({ message: '支付成功,等待号主交接', icon: 'passed' })
await loadOrders(); await loadOrders()
} else { } else {
openPaymentCashier(payment); openPaymentCashier(payment)
} }
} catch (error) { } catch (error) {
showToast({ message: readError(error, "支付失败"), icon: "cross" }); showToast({ message: readError(error, '支付失败'), icon: 'cross' })
} finally { } finally {
payingOrderId.value = null; payingOrderId.value = null
} }
} }
function openPaymentCashier(payment: PaymentOrder) { 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)) { if (payURL && /^https?:\/\//i.test(payURL)) {
window.location.href = payURL; window.location.href = payURL
return; return
} }
showDialog({ showDialog({
title: "订单支付", title: '订单支付',
message: payURL || "支付单已创建,请在订单详情页刷新支付状态。", message: payURL || '支付单已创建,请在订单详情页刷新支付状态。',
confirmButtonText: "查看详情", confirmButtonText: '查看详情',
}).then(() => { }).then(() => {
router.push(`/m/orders/${payment.order_id}`); router.push(`/m/orders/${payment.order_id}`)
}); })
} }
function readError(error: unknown, fallback: string) { function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) { if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }) const response = (error as { response?: { data?: { message?: string } } }).response
.response; return response?.data?.message || fallback
return response?.data?.message || fallback;
} }
return fallback; return fallback
} }
function money(value: unknown) { function money(value: unknown) {
return Math.round(Number(value || 0)); return Math.round(Number(value || 0))
} }
function isOwner(order: Order) { function isOwner(order: Order) {
return order.owner_id === session.userId; return order.owner_id === session.userId
} }
function amountLabel(order: Order) { function amountLabel(order: Order) {
return isOwner(order) ? "预计租金" : "支付租金"; return isOwner(order) ? '预计租金' : '支付租金'
} }
function orderRentAmount(order: Order) { function orderRentAmount(order: Order) {
if (isOwner(order)) return Number(order.owner_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); return Number(order.rent_amount ?? order.display_amount ?? 0)
} }
function ownerActualIncome(order: Order) { function ownerActualIncome(order: Order) {
if (!isOwner(order)) return null; if (!isOwner(order)) return null
const value = order.checkout?.owner_income_amount; const value = order.checkout?.owner_income_amount
return typeof value === "number" ? value : null; return typeof value === 'number' ? value : null
} }
</script> </script>
@@ -161,12 +165,7 @@ function ownerActualIncome(order: Order) {
swipeable swipeable
animated animated
> >
<van-tab <van-tab v-for="tab in statusTabs" :key="tab.key" :title="tab.label" :name="tab.key" />
v-for="tab in statusTabs"
:key="tab.key"
:title="tab.label"
:name="tab.key"
/>
</van-tabs> </van-tabs>
<!-- 订单列表 --> <!-- 订单列表 -->
@@ -208,7 +207,9 @@ function ownerActualIncome(order: Order) {
<div class="price-item"> <div class="price-item">
<span class="price-label">{{ amountLabel(order) }}</span> <span class="price-label">{{ amountLabel(order) }}</span>
<span class="price-val">¥{{ money(orderRentAmount(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>
<div class="price-item"> <div class="price-item">
<span class="price-label">押金金额</span> <span class="price-label">押金金额</span>
@@ -235,9 +236,7 @@ function ownerActualIncome(order: Order) {
> >
去支付 去支付
</van-button> </van-button>
<span v-else class="detail-link"> <span v-else class="detail-link"> 查看详情 <van-icon name="arrow" :size="10" /> </span>
查看详情 <van-icon name="arrow" :size="10" />
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -334,8 +333,12 @@ function ownerActualIncome(order: Order) {
border-radius: 16px; border-radius: 16px;
margin-bottom: 14px; margin-bottom: 14px;
padding: 16px; padding: 16px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02); box-shadow:
transition: transform 0.1s ease, box-shadow 0.1s ease; 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); border: 1px solid rgba(243, 244, 246, 0.9);
cursor: pointer; cursor: pointer;
} }
@@ -368,19 +371,58 @@ function ownerActualIncome(order: Order) {
} }
/* 状态徽章颜色 - 现代轻量化配色 */ /* 状态徽章颜色 - 现代轻量化配色 */
.badge-pending_payment { color: #ff6a00; background: rgba(255, 106, 0, 0.08); } .badge-pending_payment {
.badge-pending_handoff { color: #d97706; background: rgba(217, 119, 6, 0.08); } color: #ff6a00;
.badge-renting { color: #1477ff; background: rgba(20, 119, 255, 0.08); } background: rgba(255, 106, 0, 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_handoff {
.badge-pending_checkout_confirm { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); } color: #d97706;
.badge-pending_checkout_accept { color: #a855f7; background: rgba(168, 85, 247, 0.08); } background: rgba(217, 119, 6, 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-renting {
.badge-cancelled { color: #9ca3af; background: rgba(156, 163, 175, 0.08); } color: #1477ff;
.badge-disputing { color: #ef4444; background: rgba(239, 68, 68, 0.08); } background: rgba(20, 119, 255, 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-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 { .card-body {
padding: 12px 0 0; 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 isRenter = computed(() => order.value?.renter_id === session.userId)
const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金')) const orderAmountLabel = computed(() => (isOwner.value ? '预计租金' : '支付租金'))
const orderRentDisplayAmount = computed(() => (order.value ? orderRentAmount(order.value) : 0)) const orderRentDisplayAmount = computed(() => (order.value ? orderRentAmount(order.value) : 0))
const ownerIncomeDisplayAmount = computed(() => (order.value ? ownerActualIncome(order.value) : null)) const ownerIncomeDisplayAmount = computed(() =>
const ownerIncomeLabel = computed(() => (order.value?.status === 'completed' ? '实际到手' : '结账预计到手')) order.value ? ownerActualIncome(order.value) : null
)
const ownerIncomeLabel = computed(() =>
order.value?.status === 'completed' ? '实际到手' : '结账预计到手'
)
const canOpenDispute = computed(() => { const canOpenDispute = computed(() => {
if (!order.value || (!isOwner.value && !isRenter.value)) return false 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(() => { 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 checkoutResources = computed(() => {
const resources = readSnapshotResources() const resources = readSnapshotResources()
return resources.filter((item) => item.quantity > 0) return resources.filter(item => item.quantity > 0)
}) })
const resourceChargeAmount = computed(() => { const resourceChargeAmount = computed(() => {
return roundMoney( return roundMoney(
@@ -98,7 +112,7 @@ const resourceChargeAmount = computed(() => {
if (!isChargedResource(item)) return sum if (!isChargedResource(item)) return sum
const used = readResourceUsage(item.key) const used = readResourceUsage(item.key)
return sum + used * item.unitPrice return sum + used * item.unitPrice
}, 0), }, 0)
) )
}) })
const snapshotHafCoinM = computed(() => { const snapshotHafCoinM = computed(() => {
@@ -106,20 +120,22 @@ const snapshotHafCoinM = computed(() => {
return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000) return roundQuantity(readNumber(snapshot?.haf_coin_amount) / 1000000)
}) })
const remainingHafCoinM = computed(() => { 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) { function getOrderStep(status: string) {
const stepMap: Record<string, number> = { const stepMap: Record<string, number> = {
'pending_payment': 0, pending_payment: 0,
'pending_handoff': 1, pending_handoff: 1,
'renting': 2, renting: 2,
'overdue': 2, overdue: 2,
'pending_checkout_confirm': 3, pending_checkout_confirm: 3,
'pending_checkout_accept': 3, pending_checkout_accept: 3,
'completed': 4, completed: 4,
'cancelled': 0, cancelled: 0,
'closed': 4, closed: 4,
} }
return stepMap[status] ?? 0 return stepMap[status] ?? 0
} }
@@ -489,7 +505,7 @@ function readSnapshotResources(): CheckoutResource[] {
if (!Array.isArray(resources)) return [] if (!Array.isArray(resources)) return []
return resources return resources
.filter(isRecord) .filter(isRecord)
.map((item) => { .map(item => {
const key = String(item.key || item.label || '') const key = String(item.key || item.label || '')
const label = String(item.label || key || '额外消耗品') const label = String(item.label || key || '额外消耗品')
const price = String(item.price || '') const price = String(item.price || '')
@@ -502,13 +518,16 @@ function readSnapshotResources(): CheckoutResource[] {
unitPrice: readUnitPrice(price), unitPrice: readUnitPrice(price),
} }
}) })
.filter((item) => item.key && item.quantity > 0) .filter(item => item.key && item.quantity > 0)
} }
function hydrateResourceUsage() { function hydrateResourceUsage() {
const next: Record<string, number> = {} const next: Record<string, number> = {}
for (const item of checkoutResources.value) { 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 resourceUsage.value = next
} }
@@ -527,16 +546,21 @@ function resourceLineAmount(item: CheckoutResource) {
function checkoutContentWithSummary() { function checkoutContentWithSummary() {
const lines = [checkoutForm.value.content.trim()].filter(Boolean) 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) { if (usedResources.length) {
lines.push( lines.push(
`额外消耗品:${usedResources `额外消耗品:${usedResources
.map((item) => `${item.label} ${readResourceUsage(item.key)}/${item.quantity}${isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款'}`) .map(
.join('')}`, item =>
`${item.label} ${readResourceUsage(item.key)}/${item.quantity}${isChargedResource(item) ? `,金额¥${money(resourceLineAmount(item))}` : ',赠送不扣款'}`
)
.join('')}`
) )
} }
if (Number(checkoutForm.value.coin_consumed_m || 0) > 0) { 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) { if (lines.length === 0) {
lines.push('租客发起结账。') lines.push('租客发起结账。')
@@ -571,7 +595,8 @@ function money(value: unknown) {
} }
function orderRentAmount(item: Order) { 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) if (item.renter_id === session.userId) return Number(item.rent_amount ?? item.display_amount ?? 0)
return Number(item.display_amount ?? 0) return Number(item.display_amount ?? 0)
} }
@@ -608,7 +633,7 @@ function hydrateCounterForm() {
function linesToList(value: string) { function linesToList(value: string) {
return value return value
.split('\n') .split('\n')
.map((item) => item.trim()) .map(item => item.trim())
.filter(Boolean) .filter(Boolean)
} }
@@ -662,7 +687,7 @@ function formatHandoffRecordType(type: string) {
<p class="order-meta">{{ order.server_region }} / {{ order.login_platform }}</p> <p class="order-meta">{{ order.server_region }} / {{ order.login_platform }}</p>
</div> </div>
<el-button type="primary" :loading="openingChat" @click="openOrderChat"> <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> </el-button>
</div> </div>
@@ -670,308 +695,488 @@ function formatHandoffRecordType(type: string) {
<div v-if="order" class="content-layout"> <div v-if="order" class="content-layout">
<div class="main-content"> <div class="main-content">
<div v-if="order" class="order-progress-section"> <div v-if="order" class="order-progress-section">
<el-steps :active="getOrderStep(order.status)" align-center finish-status="success" process-status="process"> <el-steps
<el-step title="待支付" :description="order.status === 'pending_payment' ? '等待租客支付' : ''" /> :active="getOrderStep(order.status)"
<el-step title="待交接" :description="order.status === 'pending_handoff' ? handoffStatusLabel(order.handoff_status) : ''" /> align-center
<el-step title="使用中" :description="['renting', 'overdue'].includes(order.status) ? '租赁进行中' : ''" /> finish-status="success"
<el-step title="结账中" :description="['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status) ? '等待确认' : ''" /> process-status="process"
<el-step title="已完成" :description="order.status === 'completed' ? '订单完成' : ''" /> >
</el-steps> <el-step
</div> title="待支付"
:description="order.status === 'pending_payment' ? '等待租客支付' : ''"
<div v-if="order" class="detail-grid">
<div class="metric-card primary">
<span class="metric-label">订单状态</span>
<strong class="metric-value">{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">交接状态</span>
<strong class="metric-value">{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card highlight">
<span class="metric-label">{{ orderAmountLabel }}</span>
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
</div>
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="metric-card income">
<span class="metric-label">{{ ownerIncomeLabel }}</span>
<strong class="metric-value amount">¥{{ money(ownerIncomeDisplayAmount) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">押金</span>
<strong class="metric-value">¥{{ money(order.deposit_amount) }}</strong>
</div>
</div>
<div v-if="order" class="info-section">
<div class="info-card">
<div class="info-row">
<span class="info-label">开始时间</span>
<span class="info-value">{{ formatDateTime(orderRentedAt(), '未开始') }}</span>
</div>
<div class="info-row">
<span class="info-label">预计截止</span>
<span class="info-value">{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</span>
</div>
</div>
<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>
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
</div>
<div v-if="order.status === 'pending_handoff'" class="action-card info">
<div class="action-buttons">
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
</div>
</div>
<div v-if="order" class="timeline-section">
<div class="section-header">
<h2>交接记录</h2>
</div>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<el-timeline v-else>
<el-timeline-item
v-for="record in handoffRecords"
:key="record.id"
:timestamp="formatDateTime(record.created_at)"
placement="top"
>
<div class="timeline-content">
<div class="timeline-title">{{ formatHandoffRecordType(record.type) }}</div>
<div class="timeline-body">{{ record.content }}</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
<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="填写结账说明、租后资产状态或注意事项" />
<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">
<div class="checkout-resource-meta">
<strong>{{ item.label }}</strong>
<span>库存 {{ item.quantity }}{{ item.mode }}{{ item.price || '未设置单价' }}</span>
</div>
<el-input-number
v-model="resourceUsage[item.key]"
:min="0"
:max="item.quantity"
:precision="0"
controls-position="right"
/> />
<span class="checkout-resource-amount">¥{{ money(resourceLineAmount(item)) }}</span> <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>
<div v-if="order" class="detail-grid">
<div class="metric-card primary">
<span class="metric-label">订单状态</span>
<strong class="metric-value">{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">交接状态</span>
<strong class="metric-value">{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card highlight">
<span class="metric-label">{{ orderAmountLabel }}</span>
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
</div>
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="metric-card income">
<span class="metric-label">{{ ownerIncomeLabel }}</span>
<strong class="metric-value amount">¥{{ money(ownerIncomeDisplayAmount) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">押金</span>
<strong class="metric-value">¥{{ money(order.deposit_amount) }}</strong>
</div> </div>
</div> </div>
<el-form class="form-grid" label-position="top">
<el-form-item label="消耗哈夫币(M"> <div v-if="order" class="info-section">
<el-input-number v-model="checkoutForm.coin_consumed_m" class="full-control" :min="0" :max="snapshotHafCoinM" :precision="2" controls-position="right" /> <div class="info-card">
<span class="field-hint">订单快照 {{ quantity(snapshotHafCoinM) }}M预计剩余 {{ quantity(remainingHafCoinM) }}M</span> <div class="info-row">
</el-form-item> <span class="info-label">开始时间</span>
<el-form-item label="其他押金赔付(元)"> <span class="info-value">{{ formatDateTime(orderRentedAt(), '未开始') }}</span>
<el-input-number v-model="checkoutForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" /> </div>
<span class="field-hint">不含上方额外消耗品仅填写封禁违规资产损坏等需要从押金赔付的费用</span> <div class="info-row">
</el-form-item> <span class="info-label">预计截止</span>
</el-form> <span class="info-value">{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</span>
<div class="checkout-deduct-preview"> </div>
<div>
<span>额外消耗品已用</span>
<strong>¥{{ money(resourceChargeAmount) }}</strong>
<em>按上方物资数量自动计算计入实际结算租金</em>
</div> </div>
<div>
<span>其他押金赔付</span> <div v-if="order.status === 'pending_payment'" class="action-card warning">
<strong>¥{{ money(checkoutForm.other_amount) }}</strong> <p class="action-message">订单待支付支付后账号进入交接流程</p>
<em>作为押金赔付扣除和额外消耗品分开展示</em> <div class="action-buttons">
<el-button
v-if="isRenter"
type="primary"
size="large"
:loading="startingPayment"
@click="handlePay"
>
立即支付订单
</el-button>
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
</div>
<div v-if="order.status === 'pending_handoff'" class="action-card info">
<div class="action-buttons">
<el-button type="danger" plain :loading="cancelling" @click="handleCancel">
取消订单并释放账号
</el-button>
</div>
</div> </div>
</div> </div>
<el-input
v-model="checkoutForm.evidenceText"
type="textarea"
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
/>
<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" class="timeline-section">
<div class="section-header"> <div class="section-header">
<h2>结账明细</h2> <h2>交接记录</h2>
</div> </div>
<div class="checkout-summary-card"> <el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div class="summary-row"> <el-timeline v-else>
<span>实际结算租金</span> <el-timeline-item
<strong>¥{{ money(order.checkout.display_amount) }}</strong> v-for="record in handoffRecords"
:key="record.id"
:timestamp="formatDateTime(record.created_at)"
placement="top"
>
<div class="timeline-content">
<div class="timeline-title">{{ formatHandoffRecordType(record.type) }}</div>
<div class="timeline-body">{{ record.content }}</div>
</div>
</el-timeline-item>
</el-timeline>
</div> </div>
<div class="summary-row">
<span>预收押金</span>
<strong>¥{{ money(order.checkout.deposit_amount) }}</strong>
</div>
<div class="summary-row">
<span>额外消耗品已用</span>
<strong class="warning">¥{{ money(order.checkout.consumable_amount) }}</strong>
</div>
<div class="summary-row">
<span>押金赔付扣除</span>
<strong class="warning">¥{{ money(order.checkout.deposit_deduct_amount) }}</strong>
</div>
<div v-if="isRenter" class="summary-row highlight">
<span>退还租客未使用租金 + 剩余押金</span>
<strong class="amount">¥{{ money(order.checkout.renter_refund_amount) }}</strong>
</div>
<div v-if="isOwner" class="summary-row highlight">
<span>号主最终收入租金 + 押金赔付</span>
<strong class="amount">¥{{ money(order.checkout.owner_income_amount) }}</strong>
</div>
<div v-if="order.checkout.content" class="summary-note">
<label>说明</label>
<p>{{ order.checkout.content }}</p>
</div>
<div v-if="order.checkout.owner_adjustment_reason" class="summary-note warning">
<label>修正原因</label>
<p>{{ order.checkout.owner_adjustment_reason }}</p>
</div>
</div>
</div>
<div v-if="order && isOwner && order.status === 'pending_checkout_confirm'" class="form-section counter-checkout-section"> <div
<div class="section-header"> v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)"
<h2>修改结账</h2> class="form-section checkout-form-section"
</div> >
<div class="form-card"> <div class="section-header">
<h3 class="sub-title">修改结账金额</h3> <h2>发起结账</h2>
<el-form class="form-grid" label-position="top"> </div>
<el-form-item label="额外消耗品已用金额"> <div class="form-card">
<el-input-number v-model="counterForm.consumable_amount" class="full-control" :min="0" :precision="0" controls-position="right" /> <el-input
</el-form-item> v-model="checkoutForm.content"
<el-form-item label="消耗哈夫币(M"> type="textarea"
<el-input-number v-model="counterForm.coin_consumed_m" class="full-control" :min="0" :precision="2" controls-position="right" /> :rows="4"
</el-form-item> placeholder="填写结账说明、租后资产状态或注意事项"
<el-form-item label="其他押金扣款(元)"> />
<el-input-number v-model="counterForm.other_amount" class="full-control" :min="0" :precision="0" controls-position="right" /> <div class="checkout-resource-panel">
</el-form-item> <div class="checkout-resource-head">
<el-form-item label="押金扣除(元)"> <strong>额外消耗品</strong>
<el-input-number v-model="counterForm.deposit_deduct_amount" class="full-control" :min="0" :max="order.deposit_amount" :precision="0" controls-position="right" /> <span class="amount-highlight">已用金额¥{{ money(resourceChargeAmount) }}</span>
</el-form-item> </div>
</el-form> <el-empty
<el-input v-model="counterForm.reason" type="textarea" :rows="3" placeholder="填写修改原因" /> v-if="checkoutResources.length === 0"
<el-input v-model="counterForm.evidenceText" type="textarea" :rows="3" placeholder="修正证据链接,一行一个" /> description="订单快照中暂无额外消耗品"
<el-button type="warning" size="large" :loading="countering" @click="handleCounterCheckout">提交修正给租客确认</el-button> />
</div> <div
</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
>
</div>
<el-input-number
v-model="resourceUsage[item.key]"
:min="0"
:max="item.quantity"
:precision="0"
controls-position="right"
/>
<span class="checkout-resource-amount">¥{{ money(resourceLineAmount(item)) }}</span>
</div>
</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-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-form-item>
</el-form>
<div class="checkout-deduct-preview">
<div>
<span>额外消耗品已用</span>
<strong>¥{{ money(resourceChargeAmount) }}</strong>
<em>按上方物资数量自动计算计入实际结算租金</em>
</div>
<div>
<span>其他押金赔付</span>
<strong>¥{{ money(checkoutForm.other_amount) }}</strong>
<em>作为押金赔付扣除和额外消耗品分开展示</em>
</div>
</div>
<el-input
v-model="checkoutForm.evidenceText"
type="textarea"
:rows="3"
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
/>
<el-button
type="primary"
size="large"
:loading="returning"
@click="handleSubmitCheckout"
>发起结账</el-button
>
</div>
</div>
<div v-if="order && isRenter && order.status === 'pending_checkout_accept'" class="form-section reject-checkout-section"> <div
<div class="section-header"> v-if="
<h2>拒绝修正</h2> order &&
</div> order.checkout &&
<div class="form-card"> ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
<p class="form-hint">不同意修正时填写原因订单会进入争议处理</p> "
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="不同意时填写原因,会进入争议处理" /> class="info-section"
<el-button type="danger" plain size="large" :loading="rejectingCheckout" @click="handleRejectCheckout">拒绝修正并发起争议</el-button> >
</div> <div class="section-header">
</div> <h2>结账明细</h2>
</div> </div>
<div class="checkout-summary-card">
<div class="summary-row">
<span>实际结算租金</span>
<strong>¥{{ money(order.checkout.display_amount) }}</strong>
</div>
<div class="summary-row">
<span>预收押金</span>
<strong>¥{{ money(order.checkout.deposit_amount) }}</strong>
</div>
<div class="summary-row">
<span>额外消耗品已用</span>
<strong class="warning">¥{{ money(order.checkout.consumable_amount) }}</strong>
</div>
<div class="summary-row">
<span>押金赔付扣除</span>
<strong class="warning">¥{{ money(order.checkout.deposit_deduct_amount) }}</strong>
</div>
<div v-if="isRenter" class="summary-row highlight">
<span>退还租客(未使用租金 + 剩余押金)</span>
<strong class="amount">¥{{ money(order.checkout.renter_refund_amount) }}</strong>
</div>
<div v-if="isOwner" class="summary-row highlight">
<span>号主最终收入(租金 + 押金赔付)</span>
<strong class="amount">¥{{ money(order.checkout.owner_income_amount) }}</strong>
</div>
<div v-if="order.checkout.content" class="summary-note">
<label>说明:</label>
<p>{{ order.checkout.content }}</p>
</div>
<div v-if="order.checkout.owner_adjustment_reason" class="summary-note warning">
<label>修正原因:</label>
<p>{{ order.checkout.owner_adjustment_reason }}</p>
</div>
</div>
</div>
<!-- 右侧悬浮操作区 --> <div
<div v-if="order" class="action-sidebar"> v-if="order && isOwner && order.status === 'pending_checkout_confirm'"
<!-- 交接操作 --> class="form-section counter-checkout-section"
<div v-if="isOwner && order.status === 'pending_handoff' && order.handoff_status === 'pending_owner'" class="sidebar-card"> >
<h3 class="sidebar-title">提交交接说明</h3> <div class="section-header">
<el-input v-model="handoffContent" type="textarea" :rows="5" placeholder="填写登录方式、注意事项和交接说明" /> <h2>修改结账</h2>
<el-button type="primary" size="large" :loading="handoffing" @click="handleSubmitHandoff">提交交接</el-button> </div>
<div class="form-card">
<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-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-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-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-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
>
</div>
</div>
<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
>
</div>
</div>
</div> </div>
<!-- 确认收号 --> <!-- 右侧悬浮操作区 -->
<div <div v-if="order" class="action-sidebar">
v-if="isRenter && order.status === 'pending_handoff' && order.handoff_status === 'pending_renter_confirm'" <!-- 交接操作 -->
class="sidebar-card" <div
> v-if="
<h3 class="sidebar-title">确认收号</h3> isOwner &&
<p class="sidebar-hint">确认账号可以正常登录后订单会进入使用中并重新计算预计截止时间</p> order.status === 'pending_handoff' &&
<el-button type="primary" size="large" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button> order.handoff_status === 'pending_owner'
</div> "
class="sidebar-card"
<!-- 发起结账 --> >
<div v-if="isRenter && ['renting', 'overdue'].includes(order.status)" class="sidebar-card highlight-card"> <h3 class="sidebar-title">提交交接说明</h3>
<h3 class="sidebar-title">快捷结账</h3> <el-input
<el-button type="primary" size="large" :loading="returning" @click="handleSubmitCheckout"> v-model="handoffContent"
立即提交结账 type="textarea"
</el-button> :rows="5"
<p class="sidebar-hint">如需修改消耗品哈夫币等详细信息请先向下滚动填写完整表单后再提交</p> placeholder="填写登录方式、注意事项和交接说明"
<el-button type="default" size="small" @click="scrollToCheckout"> />
查看详细结账表单 <el-button type="primary" size="large" :loading="handoffing" @click="handleSubmitHandoff"
</el-button> >提交交接</el-button
</div> >
<!-- 确认结账 -->
<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>
<div class="section-divider-mini">
<span>或者</span>
</div> </div>
<el-button type="warning" plain size="large" @click="scrollToCounterCheckout">
需要修改结账金额
</el-button>
<p class="sidebar-hint">如认为金额有误点击上方按钮修改</p>
</div>
<!-- 确认修正结账 --> <!-- 确认收号 -->
<div v-if="isRenter && order.status === 'pending_checkout_accept'" class="sidebar-card"> <div
<h3 class="sidebar-title">确认修正结账</h3> v-if="
<el-button type="primary" size="large" :loading="acceptingCheckout" @click="handleAcceptCheckout"> isRenter &&
同意修正并完成订单 order.status === 'pending_handoff' &&
</el-button> order.handoff_status === 'pending_renter_confirm'
<div class="section-divider-mini"> "
<span>或者</span> 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
>
</div> </div>
<el-button type="danger" plain size="large" @click="scrollToRejectCheckout">
不同意发起争议
</el-button>
<p class="sidebar-hint">不同意修正时需填写原因将进入争议处理</p>
</div>
<!-- 发起申诉 --> <!-- 发起结账 -->
<div v-if="canOpenDispute" class="sidebar-card dispute-card"> <div
<h3 class="sidebar-title">{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h3> v-if="isRenter && ['renting', 'overdue'].includes(order.status)"
<el-button type="warning" size="large" @click="scrollToDispute"> class="sidebar-card highlight-card"
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }} >
</el-button> <h3 class="sidebar-title">快捷结账</h3>
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p> <el-button type="primary" size="large" :loading="returning" @click="handleSubmitCheckout">
立即提交结账
</el-button>
<p class="sidebar-hint">
如需修改消耗品、哈夫币等详细信息,请先向下滚动填写完整表单后再提交
</p>
<el-button type="default" size="small" @click="scrollToCheckout">
查看详细结账表单
</el-button>
</div>
<!-- 确认结账 -->
<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>
<div class="section-divider-mini">
<span>或者</span>
</div>
<el-button type="warning" plain size="large" @click="scrollToCounterCheckout">
需要修改结账金额
</el-button>
<p class="sidebar-hint">如认为金额有误,点击上方按钮修改</p>
</div>
<!-- 确认修正结账 -->
<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>
<div class="section-divider-mini">
<span>或者</span>
</div>
<el-button type="danger" plain size="large" @click="scrollToRejectCheckout">
不同意,发起争议
</el-button>
<p class="sidebar-hint">不同意修正时需填写原因,将进入争议处理</p>
</div>
<!-- 发起申诉 -->
<div v-if="canOpenDispute" class="sidebar-card dispute-card">
<h3 class="sidebar-title">{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h3>
<el-button type="warning" size="large" @click="scrollToDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
</el-button>
<p class="sidebar-hint">点击后滚动到申诉表单填写详细信息</p>
</div>
</div> </div>
</div> </div>
</div>
<div v-if="order && canOpenDispute" class="form-section dispute-section"> <div v-if="order && canOpenDispute" class="form-section dispute-section">
<div class="section-header"> <div class="section-header">
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2> <h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
</div> </div>
<div class="form-card"> <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="cannot_login" />
<el-option label="虚假描述" value="false_description" /> <el-option label="虚假描述" value="false_description" />
<el-option label="账号被封" value="account_banned" /> <el-option label="账号被封" value="account_banned" />
@@ -993,7 +1198,12 @@ function formatHandoffRecordType(type: string) {
placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接" placeholder="证据链接,一行一个。开发阶段可先填截图地址或备注链接"
/> />
<div class="upload-line"> <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> </div>
<el-button type="warning" size="large" :loading="disputing" @click="handleCreateDispute"> <el-button type="warning" size="large" :loading="disputing" @click="handleCreateDispute">
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }} {{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
@@ -1597,7 +1807,7 @@ function formatHandoffRecordType(type: string) {
border-radius: 6px; border-radius: 6px;
} }
.upload-line input[type="file"] { .upload-line input[type='file'] {
width: 100%; width: 100%;
font-size: 14px; font-size: 14px;
} }

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