增加前端格式检查配置
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
auto-imports.d.ts
|
||||||
|
components.d.ts
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"vueIndentScriptAndStyle": false
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
Generated
+1282
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 订单列表 -->
|
<!-- 订单列表 -->
|
||||||
@@ -203,12 +202,14 @@ function ownerActualIncome(order: Order) {
|
|||||||
<span class="info-tag">{{ order.server_region }}</span>
|
<span class="info-tag">{{ order.server_region }}</span>
|
||||||
<span class="info-tag">{{ order.login_platform }}</span>
|
<span class="info-tag">{{ order.login_platform }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="price-row">
|
<div class="price-row">
|
||||||
<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
Reference in New Issue
Block a user