增加前端格式检查配置
This commit is contained in:
@@ -26,7 +26,10 @@ export async function fetchAdminAnnouncements(params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResult<Announcement>> {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>('/admin/announcements', { params })
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>(
|
||||
'/admin/announcements',
|
||||
{ params }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -40,7 +43,10 @@ export async function createAnnouncement(req: CreateAnnouncementRequest): Promis
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(id: number, req: UpdateAnnouncementRequest): Promise<Announcement> {
|
||||
export async function updateAnnouncement(
|
||||
id: number,
|
||||
req: UpdateAnnouncementRequest
|
||||
): Promise<Announcement> {
|
||||
const { data } = await apiClient.put<ApiResponse<Announcement>>(`/admin/announcements/${id}`, req)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ export interface AdminAuditQuery {
|
||||
}
|
||||
|
||||
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params })
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||
)
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>(
|
||||
'/admin/audit-logs',
|
||||
{ params }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ export async function fetchAdminCaptcha() {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function loginAdmin(username: string, password: string, captchaId: string, captchaCode: string) {
|
||||
export async function loginAdmin(
|
||||
username: string,
|
||||
password: string,
|
||||
captchaId: string,
|
||||
captchaCode: string
|
||||
) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminLoginData>>('/admin/auth/login', {
|
||||
username,
|
||||
password,
|
||||
@@ -66,9 +71,12 @@ export async function logoutAdmin() {
|
||||
}
|
||||
|
||||
export async function updateSupportStatus(status: 'online' | 'offline' | 'busy') {
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean; support_status: string }>>('/admin/me/support-status', {
|
||||
status,
|
||||
})
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean; support_status: string }>>(
|
||||
'/admin/me/support-status',
|
||||
{
|
||||
status,
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -76,7 +84,11 @@ export async function updateSupportStatus(status: 'online' | 'offline' | 'busy')
|
||||
export async function refreshAdminSession() {
|
||||
const refreshToken = getRefreshToken('admin')
|
||||
if (!refreshToken) throw new Error('no refresh token')
|
||||
const { data } = await axios.post<ApiResponse<AdminTokenPair>>('/api/admin/auth/refresh', { refresh_token: refreshToken }, { timeout: 10000 })
|
||||
const { data } = await axios.post<ApiResponse<AdminTokenPair>>(
|
||||
'/api/admin/auth/refresh',
|
||||
{ refresh_token: refreshToken },
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
setAuthTokens('admin', data.data)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -37,9 +37,12 @@ export interface ChangePasswordRequest {
|
||||
}
|
||||
|
||||
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>('/admin/admin-users', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>(
|
||||
'/admin/admin-users',
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -59,18 +62,26 @@ export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) {
|
||||
}
|
||||
|
||||
export async function deleteAdminMgrUser(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/admin-users/${id}`)
|
||||
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
|
||||
`/admin/admin-users/${id}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function assignAdminRoles(id: number, roleIds: number[]) {
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/admin-users/${id}/roles`, {
|
||||
role_ids: roleIds,
|
||||
})
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||
`/admin/admin-users/${id}/roles`,
|
||||
{
|
||||
role_ids: roleIds,
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) {
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/admin-users/${id}/password`, req)
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||
`/admin/admin-users/${id}/password`,
|
||||
req
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -58,9 +58,12 @@ export async function deleteRole(id: number) {
|
||||
}
|
||||
|
||||
export async function assignRolePermissions(roleId: number, permissionIds: number[]) {
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/roles/${roleId}/permissions`, {
|
||||
permission_ids: permissionIds,
|
||||
})
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||
`/admin/roles/${roleId}/permissions`,
|
||||
{
|
||||
permission_ids: permissionIds,
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,19 @@ export interface AdminUserItem {
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
||||
'/admin/users',
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function freezeAdminUser(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/freeze`, { reason })
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/freeze`, {
|
||||
reason,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,13 @@ export interface AdminWalletLedgerQuery {
|
||||
}
|
||||
|
||||
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params })
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||
)
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>(
|
||||
'/admin/wallet/ledger',
|
||||
{ params }
|
||||
)
|
||||
const result = data.data
|
||||
return {
|
||||
items: Array.isArray(result?.items) ? result.items : [],
|
||||
|
||||
@@ -53,9 +53,12 @@ export async function fetchAdminWithdrawals(params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalDetail>>>('/admin/withdrawals', {
|
||||
params,
|
||||
})
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalDetail>>>(
|
||||
'/admin/withdrawals',
|
||||
{
|
||||
params,
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -65,11 +68,17 @@ export async function fetchAdminWithdrawal(id: number) {
|
||||
}
|
||||
|
||||
export async function reviewWithdrawal(id: number, req: ReviewWithdrawalRequest) {
|
||||
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(`/admin/withdrawals/${id}/review`, req)
|
||||
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(
|
||||
`/admin/withdrawals/${id}/review`,
|
||||
req
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function confirmPayment(id: number, req: ConfirmPaymentRequest) {
|
||||
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(`/admin/withdrawals/${id}/confirm-payment`, req)
|
||||
const { data } = await apiClient.post<ApiResponse<WithdrawalDetail>>(
|
||||
`/admin/withdrawals/${id}/confirm-payment`,
|
||||
req
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -85,9 +85,12 @@ export async function fetchPaymentConfigs(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaymentConfigListResponse>>('/admin/payment-configs', {
|
||||
params,
|
||||
})
|
||||
const { data } = await apiClient.get<ApiResponse<PaymentConfigListResponse>>(
|
||||
'/admin/payment-configs',
|
||||
{
|
||||
params,
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -104,27 +107,39 @@ export async function exportPaymentConfigBackup() {
|
||||
})
|
||||
return {
|
||||
blob: response.data,
|
||||
filename: readDownloadFilename(response.headers['content-disposition']) || fallbackBackupFilename(),
|
||||
filename:
|
||||
readDownloadFilename(response.headers['content-disposition']) || fallbackBackupFilename(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function importPaymentConfigBackup(payload: unknown) {
|
||||
const { data } = await apiClient.post<ApiResponse<PaymentConfigImportResult>>('/admin/payment-configs/import', payload)
|
||||
const { data } = await apiClient.post<ApiResponse<PaymentConfigImportResult>>(
|
||||
'/admin/payment-configs/import',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createPaymentConfig(payload: CreatePaymentConfigRequest) {
|
||||
const { data } = await apiClient.post<ApiResponse<PaymentConfig>>('/admin/payment-configs', payload)
|
||||
const { data } = await apiClient.post<ApiResponse<PaymentConfig>>(
|
||||
'/admin/payment-configs',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updatePaymentConfig(id: number, payload: UpdatePaymentConfigRequest) {
|
||||
const { data } = await apiClient.put<ApiResponse<PaymentConfig>>(`/admin/payment-configs/${id}`, payload)
|
||||
const { data } = await apiClient.put<ApiResponse<PaymentConfig>>(
|
||||
`/admin/payment-configs/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deletePaymentConfig(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ message: string }>>(`/admin/payment-configs/${id}`)
|
||||
const { data } = await apiClient.delete<ApiResponse<{ message: string }>>(
|
||||
`/admin/payment-configs/${id}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -136,6 +151,9 @@ function readDownloadFilename(contentDisposition: unknown) {
|
||||
}
|
||||
|
||||
function fallbackBackupFilename() {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '')
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/, '')
|
||||
return `payment-config-backup-${stamp}.json`
|
||||
}
|
||||
|
||||
@@ -12,11 +12,18 @@ export interface SystemConfig {
|
||||
}
|
||||
|
||||
export async function fetchSystemConfigs() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
|
||||
const { data } =
|
||||
await apiClient.get<ApiResponse<{ items: SystemConfig[] }>>('/admin/system-configs')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(key: string, payload: { value: string; description?: string }) {
|
||||
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(`/admin/system-configs/${key}`, payload)
|
||||
export async function updateSystemConfig(
|
||||
key: string,
|
||||
payload: { value: string; description?: string }
|
||||
) {
|
||||
const { data } = await apiClient.put<ApiResponse<SystemConfig>>(
|
||||
`/admin/system-configs/${key}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -60,8 +60,12 @@ function nextPage() {
|
||||
<el-option :value="100" label="100条" />
|
||||
</el-select>
|
||||
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}页</span>
|
||||
<el-button size="small" :disabled="currentPage <= 1 || loading" @click="prevPage">上页</el-button>
|
||||
<el-button size="small" :disabled="currentPage >= totalPages || loading" @click="nextPage">下页</el-button>
|
||||
<el-button size="small" :disabled="currentPage <= 1 || loading" @click="prevPage"
|
||||
>上页</el-button
|
||||
>
|
||||
<el-button size="small" :disabled="currentPage >= totalPages || loading" @click="nextPage"
|
||||
>下页</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { createAdminMgrUser, updateAdminMgrUser, type AdminMgrUser, type CreateAdminRequest, type UpdateAdminRequest } from '@/features/admin/api/adminMgr'
|
||||
import {
|
||||
createAdminMgrUser,
|
||||
updateAdminMgrUser,
|
||||
type AdminMgrUser,
|
||||
type CreateAdminRequest,
|
||||
type UpdateAdminRequest,
|
||||
} from '@/features/admin/api/adminMgr'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -26,7 +32,7 @@ const isEdit = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
if (props.admin) {
|
||||
isEdit.value = true
|
||||
@@ -42,7 +48,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
@@ -94,7 +100,12 @@ function readError(error: unknown, fallback: string) {
|
||||
<el-input v-model="form.username" :disabled="isEdit" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isEdit" label="密码" class="full-control">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码(至少6位)" />
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码(至少6位)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" class="full-control">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
|
||||
@@ -3,7 +3,10 @@ import { computed, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue'
|
||||
import type { Announcement } from '@/features/announcement'
|
||||
import type { CreateAnnouncementRequest, UpdateAnnouncementRequest } from '@/features/admin/api/adminAnnouncements'
|
||||
import type {
|
||||
CreateAnnouncementRequest,
|
||||
UpdateAnnouncementRequest,
|
||||
} from '@/features/admin/api/adminAnnouncements'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -39,42 +42,41 @@ const rules: FormRules = {
|
||||
{ required: true, message: '请输入公告标题', trigger: 'blur' },
|
||||
{ max: 255, message: '标题不能超过255个字符', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入公告内容', trigger: 'blur' },
|
||||
],
|
||||
category: [
|
||||
{ required: true, message: '请选择公告分类', trigger: 'change' },
|
||||
],
|
||||
content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请选择公告分类', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return props.mode === 'create' ? '新建公告' : '编辑公告'
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
if (props.mode === 'edit' && props.announcement) {
|
||||
formData.value = {
|
||||
title: props.announcement.title,
|
||||
content: props.announcement.content,
|
||||
category: props.announcement.category as any,
|
||||
priority: props.announcement.priority,
|
||||
is_pinned: props.announcement.is_pinned,
|
||||
is_important: props.announcement.is_important,
|
||||
}
|
||||
} else {
|
||||
formData.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
category: 'notice',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_important: false,
|
||||
watch(
|
||||
() => props.visible,
|
||||
val => {
|
||||
if (val) {
|
||||
if (props.mode === 'edit' && props.announcement) {
|
||||
formData.value = {
|
||||
title: props.announcement.title,
|
||||
content: props.announcement.content,
|
||||
category: props.announcement.category as any,
|
||||
priority: props.announcement.priority,
|
||||
is_pinned: props.announcement.is_pinned,
|
||||
is_important: props.announcement.is_important,
|
||||
}
|
||||
} else {
|
||||
formData.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
category: 'notice',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_important: false,
|
||||
}
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
@@ -93,18 +95,8 @@ async function handleSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="800px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-dialog :model-value="visible" :title="dialogTitle" width="800px" @close="handleClose">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
||||
<el-form-item label="公告标题" prop="title">
|
||||
<el-input
|
||||
v-model="formData.title"
|
||||
@@ -144,7 +136,9 @@ async function handleSave() {
|
||||
:max="999"
|
||||
placeholder="数值越大越靠前"
|
||||
/>
|
||||
<span style="margin-left: 12px; color: #909399; font-size: 13px">数值越大越靠前,默认为0</span>
|
||||
<span style="margin-left: 12px; color: #909399; font-size: 13px"
|
||||
>数值越大越靠前,默认为0</span
|
||||
>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="标记">
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { fetchPermissions, fetchRole, assignRolePermissions, type Permission, type Role } from '@/features/admin/api/adminRoles'
|
||||
import {
|
||||
fetchPermissions,
|
||||
fetchRole,
|
||||
assignRolePermissions,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/features/admin/api/adminRoles'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -24,13 +30,16 @@ const groupedPermissions = ref<Record<string, Permission[]>>({})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
async val => {
|
||||
if (val && props.role) {
|
||||
loading.value = true
|
||||
try {
|
||||
const [perms, roleDetail] = await Promise.all([fetchPermissions(), fetchRole(props.role.id)])
|
||||
const [perms, roleDetail] = await Promise.all([
|
||||
fetchPermissions(),
|
||||
fetchRole(props.role.id),
|
||||
])
|
||||
allPermissions.value = perms
|
||||
selectedPermIds.value = (roleDetail.permissions || []).map((p) => p.id)
|
||||
selectedPermIds.value = (roleDetail.permissions || []).map(p => p.id)
|
||||
|
||||
// 按 resource 分组
|
||||
const grouped: Record<string, Permission[]> = {}
|
||||
@@ -45,7 +54,7 @@ watch(
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
@@ -64,10 +73,10 @@ async function handleSave() {
|
||||
}
|
||||
|
||||
function toggleGroup(perms: Permission[]) {
|
||||
const ids = perms.map((p) => p.id)
|
||||
const allSelected = ids.every((id) => selectedPermIds.value.includes(id))
|
||||
const ids = perms.map(p => p.id)
|
||||
const allSelected = ids.every(id => selectedPermIds.value.includes(id))
|
||||
if (allSelected) {
|
||||
selectedPermIds.value = selectedPermIds.value.filter((id) => !ids.includes(id))
|
||||
selectedPermIds.value = selectedPermIds.value.filter(id => !ids.includes(id))
|
||||
} else {
|
||||
const newIds = [...selectedPermIds.value]
|
||||
for (const id of ids) {
|
||||
@@ -78,11 +87,11 @@ function toggleGroup(perms: Permission[]) {
|
||||
}
|
||||
|
||||
function isGroupAllSelected(perms: Permission[]) {
|
||||
return perms.length > 0 && perms.every((p) => selectedPermIds.value.includes(p.id))
|
||||
return perms.length > 0 && perms.every(p => selectedPermIds.value.includes(p.id))
|
||||
}
|
||||
|
||||
function isGroupPartial(perms: Permission[]) {
|
||||
return perms.some((p) => selectedPermIds.value.includes(p.id)) && !isGroupAllSelected(perms)
|
||||
return perms.some(p => selectedPermIds.value.includes(p.id)) && !isGroupAllSelected(perms)
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
|
||||
@@ -22,19 +22,19 @@ const selectedRoleIds = ref<number[]>([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
async val => {
|
||||
if (val && props.admin) {
|
||||
loading.value = true
|
||||
try {
|
||||
allRoles.value = await fetchRoles()
|
||||
selectedRoleIds.value = props.admin.roles.map((r) => r.id)
|
||||
selectedRoleIds.value = props.admin.roles.map(r => r.id)
|
||||
} catch {
|
||||
ElMessage.error('加载角色列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
|
||||
@@ -21,7 +21,7 @@ const description = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
value.value = props.config.value || ''
|
||||
description.value = props.config.description || ''
|
||||
@@ -38,7 +38,7 @@ const isStructuredConfig = computed(() => {
|
||||
const selectOptions = computed<SystemConfigOption[] | null>(() => {
|
||||
const options = getSystemConfigSelectOptions(props.config.key)
|
||||
if (!options) return null
|
||||
if (!value.value || options.some((item) => item.value === value.value)) {
|
||||
if (!value.value || options.some(item => item.value === value.value)) {
|
||||
return options
|
||||
}
|
||||
return [{ label: `当前值:${value.value}`, value: value.value }, ...options]
|
||||
@@ -78,7 +78,9 @@ function readError(error: unknown, fallback: string) {
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
|
||||
<el-form-item v-if="isStructuredConfig" label="配置值 (JSON)" class="full-control">
|
||||
<el-input v-model="value" type="textarea" :rows="12" placeholder="配置值 JSON" />
|
||||
|
||||
@@ -22,7 +22,7 @@ const homeAnnouncementLines = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(props.config.value))
|
||||
description.value = props.config.description || ''
|
||||
@@ -39,7 +39,7 @@ function parseHomeAnnouncements(raw: string) {
|
||||
function linesToItems(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ function readError(error: unknown, fallback: string) {
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="home-config-editor">
|
||||
<div class="editor-toolbar">
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { uploadAdminFile } from '@/shared/api/files'
|
||||
import { defaultHomeBanners, mergeHomeConfig, type HomeBannerSlide } from '@/features/listings/api/homeConfig'
|
||||
import {
|
||||
defaultHomeBanners,
|
||||
mergeHomeConfig,
|
||||
type HomeBannerSlide,
|
||||
} from '@/features/listings/api/homeConfig'
|
||||
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||
import { safeParseJSON } from '@/utils/json'
|
||||
|
||||
@@ -24,7 +28,7 @@ const uploadingBannerIndex = ref<number | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
homeBannersDraft.value = parseHomeBanners(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
@@ -88,7 +92,7 @@ async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const value = JSON.stringify(
|
||||
homeBannersDraft.value.filter((item) => item.title.trim() || item.image_url?.trim()),
|
||||
homeBannersDraft.value.filter(item => item.title.trim() || item.image_url?.trim()),
|
||||
null,
|
||||
2
|
||||
)
|
||||
@@ -123,7 +127,9 @@ function readError(error: unknown, fallback: string) {
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="home-config-editor">
|
||||
<div class="editor-toolbar">
|
||||
@@ -154,16 +160,24 @@ function readError(error: unknown, fallback: string) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="眉标" min-width="160">
|
||||
<template #default="{ row }"><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主标题" min-width="220">
|
||||
<template #default="{ row }"><el-input v-model="row.title" placeholder="轮播主文案" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.title" placeholder="轮播主文案"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角标" width="110">
|
||||
<template #default="{ row }"><el-input v-model="row.badge" placeholder="HOT" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.badge" placeholder="HOT"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="胶囊文案" min-width="220">
|
||||
<template #default="{ row }"><el-input v-model="row.pill" placeholder="底部补充文案" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.pill" placeholder="底部补充文案"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="色调" width="130">
|
||||
<template #default="{ row }">
|
||||
@@ -176,7 +190,9 @@ function readError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)">删除</el-button>
|
||||
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -63,25 +63,37 @@ const draft = ref<ListingPublishAgreements>(cloneAgreements(defaultListingPublis
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
draft.value = parseListingPublishAgreements(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parseListingPublishAgreements(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
|
||||
return cloneAgreements({
|
||||
virtual_asset_sale: {
|
||||
title: readText(parsed?.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title),
|
||||
content: readText(parsed?.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content),
|
||||
title: readText(
|
||||
parsed?.virtual_asset_sale?.title,
|
||||
defaultListingPublishAgreements.virtual_asset_sale.title
|
||||
),
|
||||
content: readText(
|
||||
parsed?.virtual_asset_sale?.content,
|
||||
defaultListingPublishAgreements.virtual_asset_sale.content
|
||||
),
|
||||
},
|
||||
seller_agreement: {
|
||||
title: readText(parsed?.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title),
|
||||
content: readText(parsed?.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content),
|
||||
title: readText(
|
||||
parsed?.seller_agreement?.title,
|
||||
defaultListingPublishAgreements.seller_agreement.title
|
||||
),
|
||||
content: readText(
|
||||
parsed?.seller_agreement?.content,
|
||||
defaultListingPublishAgreements.seller_agreement.content
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -133,7 +145,9 @@ function readError(error: unknown, fallback: string) {
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-header">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,25 +63,37 @@ const draft = ref<OrderAgreements>(cloneAgreements(defaultOrderAgreements))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
draft.value = parseOrderAgreements(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parseOrderAgreements(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultOrderAgreements)
|
||||
return cloneAgreements({
|
||||
virtual_asset_purchase: {
|
||||
title: readText(parsed?.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title),
|
||||
content: readText(parsed?.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content),
|
||||
title: readText(
|
||||
parsed?.virtual_asset_purchase?.title,
|
||||
defaultOrderAgreements.virtual_asset_purchase.title
|
||||
),
|
||||
content: readText(
|
||||
parsed?.virtual_asset_purchase?.content,
|
||||
defaultOrderAgreements.virtual_asset_purchase.content
|
||||
),
|
||||
},
|
||||
renter_agreement: {
|
||||
title: readText(parsed?.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title),
|
||||
content: readText(parsed?.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content),
|
||||
title: readText(
|
||||
parsed?.renter_agreement?.title,
|
||||
defaultOrderAgreements.renter_agreement.title
|
||||
),
|
||||
content: readText(
|
||||
parsed?.renter_agreement?.content,
|
||||
defaultOrderAgreements.renter_agreement.content
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -134,7 +146,9 @@ function readError(error: unknown, fallback: string) {
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-header">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,12 +86,7 @@ const isLakala = computed(() => formData.value.provider === 'lakala')
|
||||
const isMock = computed(() => formData.value.provider === 'mock')
|
||||
const signKeyLabel = computed(() => (isLakala.value ? '商户私钥' : '签名密钥'))
|
||||
const notifyKeyLabel = computed(() => (isLakala.value ? '通知证书' : '通知密钥'))
|
||||
const extraConfig = computed<Record<string, any>>(() => {
|
||||
if (!formData.value.extra_config) {
|
||||
formData.value.extra_config = {}
|
||||
}
|
||||
return formData.value.extra_config
|
||||
})
|
||||
const extraConfig = computed<Record<string, any>>(() => formData.value.extra_config || {})
|
||||
|
||||
function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any> | null) {
|
||||
const extra = {
|
||||
@@ -106,7 +101,7 @@ function normalizeExtraConfig(provider: string, extraConfig?: Record<string, any
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val && props.config) {
|
||||
formData.value = {
|
||||
name: props.config.name,
|
||||
@@ -218,7 +213,11 @@ function handleClose() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付服务商" required>
|
||||
<el-select v-model="formData.provider" :disabled="mode !== 'create'" @change="handleProviderChange">
|
||||
<el-select
|
||||
v-model="formData.provider"
|
||||
:disabled="mode !== 'create'"
|
||||
@change="handleProviderChange"
|
||||
>
|
||||
<el-option label="乐刷支付" value="leshua" />
|
||||
<el-option label="拉卡拉支付" value="lakala" />
|
||||
<el-option label="模拟支付" value="mock" />
|
||||
@@ -234,11 +233,19 @@ function handleClose() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isLakala" label="App ID" required>
|
||||
<el-input v-model="extraConfig.app_id" placeholder="拉卡拉开放平台 appId" :disabled="isReadonly" />
|
||||
<el-input
|
||||
v-model="extraConfig.app_id"
|
||||
placeholder="拉卡拉开放平台 appId"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isLakala" label="证书序列号" required>
|
||||
<el-input v-model="extraConfig.serial_no" placeholder="商户证书序列号" :disabled="isReadonly" />
|
||||
<el-input
|
||||
v-model="extraConfig.serial_no"
|
||||
placeholder="商户证书序列号"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isLakala" label="终端号" required>
|
||||
@@ -251,7 +258,9 @@ function handleClose() {
|
||||
:type="isLakala ? 'textarea' : 'password'"
|
||||
:rows="isLakala ? 5 : undefined"
|
||||
:show-password="!isLakala"
|
||||
:placeholder="mode === 'edit' ? '留空则不修改' : isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥'"
|
||||
:placeholder="
|
||||
mode === 'edit' ? '留空则不修改' : isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥'
|
||||
"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -262,7 +271,13 @@ function handleClose() {
|
||||
:type="isLakala ? 'textarea' : 'password'"
|
||||
:rows="isLakala ? 5 : undefined"
|
||||
:show-password="!isLakala"
|
||||
:placeholder="mode === 'edit' ? '留空则不修改' : isLakala ? '请输入拉卡拉通知验签证书 PEM' : '请输入通知密钥'"
|
||||
:placeholder="
|
||||
mode === 'edit'
|
||||
? '留空则不修改'
|
||||
: isLakala
|
||||
? '请输入拉卡拉通知验签证书 PEM'
|
||||
: '请输入通知密钥'
|
||||
"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -282,7 +297,11 @@ function handleClose() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!isMock" label="回调地址" required>
|
||||
<el-input v-model="formData.notify_url" placeholder="异步通知回调地址" :disabled="isReadonly" />
|
||||
<el-input
|
||||
v-model="formData.notify_url"
|
||||
placeholder="异步通知回调地址"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否默认">
|
||||
@@ -312,7 +331,11 @@ function handleClose() {
|
||||
</template>
|
||||
|
||||
<el-form-item label="跳转地址">
|
||||
<el-input v-model="formData.jump_url" placeholder="支付完成跳转地址" :disabled="isReadonly" />
|
||||
<el-input
|
||||
v-model="formData.jump_url"
|
||||
placeholder="支付完成跳转地址"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付方式">
|
||||
@@ -332,7 +355,12 @@ function handleClose() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isLakala" label="有效期分钟">
|
||||
<el-input-number v-model="extraConfig.order_expire_minutes" :min="1" :max="1440" :disabled="isReadonly" />
|
||||
<el-input-number
|
||||
v-model="extraConfig.order_expire_minutes"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付形态">
|
||||
|
||||
@@ -63,13 +63,13 @@ const draft = ref<PostRentalNotice>(cloneNotice(defaultPostRentalNotice))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
draft.value = parsePostRentalNotice(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parsePostRentalNotice(raw: string) {
|
||||
@@ -128,7 +128,9 @@ function readError(error: unknown, fallback: string) {
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-header">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListing
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
publishOptionsDraft.value = parsePublishOptions(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
@@ -47,7 +47,7 @@ function cloneOptions(options: ListingPublishOptions) {
|
||||
function linesToItems(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -200,7 +200,9 @@ function readError(error: unknown, fallback: string) {
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="publish-options-editor">
|
||||
<div class="editor-toolbar">
|
||||
@@ -300,7 +302,12 @@ function readError(error: unknown, fallback: string) {
|
||||
<strong>发布规则</strong>
|
||||
</div>
|
||||
<el-form-item label="最低烽火等级">
|
||||
<el-input-number v-model="publishOptionsDraft.fire_level_min" :min="1" :step="1" class="full-control" />
|
||||
<el-input-number
|
||||
v-model="publishOptionsDraft.fire_level_min"
|
||||
:min="1"
|
||||
:step="1"
|
||||
class="full-control"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +316,11 @@ function readError(error: unknown, fallback: string) {
|
||||
<strong>押金与价格提示</strong>
|
||||
</div>
|
||||
<el-form-item label="押金提示">
|
||||
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
|
||||
<el-input
|
||||
v-model="publishOptionsDraft.price_config.deposit_placeholder"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="押金后缀">
|
||||
<el-input
|
||||
@@ -319,10 +330,16 @@ function readError(error: unknown, fallback: string) {
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="价格提示">
|
||||
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" class="full-control" />
|
||||
<el-input
|
||||
v-model="publishOptionsDraft.price_config.price_placeholder"
|
||||
class="full-control"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="比例说明">
|
||||
<el-input v-model="publishOptionsDraft.price_config.ratio_description" class="full-control" />
|
||||
<el-input
|
||||
v-model="publishOptionsDraft.price_config.ratio_description"
|
||||
class="full-control"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@@ -339,19 +356,35 @@ function readError(error: unknown, fallback: string) {
|
||||
class="full-control"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-table :data="publishOptionsDraft.deposit_recommend_config.skin_group_rules" size="small" border>
|
||||
<el-table
|
||||
:data="publishOptionsDraft.deposit_recommend_config.skin_group_rules"
|
||||
size="small"
|
||||
border
|
||||
>
|
||||
<el-table-column label="皮肤分组 Key" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.group_key" placeholder="operatorRed" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.group_key" placeholder="operatorRed"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row }"><el-input v-model="row.label" placeholder="干员红皮" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.label" placeholder="干员红皮"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="每个增加押金" min-width="140">
|
||||
<template #default="{ row }"><el-input-number v-model="row.amount_per_item" :min="0" :step="5" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.amount_per_item" :min="0" :step="5"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeDepositSkinGroupRule($index)">删除</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="removeDepositSkinGroupRule($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -365,16 +398,30 @@ function readError(error: unknown, fallback: string) {
|
||||
<span>保险基础比例</span>
|
||||
<el-button size="small" @click="addInsuranceBaseRatio">添加保险比例</el-button>
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.ratio_config.insurance_base_ratios" size="small" border>
|
||||
<el-table
|
||||
:data="publishOptionsDraft.ratio_config.insurance_base_ratios"
|
||||
size="small"
|
||||
border
|
||||
>
|
||||
<el-table-column label="保险" min-width="160">
|
||||
<template #default="{ row }"><el-input v-model="row.insurance" placeholder="3*3" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input v-model="row.insurance" placeholder="3*3"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基础比例" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.ratio" :min="0" :step="0.5" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.ratio" :min="0" :step="0.5"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeInsuranceBaseRatio($index)">删除</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="removeInsuranceBaseRatio($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -403,11 +450,15 @@ function readError(error: unknown, fallback: string) {
|
||||
<template #default="{ row }"><el-input v-model="row.group_key" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="缺失加成" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.missing_penalty" :min="0" :step="0.5" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.missing_penalty" :min="0" :step="0.5"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeRatioConfigItem($index)">删除</el-button>
|
||||
<el-button size="small" type="danger" plain @click="removeRatioConfigItem($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -418,14 +469,20 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.ratio_config.coin_corrections" size="small" border>
|
||||
<el-table-column label="大于 M" min-width="130">
|
||||
<template #default="{ row }"><el-input-number v-model="row.threshold_m" :min="0" :step="10" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.threshold_m" :min="0" :step="10"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="比例加成" min-width="130">
|
||||
<template #default="{ row }"><el-input-number v-model="row.correction" :min="0" :step="0.5" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.correction" :min="0" :step="0.5"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeCoinCorrection($index)">删除</el-button>
|
||||
<el-button size="small" type="danger" plain @click="removeCoinCorrection($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -436,7 +493,11 @@ function readError(error: unknown, fallback: string) {
|
||||
<strong>皮肤分类</strong>
|
||||
<el-button size="small" @click="addSkinGroup">添加分类</el-button>
|
||||
</div>
|
||||
<div v-for="(group, index) in publishOptionsDraft.skin_groups" :key="`${group.key}-${index}`" class="skin-config-row">
|
||||
<div
|
||||
v-for="(group, index) in publishOptionsDraft.skin_groups"
|
||||
:key="`${group.key}-${index}`"
|
||||
class="skin-config-row"
|
||||
>
|
||||
<el-input v-model="group.key" placeholder="分类 key" />
|
||||
<el-input v-model="group.title" placeholder="分类名称" />
|
||||
<el-input
|
||||
@@ -470,7 +531,9 @@ function readError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)">删除</el-button>
|
||||
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -496,7 +559,9 @@ function readError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)">删除</el-button>
|
||||
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -29,14 +29,17 @@ const form = ref({
|
||||
is_global: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadReplies()
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadReplies()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
watch(visible, val => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
@@ -76,7 +79,12 @@ async function handleSubmit() {
|
||||
await updateQuickReply(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createQuickReply(form.value.title, form.value.content, form.value.sort_order, form.value.is_global)
|
||||
await createQuickReply(
|
||||
form.value.title,
|
||||
form.value.content,
|
||||
form.value.sort_order,
|
||||
form.value.is_global
|
||||
)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
resetForm()
|
||||
@@ -100,7 +108,9 @@ async function handleDelete(reply: QuickReply) {
|
||||
ElMessage.success('删除成功')
|
||||
await loadReplies()
|
||||
emit('success')
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { createRole, updateRole, type Role, type CreateRoleRequest, type UpdateRoleRequest } from '@/features/admin/api/adminRoles'
|
||||
import {
|
||||
createRole,
|
||||
updateRole,
|
||||
type Role,
|
||||
type CreateRoleRequest,
|
||||
type UpdateRoleRequest,
|
||||
} from '@/features/admin/api/adminRoles'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -25,7 +31,7 @@ const isEdit = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
if (props.role) {
|
||||
isEdit.value = true
|
||||
@@ -40,7 +46,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
|
||||
@@ -22,11 +22,13 @@ const emit = defineEmits<{
|
||||
|
||||
const submitting = ref(false)
|
||||
const description = ref('')
|
||||
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig))
|
||||
const salePriceConfigDraft = ref<PublishSalePriceConfig>(
|
||||
cloneSalePriceConfig(emptyListingSalePriceConfig)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
val => {
|
||||
if (val) {
|
||||
salePriceConfigDraft.value = parseSalePriceConfig(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
@@ -103,7 +105,9 @@ function readError(error: unknown, fallback: string) {
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
<p class="config-key-label">
|
||||
<strong>{{ config.key }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="publish-options-editor">
|
||||
<div class="editor-toolbar">
|
||||
@@ -117,17 +121,29 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
<el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border>
|
||||
<el-table-column label="最小 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.min_m" :min="0" :step="10"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.max_m" :min="0" :step="10"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加价金额/元" min-width="140">
|
||||
<template #default="{ row }"><el-input-number v-model="row.markup_amount" :min="0" :step="1" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.markup_amount" :min="0" :step="1"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeSaleFixedMarkupRule($index)">删除</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="removeSaleFixedMarkupRule($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -138,17 +154,29 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
<el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border>
|
||||
<el-table-column label="最小 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.min_m" :min="0" :step="10"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.max_m" :min="0" :step="10"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="比例修正" min-width="130">
|
||||
<template #default="{ row }"><el-input-number v-model="row.ratio_subtract" :min="0" :step="0.5" /></template>
|
||||
<template #default="{ row }"
|
||||
><el-input-number v-model="row.ratio_subtract" :min="0" :step="0.5"
|
||||
/></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeSaleRatioAdjustmentRule($index)">删除</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="removeSaleRatioAdjustmentRule($index)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -51,14 +51,17 @@ function getSupportStatusColor(status: string) {
|
||||
return colors[status] || '#9ca3af'
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadAdmins()
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadAdmins()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
watch(visible, val => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
@@ -112,7 +115,10 @@ function getStatusTag(count: number) {
|
||||
<div class="admin-info">
|
||||
<div class="admin-avatar-wrap">
|
||||
<el-avatar :size="36" :icon="User" />
|
||||
<span class="status-indicator" :style="{ backgroundColor: getSupportStatusColor(admin.support_status) }"></span>
|
||||
<span
|
||||
class="status-indicator"
|
||||
:style="{ backgroundColor: getSupportStatusColor(admin.support_status) }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="admin-detail">
|
||||
<span class="admin-name">{{ admin.nickname }}</span>
|
||||
@@ -120,7 +126,10 @@ function getStatusTag(count: number) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-status">
|
||||
<span class="support-status" :style="{ color: getSupportStatusColor(admin.support_status) }">
|
||||
<span
|
||||
class="support-status"
|
||||
:style="{ color: getSupportStatusColor(admin.support_status) }"
|
||||
>
|
||||
{{ getSupportStatusLabel(admin.support_status) }}
|
||||
</span>
|
||||
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
|
||||
@@ -131,7 +140,12 @@ function getStatusTag(count: number) {
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!selectedAdminId" @click="handleSubmit">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!selectedAdminId"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确认转接
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +62,7 @@ async function handleConfirmPayment() {
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '输入打款备注...',
|
||||
inputValidator: (value) => {
|
||||
inputValidator: value => {
|
||||
return value && value.trim().length > 0
|
||||
},
|
||||
inputErrorMessage: '请输入打款备注',
|
||||
@@ -97,10 +97,15 @@ function statusLabel(status: string) {
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
return status === 'completed' ? 'success' :
|
||||
status === 'pending' ? 'warning' :
|
||||
status === 'processing' ? 'primary' :
|
||||
status === 'rejected' ? 'danger' : 'info'
|
||||
return status === 'completed'
|
||||
? 'success'
|
||||
: status === 'pending'
|
||||
? 'warning'
|
||||
: status === 'processing'
|
||||
? 'primary'
|
||||
: status === 'rejected'
|
||||
? 'danger'
|
||||
: 'info'
|
||||
}
|
||||
|
||||
function accountTypeLabel(type: string) {
|
||||
@@ -188,7 +193,11 @@ function accountTypeLabel(type: string) {
|
||||
<el-descriptions-item v-if="withdrawal.bank_branch" label="开户支行">
|
||||
{{ withdrawal.bank_branch }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="withdrawal.certificate_urls && withdrawal.certificate_urls.length > 0" label="收款二维码" :span="2">
|
||||
<el-descriptions-item
|
||||
v-if="withdrawal.certificate_urls && withdrawal.certificate_urls.length > 0"
|
||||
label="收款二维码"
|
||||
:span="2"
|
||||
>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap">
|
||||
<el-image
|
||||
v-for="(url, idx) in withdrawal.certificate_urls"
|
||||
@@ -299,9 +308,7 @@ function accountTypeLabel(type: string) {
|
||||
确认打款
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button @click="emit('update:modelValue', false)">
|
||||
关闭
|
||||
</el-button>
|
||||
<el-button @click="emit('update:modelValue', false)"> 关闭 </el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -21,7 +21,9 @@ interface AdminPaginatedTableResult<T> {
|
||||
handleSizeChange: () => void
|
||||
}
|
||||
|
||||
export function useAdminPaginatedTable<T>(options: AdminPaginatedTableOptions<T>): AdminPaginatedTableResult<T> {
|
||||
export function useAdminPaginatedTable<T>(
|
||||
options: AdminPaginatedTableOptions<T>
|
||||
): AdminPaginatedTableResult<T> {
|
||||
const loading = ref(false)
|
||||
const data = ref<T[]>([]) as Ref<T[]>
|
||||
const total = ref(0)
|
||||
|
||||
@@ -19,7 +19,7 @@ export {
|
||||
type AdminUser,
|
||||
type AdminTokenPair,
|
||||
type AdminLoginData,
|
||||
type AdminCaptcha
|
||||
type AdminCaptcha,
|
||||
} from './api/adminAuth'
|
||||
|
||||
// adminRoles 导出
|
||||
@@ -31,5 +31,5 @@ export {
|
||||
deleteRole,
|
||||
fetchPermissions,
|
||||
type Role,
|
||||
type Permission
|
||||
type Permission,
|
||||
} from './api/adminRoles'
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Bell, Delete, Document, Edit, Plus, QuestionFilled, Refresh, Top, Warning } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Bell,
|
||||
Delete,
|
||||
Document,
|
||||
Edit,
|
||||
Plus,
|
||||
QuestionFilled,
|
||||
Refresh,
|
||||
Top,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchAdminAnnouncements,
|
||||
createAnnouncement,
|
||||
@@ -199,12 +209,34 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="statusFilter" placeholder="筛选状态" clearable @change="handleFilterChange" style="width: 150px">
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
placeholder="筛选状态"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="categoryFilter" placeholder="筛选分类" clearable @change="handleFilterChange" style="width: 150px">
|
||||
<el-select
|
||||
v-model="categoryFilter"
|
||||
placeholder="筛选分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option value="" label="全部分类" />
|
||||
<el-option v-for="cat in categories" :key="cat.value" :label="cat.label" :value="cat.value" />
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat.value"
|
||||
:label="cat.label"
|
||||
:value="cat.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
@@ -220,14 +252,23 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
|
||||
<el-icon><Top /></el-icon>
|
||||
置顶
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain">重要</el-tag>
|
||||
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain"
|
||||
>重要</el-tag
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :style="{ borderColor: getCategoryColor(row.category), color: getCategoryColor(row.category) }" effect="plain" size="small">
|
||||
<el-tag
|
||||
:style="{
|
||||
borderColor: getCategoryColor(row.category),
|
||||
color: getCategoryColor(row.category),
|
||||
}"
|
||||
effect="plain"
|
||||
size="small"
|
||||
>
|
||||
{{ getCategoryLabel(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -248,17 +289,43 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="handleEdit(row)" text style="color: #1d6fd6; font-weight: 700;">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
text
|
||||
style="color: #1d6fd6; font-weight: 700"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="row.status === 'draft'" type="success" size="small" @click="handlePublish(row)" text style="color: #4caf50; font-weight: 700;">
|
||||
<el-button
|
||||
v-if="row.status === 'draft'"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handlePublish(row)"
|
||||
text
|
||||
style="color: #4caf50; font-weight: 700"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button v-if="row.status === 'published'" type="warning" size="small" @click="handleArchive(row)" text style="color: #d97706; font-weight: 700;">
|
||||
<el-button
|
||||
v-if="row.status === 'published'"
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handleArchive(row)"
|
||||
text
|
||||
style="color: #d97706; font-weight: 700"
|
||||
>
|
||||
归档
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="handleDelete(row)" text style="color: #dc2626; font-weight: 700;">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
text
|
||||
style="color: #dc2626; font-weight: 700"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
|
||||
@@ -18,7 +18,11 @@ const filters = reactive({
|
||||
biz_type: '',
|
||||
})
|
||||
|
||||
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
|
||||
const highRiskCount = computed(
|
||||
() =>
|
||||
logs.value.filter(item => item.action.includes('freeze') || item.action.includes('update'))
|
||||
.length
|
||||
)
|
||||
|
||||
onMounted(loadLogs)
|
||||
|
||||
@@ -81,7 +85,9 @@ function actionType(action: string) {
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs">查询</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs"
|
||||
>查询</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +107,13 @@ function actionType(action: string) {
|
||||
<el-input v-model="filters.actor_id" clearable placeholder="按管理员筛选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="动作">
|
||||
<el-select v-model="filters.action" clearable filterable placeholder="全部动作" class="full-control">
|
||||
<el-select
|
||||
v-model="filters.action"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部动作"
|
||||
class="full-control"
|
||||
>
|
||||
<el-option label="冻结用户" value="admin_user.freeze" />
|
||||
<el-option label="解冻用户" value="admin_user.unfreeze" />
|
||||
<el-option label="后台下架商品" value="listing.admin_offline" />
|
||||
@@ -155,9 +167,18 @@ function actionType(action: string) {
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
|
||||
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
|
||||
<el-dialog
|
||||
:model-value="!!activeLog"
|
||||
title="审计明细"
|
||||
width="720px"
|
||||
@update:model-value="activeLog = null"
|
||||
>
|
||||
<div v-if="activeLog" class="dialog-body">
|
||||
<p><strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}</p>
|
||||
<p>
|
||||
<strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{
|
||||
activeLog.biz_id || '-'
|
||||
}}
|
||||
</p>
|
||||
<p>操作人:{{ actorName(activeLog) }} · IP:{{ activeLog.ip }}</p>
|
||||
<p>User-Agent:{{ activeLog.user_agent }}</p>
|
||||
<div class="code-panel">
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Tickets,
|
||||
User,
|
||||
Wallet,
|
||||
Warning
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard'
|
||||
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
||||
@@ -23,7 +23,12 @@ import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const money = useMoney()
|
||||
|
||||
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
data: dashboard,
|
||||
load: loadDashboard,
|
||||
} = useAdminTable<AdminDashboard>({
|
||||
fetchFn: fetchAdminDashboard,
|
||||
})
|
||||
</script>
|
||||
@@ -108,7 +113,13 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
|
||||
待处理事项
|
||||
</h2>
|
||||
<el-tag type="warning" effect="dark" round>
|
||||
{{ dashboard.pending.disputes + dashboard.pending.listing_reviews + dashboard.pending.pending_handoffs + dashboard.pending.pending_return_confirms }} 项
|
||||
{{
|
||||
dashboard.pending.disputes +
|
||||
dashboard.pending.listing_reviews +
|
||||
dashboard.pending.pending_handoffs +
|
||||
dashboard.pending.pending_return_confirms
|
||||
}}
|
||||
项
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="pending-list">
|
||||
@@ -117,28 +128,36 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
|
||||
<span class="pending-label">待仲裁申诉</span>
|
||||
<span class="pending-desc">需要处理的用户争议</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{ dashboard.pending.disputes }}</strong>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{
|
||||
dashboard.pending.disputes
|
||||
}}</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/listings/review">
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待审核商品</span>
|
||||
<span class="pending-desc">新提交的商品审核</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.listing_reviews > 0 }">{{ dashboard.pending.listing_reviews }}</strong>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.listing_reviews > 0 }">{{
|
||||
dashboard.pending.listing_reviews
|
||||
}}</strong>
|
||||
</RouterLink>
|
||||
<div class="pending-row">
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待交接订单</span>
|
||||
<span class="pending-desc">等待卖家交接</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.pending_handoffs > 0 }">{{ dashboard.pending.pending_handoffs }}</strong>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.pending_handoffs > 0 }">{{
|
||||
dashboard.pending.pending_handoffs
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="pending-row">
|
||||
<div class="pending-info">
|
||||
<span class="pending-label">待结账确认</span>
|
||||
<span class="pending-desc">等待双方确认</span>
|
||||
</div>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{ dashboard.pending.pending_return_confirms }}</strong>
|
||||
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{
|
||||
dashboard.pending.pending_return_confirms
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,7 +209,17 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
|
||||
<el-table-column prop="title" label="商品名称" min-width="170" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'renting' ? 'primary' : 'warning'" size="small" effect="plain">
|
||||
<el-tag
|
||||
:type="
|
||||
row.status === 'completed'
|
||||
? 'success'
|
||||
: row.status === 'renting'
|
||||
? 'primary'
|
||||
: 'warning'
|
||||
"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ orderStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -218,7 +247,11 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
|
||||
<el-table-column prop="type" label="申诉类型" width="140" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'resolved' ? 'success' : 'danger'" size="small" effect="plain">
|
||||
<el-tag
|
||||
:type="row.status === 'resolved' ? 'success' : 'danger'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ disputeStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
@@ -128,11 +128,26 @@ function readError(error: unknown, fallback: string) {
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
prop="arbitration_result"
|
||||
label="仲裁结果"
|
||||
width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
|
||||
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="evidenceItems(row).length === 0"
|
||||
@click="evidenceDispute = row"
|
||||
>证据</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="row.status === 'resolved'"
|
||||
@click="openArbitration(row)"
|
||||
>仲裁</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -146,9 +161,16 @@ function readError(error: unknown, fallback: string) {
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
|
||||
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
||||
<el-dialog
|
||||
:model-value="!!activeDispute"
|
||||
title="申诉仲裁"
|
||||
width="560px"
|
||||
@update:model-value="activeDispute = null"
|
||||
>
|
||||
<div v-if="activeDispute" class="dialog-body">
|
||||
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
||||
<p>
|
||||
<strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}
|
||||
</p>
|
||||
<p>{{ activeDispute.description }}</p>
|
||||
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
|
||||
<el-option label="全额退款" value="full_refund" />
|
||||
@@ -168,19 +190,38 @@ function readError(error: unknown, fallback: string) {
|
||||
:step="10"
|
||||
placeholder="裁决金额"
|
||||
/>
|
||||
<p v-if="result === 'partial_refund'">部分退款金额表示退给租客的金额,剩余冻结金额结算给号主。</p>
|
||||
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">金额表示从押金中赔付给号主的部分;不填则默认处理全额押金。</p>
|
||||
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
|
||||
<p v-if="result === 'partial_refund'">
|
||||
部分退款金额表示退给租客的金额,剩余冻结金额结算给号主。
|
||||
</p>
|
||||
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">
|
||||
金额表示从押金中赔付给号主的部分;不填则默认处理全额押金。
|
||||
</p>
|
||||
<el-input
|
||||
v-model="remark"
|
||||
class="panel-action"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="填写客服裁决说明"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeDispute = null">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleArbitrate">保存裁决</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleArbitrate"
|
||||
>保存裁决</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!evidenceDispute" title="申诉证据" width="640px" @update:model-value="evidenceDispute = null">
|
||||
<el-dialog
|
||||
:model-value="!!evidenceDispute"
|
||||
title="申诉证据"
|
||||
width="640px"
|
||||
@update:model-value="evidenceDispute = null"
|
||||
>
|
||||
<div v-if="evidenceDispute" class="dialog-body">
|
||||
<p><strong>{{ evidenceDispute.order_no }}</strong> · {{ evidenceDispute.title }}</p>
|
||||
<p>
|
||||
<strong>{{ evidenceDispute.order_no }}</strong> · {{ evidenceDispute.title }}
|
||||
</p>
|
||||
<div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
|
||||
<span>{{ item }}</span>
|
||||
<el-button size="small" @click="openEvidence(item)">打开</el-button>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/features/listings'
|
||||
import {
|
||||
adminMarkListingAbnormal,
|
||||
adminOfflineListing,
|
||||
fetchAdminListing,
|
||||
type Listing,
|
||||
} from '@/features/listings'
|
||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
@@ -15,8 +20,15 @@ const listing = ref<Listing | null>(null)
|
||||
const actionType = ref<'offline' | 'abnormal' | ''>('')
|
||||
const reason = ref('')
|
||||
|
||||
const actionTitle = computed(() => (actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'))
|
||||
const canOperate = computed(() => !!listing.value && listing.value.status !== 'rented' && !['offline', 'abnormal'].includes(listing.value.status))
|
||||
const actionTitle = computed(() =>
|
||||
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
|
||||
)
|
||||
const canOperate = computed(
|
||||
() =>
|
||||
!!listing.value &&
|
||||
listing.value.status !== 'rented' &&
|
||||
!['offline', 'abnormal'].includes(listing.value.status)
|
||||
)
|
||||
|
||||
onMounted(loadListing)
|
||||
|
||||
@@ -100,8 +112,12 @@ function readError(error: unknown, fallback: string) {
|
||||
<RouterLink to="/admin/listings">
|
||||
<el-button>返回列表</el-button>
|
||||
</RouterLink>
|
||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')">强制下架</el-button>
|
||||
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
|
||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
|
||||
>强制下架</el-button
|
||||
>
|
||||
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')"
|
||||
>标记异常</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,10 +180,22 @@ function readError(error: unknown, fallback: string) {
|
||||
<p v-else>暂无截图</p>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
|
||||
<el-dialog
|
||||
:model-value="!!actionType"
|
||||
:title="actionTitle"
|
||||
width="560px"
|
||||
@update:model-value="actionType = ''"
|
||||
>
|
||||
<div v-if="listing" class="dialog-body">
|
||||
<p><strong>{{ listing.title }}</strong></p>
|
||||
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写后台操作原因,会写入审计日志并通知号主" />
|
||||
<p>
|
||||
<strong>{{ listing.title }}</strong>
|
||||
</p>
|
||||
<el-input
|
||||
v-model="reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="填写后台操作原因,会写入审计日志并通知号主"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="actionType = ''">取消</el-button>
|
||||
|
||||
@@ -4,7 +4,13 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||
import { adjustListingReviewPrice, approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
|
||||
import {
|
||||
adjustListingReviewPrice,
|
||||
approveListing,
|
||||
fetchPendingReviewListings,
|
||||
rejectListing,
|
||||
type Listing,
|
||||
} from '@/features/listings'
|
||||
import {
|
||||
assetRegions,
|
||||
formatHafCoinM,
|
||||
@@ -26,7 +32,13 @@ interface RiskItem {
|
||||
}
|
||||
|
||||
const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
|
||||
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常']
|
||||
const rejectReasonOptions = [
|
||||
'默认截图,需补充真实截图',
|
||||
'账号资产信息不完整',
|
||||
'价格或押金异常',
|
||||
'封禁记录需补充说明',
|
||||
'联系方式异常',
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -50,10 +62,12 @@ const filters = reactive({
|
||||
const previewURLs = reactive<Record<string, string>>({})
|
||||
const createdObjectURLs = new Set<string>()
|
||||
|
||||
const selectedListing = computed(() => listings.value.find((item) => item.id === selectedID.value) || listings.value[0] || null)
|
||||
const selectedListing = computed(
|
||||
() => listings.value.find(item => item.id === selectedID.value) || listings.value[0] || null
|
||||
)
|
||||
const filteredListings = computed(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
return listings.value.filter((item) => {
|
||||
return listings.value.filter(item => {
|
||||
if (filters.risk === 'external' && !isExternalUpload(item)) return false
|
||||
if (filters.risk === 'defaultImage' && !hasDefaultScreenshot(item)) return false
|
||||
if (filters.risk === 'ban' && !hasBanRecord(item)) return false
|
||||
@@ -62,26 +76,33 @@ const filteredListings = computed(() => {
|
||||
})
|
||||
})
|
||||
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
|
||||
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
|
||||
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
|
||||
const priceAdjustPreview = computed(() => (priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null))
|
||||
const selectedResources = computed(() =>
|
||||
selectedListing.value ? getListingResources(selectedListing.value) : []
|
||||
)
|
||||
const selectedSkins = computed(() =>
|
||||
selectedListing.value ? getSkinNames(selectedListing.value) : []
|
||||
)
|
||||
const priceAdjustPreview = computed(() =>
|
||||
priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedListing.value,
|
||||
async (listing) => {
|
||||
async listing => {
|
||||
if (!listing) return
|
||||
selectedID.value = listing.id
|
||||
await nextTick()
|
||||
loadScreenshotPreviews(listing)
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(priceAdjustMode, (mode, previousMode) => {
|
||||
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
|
||||
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
|
||||
if (mode === 'price') {
|
||||
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
|
||||
priceAdjustForm.buyer_total_price =
|
||||
preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
|
||||
} else {
|
||||
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
|
||||
}
|
||||
@@ -89,14 +110,14 @@ watch(priceAdjustMode, (mode, previousMode) => {
|
||||
|
||||
onMounted(loadListings)
|
||||
onBeforeUnmount(() => {
|
||||
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
|
||||
createdObjectURLs.forEach(url => URL.revokeObjectURL(url))
|
||||
})
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchPendingReviewListings()
|
||||
if (!selectedID.value || !listings.value.some((item) => item.id === selectedID.value)) {
|
||||
if (!selectedID.value || !listings.value.some(item => item.id === selectedID.value)) {
|
||||
selectedID.value = listings.value[0]?.id || null
|
||||
}
|
||||
} finally {
|
||||
@@ -109,7 +130,7 @@ function selectListing(row: Listing) {
|
||||
}
|
||||
|
||||
function replaceListing(next: Listing) {
|
||||
const index = listings.value.findIndex((item) => item.id === next.id)
|
||||
const index = listings.value.findIndex(item => item.id === next.id)
|
||||
if (index >= 0) {
|
||||
listings.value.splice(index, 1, next)
|
||||
} else {
|
||||
@@ -157,8 +178,14 @@ async function handleSavePriceAdjust() {
|
||||
if (!priceAdjustListing.value) return
|
||||
const payload =
|
||||
priceAdjustMode.value === 'ratio'
|
||||
? { buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0), reason: priceAdjustForm.reason.trim() }
|
||||
: { buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0), reason: priceAdjustForm.reason.trim() }
|
||||
? {
|
||||
buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0),
|
||||
reason: priceAdjustForm.reason.trim(),
|
||||
}
|
||||
: {
|
||||
buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0),
|
||||
reason: priceAdjustForm.reason.trim(),
|
||||
}
|
||||
if ((payload.buyer_ratio || payload.buyer_total_price || 0) <= 0) {
|
||||
ElMessage.warning('请填写有效的加价后比例或价格')
|
||||
return
|
||||
@@ -233,7 +260,9 @@ function assetNumberText(row: Listing, key: string) {
|
||||
|
||||
function priceBreakdown(row: Listing) {
|
||||
const breakdown = row.asset_summary?.price_breakdown
|
||||
return typeof breakdown === 'object' && breakdown !== null ? (breakdown as Record<string, unknown>) : {}
|
||||
return typeof breakdown === 'object' && breakdown !== null
|
||||
? (breakdown as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function breakdownNumber(row: Listing, key: string) {
|
||||
@@ -278,7 +307,8 @@ function sellerCoinBasePrice(row: Listing) {
|
||||
}
|
||||
|
||||
function sellerRatio(row: Listing) {
|
||||
const value = breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
|
||||
const value =
|
||||
breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
|
||||
if (value > 0) return value
|
||||
const base = sellerCoinBasePrice(row)
|
||||
return base > 0 ? getCoinWan(row) / base : 0
|
||||
@@ -302,7 +332,10 @@ function buyerRatio(row: Listing) {
|
||||
return base > 0 ? getCoinWan(row) / base : 0
|
||||
}
|
||||
|
||||
function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = priceAdjustMode.value) {
|
||||
function calculatePriceAdjustPreview(
|
||||
row: Listing,
|
||||
mode: 'ratio' | 'price' = priceAdjustMode.value
|
||||
) {
|
||||
const consumablePrice = getListingConsumablePrice(row)
|
||||
const coinWan = getCoinWan(row)
|
||||
if (mode === 'price') {
|
||||
@@ -313,7 +346,8 @@ function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = pri
|
||||
}
|
||||
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
|
||||
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 0
|
||||
const buyerTotalPrice = buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
|
||||
const buyerTotalPrice =
|
||||
buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
|
||||
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
|
||||
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
|
||||
}
|
||||
@@ -412,7 +446,7 @@ function isExternalUpload(row: Listing) {
|
||||
}
|
||||
|
||||
function hasDefaultScreenshot(row: Listing) {
|
||||
return row.screenshot_urls?.some((url) => isDefaultScreenshot(url)) || false
|
||||
return row.screenshot_urls?.some(url => isDefaultScreenshot(url)) || false
|
||||
}
|
||||
|
||||
function isDefaultScreenshot(url: string) {
|
||||
@@ -425,12 +459,18 @@ function riskItems(row: Listing): RiskItem[] {
|
||||
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
|
||||
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
|
||||
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
|
||||
if (getListingConsumablePrice(row) > 0 && Number(row.deposit_amount || 0) <= getListingConsumablePrice(row)) {
|
||||
if (
|
||||
getListingConsumablePrice(row) > 0 &&
|
||||
Number(row.deposit_amount || 0) <= getListingConsumablePrice(row)
|
||||
) {
|
||||
items.push({ label: '押金不高于消耗品价值', level: 'danger' })
|
||||
}
|
||||
if (readAssetNumber(row, 'daily_loss_m') <= 0) items.push({ label: '缺少每日损耗', level: 'warning' })
|
||||
if (readAssetNumber(row, 'fire_level') <= 40) items.push({ label: '烽火等级接近下限', level: 'warning' })
|
||||
if (!readAssetString(row, 'season_insurance')) items.push({ label: '缺少保险格数', level: 'warning' })
|
||||
if (readAssetNumber(row, 'daily_loss_m') <= 0)
|
||||
items.push({ label: '缺少每日损耗', level: 'warning' })
|
||||
if (readAssetNumber(row, 'fire_level') <= 40)
|
||||
items.push({ label: '烽火等级接近下限', level: 'warning' })
|
||||
if (!readAssetString(row, 'season_insurance'))
|
||||
items.push({ label: '缺少保险格数', level: 'warning' })
|
||||
if (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
|
||||
return items
|
||||
}
|
||||
@@ -525,19 +565,35 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
|
||||
<div class="review-summary">
|
||||
<button class="summary-card" :class="{ active: filters.risk === 'all' }" @click="filters.risk = 'all'">
|
||||
<button
|
||||
class="summary-card"
|
||||
:class="{ active: filters.risk === 'all' }"
|
||||
@click="filters.risk = 'all'"
|
||||
>
|
||||
<span>待审核</span>
|
||||
<strong>{{ listings.length }}</strong>
|
||||
</button>
|
||||
<button class="summary-card" :class="{ active: filters.risk === 'external' }" @click="filters.risk = 'external'">
|
||||
<button
|
||||
class="summary-card"
|
||||
:class="{ active: filters.risk === 'external' }"
|
||||
@click="filters.risk = 'external'"
|
||||
>
|
||||
<span>外部上传</span>
|
||||
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
|
||||
</button>
|
||||
<button class="summary-card" :class="{ active: filters.risk === 'defaultImage' }" @click="filters.risk = 'defaultImage'">
|
||||
<button
|
||||
class="summary-card"
|
||||
:class="{ active: filters.risk === 'defaultImage' }"
|
||||
@click="filters.risk = 'defaultImage'"
|
||||
>
|
||||
<span>默认截图</span>
|
||||
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
|
||||
</button>
|
||||
<button class="summary-card" :class="{ active: filters.risk === 'ban' }" @click="filters.risk = 'ban'">
|
||||
<button
|
||||
class="summary-card"
|
||||
:class="{ active: filters.risk === 'ban' }"
|
||||
@click="filters.risk = 'ban'"
|
||||
>
|
||||
<span>封禁风险</span>
|
||||
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
|
||||
</button>
|
||||
@@ -546,7 +602,12 @@ function readError(error: unknown, fallback: string) {
|
||||
<div class="review-workbench">
|
||||
<aside class="review-queue">
|
||||
<div class="queue-toolbar">
|
||||
<el-input v-model="filters.keyword" :prefix-icon="Search" clearable placeholder="搜索标题、客服、段位、皮肤" />
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="搜索标题、客服、段位、皮肤"
|
||||
/>
|
||||
<el-select v-model="filters.risk" placeholder="风险筛选">
|
||||
<el-option label="全部待审" value="all" />
|
||||
<el-option label="外部上传" value="external" />
|
||||
@@ -571,16 +632,23 @@ function readError(error: unknown, fallback: string) {
|
||||
<div class="queue-meta">
|
||||
<span>{{ item.rank_level || '-' }}</span>
|
||||
<span>{{ assetText(item, 'season_insurance') }}</span>
|
||||
<span>{{ assetText(item, 'stamina_level') }}/{{ assetText(item, 'load_level') }}</span>
|
||||
<span
|
||||
>{{ assetText(item, 'stamina_level') }}/{{ assetText(item, 'load_level') }}</span
|
||||
>
|
||||
<span>KD {{ assetNumberText(item, 'secret_kd') }}</span>
|
||||
</div>
|
||||
<div class="queue-footer">
|
||||
<span>{{ money(item.price) }} / 押 {{ money(item.deposit_amount) }}</span>
|
||||
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{ uploaderName(item) }}</el-tag>
|
||||
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
||||
uploaderName(item)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
|
||||
</div>
|
||||
</button>
|
||||
<el-empty v-if="!loading && !filteredListings.length" description="暂无符合条件的待审核商品" />
|
||||
<el-empty
|
||||
v-if="!loading && !filteredListings.length"
|
||||
description="暂无符合条件的待审核商品"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -595,12 +663,29 @@ function readError(error: unknown, fallback: string) {
|
||||
<RouterLink :to="`/admin/listings/${selectedListing.id}`">
|
||||
<el-button>详情页</el-button>
|
||||
</RouterLink>
|
||||
<el-button :icon="Close" type="danger" :loading="submitting" @click="openReject(selectedListing)">拒绝</el-button>
|
||||
<el-button :icon="Check" type="primary" :loading="submitting" @click="handleApprove(selectedListing)">通过</el-button>
|
||||
<el-button
|
||||
:icon="Close"
|
||||
type="danger"
|
||||
:loading="submitting"
|
||||
@click="openReject(selectedListing)"
|
||||
>拒绝</el-button
|
||||
>
|
||||
<el-button
|
||||
:icon="Check"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleApprove(selectedListing)"
|
||||
>通过</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="risk-strip">
|
||||
<el-tag v-for="risk in activeRisks" :key="risk.label" :type="riskTagType(risk.level)" effect="light">
|
||||
<el-tag
|
||||
v-for="risk in activeRisks"
|
||||
:key="risk.label"
|
||||
:type="riskTagType(risk.level)"
|
||||
effect="light"
|
||||
>
|
||||
{{ risk.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
@@ -612,14 +697,19 @@ function readError(error: unknown, fallback: string) {
|
||||
<h3>价格审核</h3>
|
||||
<p>核对卖家提交价格、平台加价规则和最终买家展示价。</p>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)">调整价格</el-button>
|
||||
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)"
|
||||
>调整价格</el-button
|
||||
>
|
||||
</div>
|
||||
<div class="price-decision-grid">
|
||||
<div class="price-decision-card seller">
|
||||
<span>卖家发布</span>
|
||||
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
|
||||
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
|
||||
<small>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }}</small>
|
||||
<small
|
||||
>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品
|
||||
{{ money(getListingConsumablePrice(selectedListing)) }}</small
|
||||
>
|
||||
</div>
|
||||
<div class="price-decision-card rule">
|
||||
<span>当前加价规则</span>
|
||||
@@ -648,29 +738,82 @@ function readError(error: unknown, fallback: string) {
|
||||
<h3>账号属性</h3>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div><dt>上号方式</dt><dd>{{ selectedListing.login_platform || '-' }}</dd></div>
|
||||
<div><dt>区服</dt><dd>{{ selectedListing.server_region || '-' }}</dd></div>
|
||||
<div><dt>段位</dt><dd>{{ selectedListing.rank_level || '-' }}</dd></div>
|
||||
<div><dt>烽火等级</dt><dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd></div>
|
||||
<div><dt>保险格数</dt><dd>{{ assetText(selectedListing, 'season_insurance') }}</dd></div>
|
||||
<div><dt>绝密KD</dt><dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd></div>
|
||||
<div><dt>体力/负重</dt><dd>{{ assetText(selectedListing, 'stamina_level') }} / {{ assetText(selectedListing, 'load_level') }}</dd></div>
|
||||
<div><dt>封禁记录</dt><dd>{{ banRecordText(selectedListing) }}</dd></div>
|
||||
<div>
|
||||
<dt>上号方式</dt>
|
||||
<dd>{{ selectedListing.login_platform || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>区服</dt>
|
||||
<dd>{{ selectedListing.server_region || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>段位</dt>
|
||||
<dd>{{ selectedListing.rank_level || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>烽火等级</dt>
|
||||
<dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>保险格数</dt>
|
||||
<dd>{{ assetText(selectedListing, 'season_insurance') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>绝密KD</dt>
|
||||
<dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>体力/负重</dt>
|
||||
<dd>
|
||||
{{ assetText(selectedListing, 'stamina_level') }} /
|
||||
{{ assetText(selectedListing, 'load_level') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>封禁记录</dt>
|
||||
<dd>{{ banRecordText(selectedListing) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="review-panel">
|
||||
<div class="panel-title">
|
||||
<h3>上传信息</h3>
|
||||
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info">外部上传</el-tag>
|
||||
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
|
||||
>外部上传</el-tag
|
||||
>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div><dt>上传人</dt><dd>{{ uploaderName(selectedListing) }}</dd></div>
|
||||
<div><dt>号主</dt><dd>{{ selectedListing.owner_phone || selectedListing.owner_nickname || selectedListing.owner_id }}</dd></div>
|
||||
<div><dt>联系电话</dt><dd>{{ contactPhone(selectedListing) }}</dd></div>
|
||||
<div><dt>常用地区</dt><dd>{{ commonRegionText(selectedListing) }}</dd></div>
|
||||
<div><dt>在线时间</dt><dd>{{ ownerOnlineText(selectedListing) }}</dd></div>
|
||||
<div><dt>提交时间</dt><dd>{{ formatDateTime(selectedListing.updated_at) }}</dd></div>
|
||||
<div>
|
||||
<dt>上传人</dt>
|
||||
<dd>{{ uploaderName(selectedListing) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>号主</dt>
|
||||
<dd>
|
||||
{{
|
||||
selectedListing.owner_phone ||
|
||||
selectedListing.owner_nickname ||
|
||||
selectedListing.owner_id
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>联系电话</dt>
|
||||
<dd>{{ contactPhone(selectedListing) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>常用地区</dt>
|
||||
<dd>{{ commonRegionText(selectedListing) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>在线时间</dt>
|
||||
<dd>{{ ownerOnlineText(selectedListing) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{{ formatDateTime(selectedListing.updated_at) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
@@ -681,9 +824,16 @@ function readError(error: unknown, fallback: string) {
|
||||
<span>额外消耗品约 {{ money(getListingConsumablePrice(selectedListing)) }}</span>
|
||||
</div>
|
||||
<div class="inventory-grid">
|
||||
<div><span>AWM子弹</span><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong></div>
|
||||
<div><span>6头</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong></div>
|
||||
<div><span>6甲</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong></div>
|
||||
<div>
|
||||
<span>AWM子弹</span
|
||||
><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>6头</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>6甲</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedResources.length" class="resource-table">
|
||||
<div v-for="resource in selectedResources" :key="resource.key">
|
||||
@@ -693,7 +843,9 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="skin-list">
|
||||
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{ skin }}</el-tag>
|
||||
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{
|
||||
skin
|
||||
}}</el-tag>
|
||||
<span v-if="!selectedSkins.length">暂无皮肤数据</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -701,10 +853,18 @@ function readError(error: unknown, fallback: string) {
|
||||
<section class="review-panel">
|
||||
<div class="panel-title">
|
||||
<h3>账号截图</h3>
|
||||
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)">查看全部</el-button>
|
||||
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)"
|
||||
>查看全部</el-button
|
||||
>
|
||||
</div>
|
||||
<div v-if="selectedListing.screenshot_urls?.length" class="screenshot-grid">
|
||||
<button v-for="url in selectedListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
|
||||
<button
|
||||
v-for="url in selectedListing.screenshot_urls"
|
||||
:key="url"
|
||||
type="button"
|
||||
class="screenshot-tile"
|
||||
@click="openScreenshot(url)"
|
||||
>
|
||||
<img :src="previewURLs[url] || url" alt="账号截图" />
|
||||
<span v-if="isDefaultScreenshot(url)">默认图片</span>
|
||||
</button>
|
||||
@@ -721,15 +881,32 @@ function readError(error: unknown, fallback: string) {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="620px" @update:model-value="activeListing = null">
|
||||
<el-dialog
|
||||
:model-value="!!activeListing"
|
||||
title="拒绝发布"
|
||||
width="620px"
|
||||
@update:model-value="activeListing = null"
|
||||
>
|
||||
<div v-if="activeListing" class="dialog-body reject-dialog">
|
||||
<p><strong>{{ activeListing.title }}</strong></p>
|
||||
<p>
|
||||
<strong>{{ activeListing.title }}</strong>
|
||||
</p>
|
||||
<div class="reject-reasons">
|
||||
<el-button v-for="reason in rejectReasonOptions" :key="reason" size="small" @click="appendRejectReason(reason)">
|
||||
<el-button
|
||||
v-for="reason in rejectReasonOptions"
|
||||
:key="reason"
|
||||
size="small"
|
||||
@click="appendRejectReason(reason)"
|
||||
>
|
||||
{{ reason }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="5" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
|
||||
<el-input
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="填写拒绝原因,号主会在通知中看到审核结果"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeListing = null">取消</el-button>
|
||||
@@ -737,7 +914,12 @@ function readError(error: unknown, fallback: string) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!priceAdjustListing" title="调整审核价格" width="560px" @update:model-value="priceAdjustListing = null">
|
||||
<el-dialog
|
||||
:model-value="!!priceAdjustListing"
|
||||
title="调整审核价格"
|
||||
width="560px"
|
||||
@update:model-value="priceAdjustListing = null"
|
||||
>
|
||||
<div v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
|
||||
<div class="adjust-current">
|
||||
<div>
|
||||
@@ -746,11 +928,15 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
<div>
|
||||
<span>调整后买家价</span>
|
||||
<strong class="preview-value">{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}</strong>
|
||||
<strong class="preview-value">{{
|
||||
previewMoney(priceAdjustPreview?.buyerTotalPrice || 0)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>调整后买家比例</span>
|
||||
<strong class="preview-value">{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}</strong>
|
||||
<strong class="preview-value">{{
|
||||
ratioText(priceAdjustPreview?.buyerRatio || 0)
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-radio-group v-model="priceAdjustMode" class="adjust-mode">
|
||||
@@ -759,28 +945,61 @@ function readError(error: unknown, fallback: string) {
|
||||
</el-radio-group>
|
||||
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
|
||||
<span>加价后比例</span>
|
||||
<el-input-number v-model="priceAdjustForm.buyer_ratio" :min="0" :step="0.1" :controls="false" />
|
||||
<el-input-number
|
||||
v-model="priceAdjustForm.buyer_ratio"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
:controls="false"
|
||||
/>
|
||||
</label>
|
||||
<label v-else class="adjust-field">
|
||||
<span>加价后价格</span>
|
||||
<el-input-number v-model="priceAdjustForm.buyer_total_price" :min="0" :step="1" :controls="false" />
|
||||
<el-input-number
|
||||
v-model="priceAdjustForm.buyer_total_price"
|
||||
:min="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="adjust-field">
|
||||
<span>调价原因</span>
|
||||
<el-input v-model="priceAdjustForm.reason" type="textarea" :rows="3" placeholder="可填写调价原因,便于审计追踪" />
|
||||
<el-input
|
||||
v-model="priceAdjustForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可填写调价原因,便于审计追踪"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="priceAdjustListing = null">取消</el-button>
|
||||
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust">保存调整</el-button>
|
||||
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust"
|
||||
>保存调整</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
|
||||
<el-dialog
|
||||
:model-value="!!evidenceListing"
|
||||
title="账号资产截图"
|
||||
width="860px"
|
||||
@update:model-value="evidenceListing = null"
|
||||
>
|
||||
<div v-if="evidenceListing" class="dialog-body">
|
||||
<p><strong>{{ evidenceListing.title }}</strong></p>
|
||||
<div v-if="evidenceListing.screenshot_urls?.length" class="screenshot-grid dialog-screenshots">
|
||||
<button v-for="url in evidenceListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
|
||||
<p>
|
||||
<strong>{{ evidenceListing.title }}</strong>
|
||||
</p>
|
||||
<div
|
||||
v-if="evidenceListing.screenshot_urls?.length"
|
||||
class="screenshot-grid dialog-screenshots"
|
||||
>
|
||||
<button
|
||||
v-for="url in evidenceListing.screenshot_urls"
|
||||
:key="url"
|
||||
type="button"
|
||||
class="screenshot-tile"
|
||||
@click="openScreenshot(url)"
|
||||
>
|
||||
<img :src="previewURLs[url] || url" alt="账号截图" />
|
||||
<span v-if="isDefaultScreenshot(url)">默认图片</span>
|
||||
</button>
|
||||
@@ -911,7 +1130,10 @@ function readError(error: unknown, fallback: string) {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.queue-item:hover,
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchAdminListings, type AdminListingPage, type AdminListingQuery, type Listing } from '@/features/listings'
|
||||
import {
|
||||
fetchAdminListings,
|
||||
type AdminListingPage,
|
||||
type AdminListingQuery,
|
||||
type Listing,
|
||||
} from '@/features/listings'
|
||||
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
||||
import { useMoney } from '@/shared/composables/useMoney'
|
||||
import {
|
||||
@@ -29,7 +34,11 @@ const filters = reactive<AdminListingQuery>({
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
|
||||
const {
|
||||
loading,
|
||||
data: listingPage,
|
||||
load: loadListings,
|
||||
} = useAdminTable<AdminListingPage>({
|
||||
fetchFn: () =>
|
||||
fetchAdminListings({
|
||||
...filters,
|
||||
@@ -46,9 +55,13 @@ const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminLi
|
||||
|
||||
const listings = computed(() => listingPage.value.items)
|
||||
const totalListings = computed(() => listingPage.value.total)
|
||||
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
|
||||
const rentedCount = computed(() => listings.value.filter((item) => item.status === 'rented').length)
|
||||
const pendingCount = computed(() => listings.value.filter((item) => item.review_status === 'pending').length)
|
||||
const publishedCount = computed(
|
||||
() => listings.value.filter(item => item.status === 'published').length
|
||||
)
|
||||
const rentedCount = computed(() => listings.value.filter(item => item.status === 'rented').length)
|
||||
const pendingCount = computed(
|
||||
() => listings.value.filter(item => item.review_status === 'pending').length
|
||||
)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
|
||||
|
||||
const screenshotColumns = [
|
||||
@@ -65,10 +78,18 @@ const screenshotColumns = [
|
||||
{ label: '六头', width: 50, read: (row: Listing) => resourceText(row, 'helmet6') },
|
||||
{ label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') },
|
||||
{ label: '特殊刀皮', width: 110, read: (row: Listing) => skinGroupText(row, 'melee') },
|
||||
{ label: '人物红皮/人物金皮/武器皮肤', width: 420, read: (row: Listing) => characterAndWeaponSkinText(row) },
|
||||
{
|
||||
label: '人物红皮/人物金皮/武器皮肤',
|
||||
width: 420,
|
||||
read: (row: Listing) => characterAndWeaponSkinText(row),
|
||||
},
|
||||
{ label: '租金/押金', width: 96, read: (row: Listing) => rentAndDepositText(row) },
|
||||
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(row) },
|
||||
{ label: '租期', width: 92, read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}` },
|
||||
{
|
||||
label: '租期',
|
||||
width: 92,
|
||||
read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}`,
|
||||
},
|
||||
] as const
|
||||
|
||||
async function queryListings() {
|
||||
@@ -90,7 +111,9 @@ function setStatusFilter(status: string) {
|
||||
}
|
||||
|
||||
function setReviewStatusFilter(reviewStatus: string) {
|
||||
filters.review_status = (filters.review_status === reviewStatus ? '' : reviewStatus) as AdminListingQuery['review_status']
|
||||
filters.review_status = (
|
||||
filters.review_status === reviewStatus ? '' : reviewStatus
|
||||
) as AdminListingQuery['review_status']
|
||||
filters.status = ''
|
||||
void queryListings()
|
||||
}
|
||||
@@ -143,7 +166,7 @@ async function downloadTableScreenshot() {
|
||||
async function createTableScreenshotBlob() {
|
||||
if (!listings.value.length) throw new Error('当前页暂无可截图数据')
|
||||
const canvas = renderTableScreenshotCanvas(listings.value)
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
|
||||
if (!blob) throw new Error('截图生成失败')
|
||||
return blob
|
||||
}
|
||||
@@ -165,14 +188,17 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
|
||||
if (!measureCtx) throw new Error('截图画布初始化失败')
|
||||
measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif'
|
||||
|
||||
const rowLines = rows.map((row) =>
|
||||
screenshotColumns.map((column) => wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)),
|
||||
const rowLines = rows.map(row =>
|
||||
screenshotColumns.map(column =>
|
||||
wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)
|
||||
)
|
||||
)
|
||||
const rowHeights = rowLines.map((lineGroups) => {
|
||||
const maxLines = Math.max(...lineGroups.map((lines) => lines.length), 1)
|
||||
const rowHeights = rowLines.map(lineGroups => {
|
||||
const maxLines = Math.max(...lineGroups.map(lines => lines.length), 1)
|
||||
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2)
|
||||
})
|
||||
const canvasHeight = paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
|
||||
const canvasHeight =
|
||||
paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(canvasWidth * ratio)
|
||||
canvas.height = Math.round(canvasHeight * ratio)
|
||||
@@ -189,7 +215,11 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
|
||||
ctx.fillText('商品管理', paddingX, paddingY + 20)
|
||||
ctx.fillStyle = '#8f9bba'
|
||||
ctx.font = '12px Arial, "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value} 条`, paddingX, paddingY + 40)
|
||||
ctx.fillText(
|
||||
`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value} 条`,
|
||||
paddingX,
|
||||
paddingY + 40
|
||||
)
|
||||
|
||||
let y = paddingY + titleHeight
|
||||
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
|
||||
@@ -211,7 +241,10 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
|
||||
for (const [columnIndex, column] of screenshotColumns.entries()) {
|
||||
const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-']
|
||||
ctx.fillStyle = columnIndex === 0 ? '#4b5563' : '#3f4654'
|
||||
ctx.font = columnIndex === 0 ? '700 13px Arial, "Microsoft YaHei", sans-serif' : '13px Arial, "Microsoft YaHei", sans-serif'
|
||||
ctx.font =
|
||||
columnIndex === 0
|
||||
? '700 13px Arial, "Microsoft YaHei", sans-serif'
|
||||
: '13px Arial, "Microsoft YaHei", sans-serif'
|
||||
drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
|
||||
x += column.width
|
||||
}
|
||||
@@ -240,18 +273,37 @@ function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: n
|
||||
return lines
|
||||
}
|
||||
|
||||
function drawCellText(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number) {
|
||||
function drawCellText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
lines: string[],
|
||||
x: number,
|
||||
y: number,
|
||||
lineHeight: number
|
||||
) {
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, x, y + index * lineHeight)
|
||||
})
|
||||
}
|
||||
|
||||
function drawRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, color: string) {
|
||||
function drawRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string
|
||||
) {
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, width, height)
|
||||
}
|
||||
|
||||
function drawLine(ctx: CanvasRenderingContext2D, startX: number, startY: number, endX: number, endY: number) {
|
||||
function drawLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number
|
||||
) {
|
||||
ctx.strokeStyle = '#e6eaf2'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
@@ -311,7 +363,7 @@ function characterAndWeaponSkinText(row: Listing) {
|
||||
{ label: '武器', key: 'weapon' },
|
||||
]
|
||||
const parts = groups
|
||||
.map((group) => {
|
||||
.map(group => {
|
||||
const names = getSkinGroup(row, group.key)
|
||||
return names.length ? `${group.label}:${names.join('、')}` : ''
|
||||
})
|
||||
@@ -421,9 +473,13 @@ function formatQuantity(value: number) {
|
||||
</el-form>
|
||||
<div class="listing-action-strip">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings">查询</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings"
|
||||
>查询</el-button
|
||||
>
|
||||
<el-button :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
|
||||
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button>
|
||||
<el-button :disabled="!listings.length" @click="downloadTableScreenshot"
|
||||
>下载截图</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -504,7 +560,9 @@ function formatQuantity(value: number) {
|
||||
</el-table>
|
||||
|
||||
<div class="table-pagination">
|
||||
<span class="pagination-summary">当前页 {{ listings.length }} 条,共 {{ totalListings }} 条</span>
|
||||
<span class="pagination-summary"
|
||||
>当前页 {{ listings.length }} 条,共 {{ totalListings }} 条</span
|
||||
>
|
||||
<div class="pagination-controls">
|
||||
<span class="pagination-size-label">每页</span>
|
||||
<el-select
|
||||
@@ -520,7 +578,9 @@ function formatQuantity(value: number) {
|
||||
</el-select>
|
||||
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}页</span>
|
||||
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上页</el-button>
|
||||
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下页</el-button>
|
||||
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage"
|
||||
>下页</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,63 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Lock, User } from "@element-plus/icons-vue";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, User } from '@element-plus/icons-vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAdminCaptcha, type AdminCaptcha } from "@/features/admin";
|
||||
import { useAdminSessionStore } from "@/stores/adminSession";
|
||||
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
|
||||
const router = useRouter();
|
||||
const adminSession = useAdminSessionStore();
|
||||
const loading = ref(false);
|
||||
const captchaLoading = ref(false);
|
||||
const captcha = ref<AdminCaptcha | null>(null);
|
||||
const router = useRouter()
|
||||
const adminSession = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
const captcha = ref<AdminCaptcha | null>(null)
|
||||
const form = reactive({
|
||||
username: "admin",
|
||||
password: "admin123456",
|
||||
captchaCode: "",
|
||||
});
|
||||
username: 'admin',
|
||||
password: 'admin123456',
|
||||
captchaCode: '',
|
||||
})
|
||||
|
||||
onMounted(loadCaptcha);
|
||||
onMounted(loadCaptcha)
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true;
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
captcha.value = await fetchAdminCaptcha();
|
||||
form.captchaCode = "";
|
||||
captcha.value = await fetchAdminCaptcha()
|
||||
form.captchaCode = ''
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, "验证码加载失败"));
|
||||
ElMessage.error(readError(error, '验证码加载失败'))
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
await adminSession.login(
|
||||
form.username,
|
||||
form.password,
|
||||
captcha.value?.captcha_id || "",
|
||||
captcha.value?.captcha_id || '',
|
||||
form.captchaCode
|
||||
);
|
||||
ElMessage.success("后台登录成功");
|
||||
await router.push("/admin/dashboard");
|
||||
)
|
||||
ElMessage.success('后台登录成功')
|
||||
await router.push('/admin/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, "后台登录失败"));
|
||||
await loadCaptcha();
|
||||
ElMessage.error(readError(error, '后台登录失败'))
|
||||
await loadCaptcha()
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === "object" && error && "response" in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } })
|
||||
.response;
|
||||
return response?.data?.message || fallback;
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback;
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -151,16 +150,9 @@ function readError(error: unknown, fallback: string) {
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(
|
||||
circle at 18% 20%,
|
||||
rgba(20, 119, 255, 0.14),
|
||||
transparent 42%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 82% 80%,
|
||||
rgba(109, 40, 217, 0.12),
|
||||
transparent 42%
|
||||
),
|
||||
background:
|
||||
radial-gradient(circle at 18% 20%, rgba(20, 119, 255, 0.14), transparent 42%),
|
||||
radial-gradient(circle at 82% 80%, rgba(109, 40, 217, 0.12), transparent 42%),
|
||||
linear-gradient(180deg, #0b1120 0%, #0f172a 60%, #111827 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -172,11 +164,7 @@ function readError(error: unknown, fallback: string) {
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(20, 119, 255, 0.22),
|
||||
transparent 65%
|
||||
);
|
||||
background: radial-gradient(circle, rgba(20, 119, 255, 0.22), transparent 65%);
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -185,11 +173,7 @@ function readError(error: unknown, fallback: string) {
|
||||
left: auto;
|
||||
right: -10%;
|
||||
bottom: -10%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(109, 40, 217, 0.18),
|
||||
transparent 65%
|
||||
);
|
||||
background: radial-gradient(circle, rgba(109, 40, 217, 0.18), transparent 65%);
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
@@ -203,7 +187,8 @@ function readError(error: unknown, fallback: string) {
|
||||
border-radius: 20px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(24px);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35),
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -214,11 +199,8 @@ function readError(error: unknown, fallback: string) {
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 40px 36px;
|
||||
background: radial-gradient(
|
||||
circle at 30% 20%,
|
||||
rgba(20, 119, 255, 0.12),
|
||||
transparent 50%
|
||||
),
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(20, 119, 255, 0.12), transparent 50%),
|
||||
radial-gradient(circle at 80% 90%, rgba(109, 40, 217, 0.1), transparent 50%),
|
||||
linear-gradient(160deg, rgba(20, 119, 255, 0.1), rgba(109, 40, 217, 0.06));
|
||||
}
|
||||
@@ -312,14 +294,17 @@ function readError(error: unknown, fallback: string) {
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
transition: box-shadow 0.2s, background 0.2s;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.admin-form :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.admin-form :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(20, 119, 255, 0.45) inset,
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(20, 119, 255, 0.45) inset,
|
||||
0 0 0 3px rgba(20, 119, 255, 0.08);
|
||||
}
|
||||
.admin-form :deep(.el-input__inner) {
|
||||
@@ -352,7 +337,9 @@ function readError(error: unknown, fallback: string) {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.captcha-image-button:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
@@ -383,7 +370,9 @@ function readError(error: unknown, fallback: string) {
|
||||
background: linear-gradient(135deg, #1477ff, #2f8dff);
|
||||
border: none;
|
||||
box-shadow: 0 10px 28px rgba(20, 119, 255, 0.25);
|
||||
transition: transform 0.15s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
.admin-login-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { fetchAdminMgrUsers, deleteAdminMgrUser, changeAdminPassword, type AdminMgrUser } from '@/features/admin/api/adminMgr'
|
||||
import {
|
||||
fetchAdminMgrUsers,
|
||||
deleteAdminMgrUser,
|
||||
changeAdminPassword,
|
||||
type AdminMgrUser,
|
||||
} from '@/features/admin/api/adminMgr'
|
||||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
@@ -19,7 +24,15 @@ const passwordAdmin = ref<AdminMgrUser | null>(null)
|
||||
const passwordForm = ref({ old_password: '', new_password: '' })
|
||||
const passwordSubmitting = ref(false)
|
||||
|
||||
const { loading, data: admins, total, currentPage, currentPageSize, load: loadAdmins, handleSizeChange } = useAdminPaginatedTable<AdminMgrUser>({
|
||||
const {
|
||||
loading,
|
||||
data: admins,
|
||||
total,
|
||||
currentPage,
|
||||
currentPageSize,
|
||||
load: loadAdmins,
|
||||
handleSizeChange,
|
||||
} = useAdminPaginatedTable<AdminMgrUser>({
|
||||
fetchFn: fetchAdminMgrUsers,
|
||||
})
|
||||
|
||||
@@ -46,11 +59,15 @@ function openPassword(row: AdminMgrUser) {
|
||||
|
||||
async function handleDelete(row: AdminMgrUser) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除管理员「${row.username}」吗?此操作不可撤销。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
await deleteAdminMgrUser(row.id)
|
||||
ElMessage.success('管理员已删除')
|
||||
await loadAdmins()
|
||||
@@ -130,7 +147,9 @@ const statusLabel: Record<string, string> = {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" min-width="170">
|
||||
<template #default="{ row }">{{ row.last_login_at ? formatDateTime(row.last_login_at) : '-' }}</template>
|
||||
<template #default="{ row }">{{
|
||||
row.last_login_at ? formatDateTime(row.last_login_at) : '-'
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
@@ -152,18 +171,10 @@ const statusLabel: Record<string, string> = {
|
||||
/>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<AdminUserDialog
|
||||
v-model="showDialog"
|
||||
:admin="editingAdmin"
|
||||
@saved="loadAdmins"
|
||||
/>
|
||||
<AdminUserDialog v-model="showDialog" :admin="editingAdmin" @saved="loadAdmins" />
|
||||
|
||||
<!-- 角色分配对话框 -->
|
||||
<AssignRolesDialog
|
||||
v-model="showRolesDialog"
|
||||
:admin="rolesAdmin"
|
||||
@saved="loadAdmins"
|
||||
/>
|
||||
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog
|
||||
@@ -174,15 +185,27 @@ const statusLabel: Record<string, string> = {
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<el-form-item label="原密码" class="full-control">
|
||||
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入原密码" />
|
||||
<el-input
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入原密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" class="full-control">
|
||||
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="请输入新密码(至少6位)" />
|
||||
<el-input
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword">确认修改</el-button>
|
||||
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
|
||||
>确认修改</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,17 @@ import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { adminCloseOrder, adminMarkOrderAbnormal, adminRefundOrder, adminRefundStatus, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order, type RefundStatus } from '@/features/orders'
|
||||
import {
|
||||
adminCloseOrder,
|
||||
adminMarkOrderAbnormal,
|
||||
adminRefundOrder,
|
||||
adminRefundStatus,
|
||||
fetchAdminHandoffRecords,
|
||||
fetchAdminOrder,
|
||||
type HandoffRecord,
|
||||
type Order,
|
||||
type RefundStatus,
|
||||
} from '@/features/orders'
|
||||
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
@@ -19,7 +29,9 @@ const refunding = ref(false)
|
||||
|
||||
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
|
||||
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
|
||||
const canOperate = computed(() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status))
|
||||
const canOperate = computed(
|
||||
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
|
||||
)
|
||||
|
||||
onMounted(loadOrder)
|
||||
|
||||
@@ -138,9 +150,18 @@ function formatHandoffRecordType(type: string) {
|
||||
<RouterLink to="/admin/orders">
|
||||
<el-button>返回列表</el-button>
|
||||
</RouterLink>
|
||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
|
||||
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
|
||||
<el-button type="primary" :loading="refunding" :disabled="refundStatus?.refund_status === 'refunded'" @click="handleRefund">
|
||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
|
||||
>标记异常</el-button
|
||||
>
|
||||
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')"
|
||||
>客服关闭</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="refunding"
|
||||
:disabled="refundStatus?.refund_status === 'refunded'"
|
||||
@click="handleRefund"
|
||||
>
|
||||
{{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -170,7 +191,9 @@ function formatHandoffRecordType(type: string) {
|
||||
<div v-if="refundStatus" class="metric-card">
|
||||
<span>退款状态</span>
|
||||
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
|
||||
<small v-if="refundStatus.refund_amount_cent > 0">¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small>
|
||||
<small v-if="refundStatus.refund_amount_cent > 0"
|
||||
>¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,10 +222,22 @@ function formatHandoffRecordType(type: string) {
|
||||
<pre>{{ snapshotText }}</pre>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
|
||||
<el-dialog
|
||||
:model-value="!!actionType"
|
||||
:title="actionTitle"
|
||||
width="560px"
|
||||
@update:model-value="actionType = ''"
|
||||
>
|
||||
<div v-if="order" class="dialog-body">
|
||||
<p><strong>{{ order.order_no }}</strong> · {{ order.title }}</p>
|
||||
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写客服操作原因,会写入审计日志并通知双方" />
|
||||
<p>
|
||||
<strong>{{ order.order_no }}</strong> · {{ order.title }}
|
||||
</p>
|
||||
<el-input
|
||||
v-model="reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="填写客服操作原因,会写入审计日志并通知双方"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="actionType = ''">取消</el-button>
|
||||
|
||||
@@ -32,7 +32,7 @@ async function handlePageChange() {
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!status.value) return orders.value
|
||||
return orders.value.filter((item) => item.status === status.value)
|
||||
return orders.value.filter(item => item.status === status.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -117,11 +117,15 @@ async function handleDelete(row: PaymentConfig) {
|
||||
|
||||
async function handleExportBackup() {
|
||||
try {
|
||||
await ElMessageBox.confirm('备份文件会包含支付密钥明文,请妥善保管。确定导出吗?', '导出支付配置备份', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '导出',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await ElMessageBox.confirm(
|
||||
'备份文件会包含支付密钥明文,请妥善保管。确定导出吗?',
|
||||
'导出支付配置备份',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '导出',
|
||||
cancelButtonText: '取消',
|
||||
}
|
||||
)
|
||||
|
||||
exporting.value = true
|
||||
const { blob, filename } = await exportPaymentConfigBackup()
|
||||
@@ -162,7 +166,7 @@ async function handleImportFile(event: Event) {
|
||||
type: 'warning',
|
||||
confirmButtonText: '导入',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
importing.value = true
|
||||
@@ -256,18 +260,52 @@ function deleteDisabledReason(row: PaymentConfig) {
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filterProvider" placeholder="支付服务商" style="width: 150px" @change="loadConfigs">
|
||||
<el-option v-for="opt in providerOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
<el-select
|
||||
v-model="filterProvider"
|
||||
placeholder="支付服务商"
|
||||
style="width: 150px"
|
||||
@change="loadConfigs"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in providerOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="状态" style="width: 120px" @change="loadConfigs">
|
||||
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
<el-select
|
||||
v-model="filterStatus"
|
||||
placeholder="状态"
|
||||
style="width: 120px"
|
||||
@change="loadConfigs"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filterEnvironment" placeholder="环境" style="width: 120px" @change="loadConfigs">
|
||||
<el-option v-for="opt in environmentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
<el-select
|
||||
v-model="filterEnvironment"
|
||||
placeholder="环境"
|
||||
style="width: 120px"
|
||||
@change="loadConfigs"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in environmentOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" @click="loadConfigs">刷新</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup">导入备份</el-button>
|
||||
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup">导出备份</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup"
|
||||
>导入备份</el-button
|
||||
>
|
||||
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup"
|
||||
>导出备份</el-button
|
||||
>
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button>
|
||||
</div>
|
||||
|
||||
@@ -279,12 +317,7 @@ function deleteDisabledReason(row: PaymentConfig) {
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
:data="filteredConfigs"
|
||||
v-loading="loading"
|
||||
class="payment-config-table"
|
||||
stripe
|
||||
>
|
||||
<el-table :data="filteredConfigs" v-loading="loading" class="payment-config-table" stripe>
|
||||
<el-table-column prop="name" label="配置名称" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="provider" label="服务商" width="120">
|
||||
<template #default="{ row }">
|
||||
@@ -327,10 +360,20 @@ function deleteDisabledReason(row: PaymentConfig) {
|
||||
<el-table-column label="操作" width="226" class-name="operation-column">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button class="action-button view" size="small" :icon="View" @click="handleView(row)">
|
||||
<el-button
|
||||
class="action-button view"
|
||||
size="small"
|
||||
:icon="View"
|
||||
@click="handleView(row)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button class="action-button edit" size="small" :icon="Edit" @click="handleEdit(row)">
|
||||
<el-button
|
||||
class="action-button edit"
|
||||
size="small"
|
||||
:icon="Edit"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-tooltip :content="deleteDisabledReason(row)" placement="top">
|
||||
|
||||
@@ -13,7 +13,11 @@ const editingRole = ref<Role | null>(null)
|
||||
const showPermsDialog = ref(false)
|
||||
const permsRole = ref<Role | null>(null)
|
||||
|
||||
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({
|
||||
const {
|
||||
loading,
|
||||
data: roles,
|
||||
load: loadRoles,
|
||||
} = useAdminTable<Role[]>({
|
||||
fetchFn: fetchRoles,
|
||||
})
|
||||
|
||||
@@ -34,11 +38,15 @@ function openPerms(row: Role) {
|
||||
|
||||
async function handleDelete(row: Role) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
await deleteRole(row.id)
|
||||
ElMessage.success('角色已删除')
|
||||
await loadRoles()
|
||||
@@ -85,18 +93,10 @@ async function handleDelete(row: Role) {
|
||||
</el-table>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<RoleDialog
|
||||
v-model="showDialog"
|
||||
:role="editingRole"
|
||||
@saved="loadRoles"
|
||||
/>
|
||||
<RoleDialog v-model="showDialog" :role="editingRole" @saved="loadRoles" />
|
||||
|
||||
<!-- 权限分配对话框 -->
|
||||
<AssignPermissionsDialog
|
||||
v-model="showPermsDialog"
|
||||
:role="permsRole"
|
||||
@saved="loadRoles"
|
||||
/>
|
||||
<AssignPermissionsDialog v-model="showPermsDialog" :role="permsRole" @saved="loadRoles" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -69,25 +69,39 @@ const agreementsVisible = ref(false)
|
||||
const postRentalNoticeVisible = ref(false)
|
||||
const generalVisible = ref(false)
|
||||
|
||||
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
|
||||
const salePriceConfig = computed(() => configs.value.find((item) => item.key === 'listing.sale_price_config') || null)
|
||||
const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null)
|
||||
const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null)
|
||||
const listingPublishAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_agreements') || null)
|
||||
const orderAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'order.agreements') || null)
|
||||
const postRentalNoticeConfig = computed(() => configs.value.find((item) => item.key === 'profile.post_rental_notice') || null)
|
||||
const publishConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'listing.publish_options') || null
|
||||
)
|
||||
const salePriceConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'listing.sale_price_config') || null
|
||||
)
|
||||
const homeAnnouncementsConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'mobile.home_announcements') || null
|
||||
)
|
||||
const homeBannersConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'mobile.home_banners') || null
|
||||
)
|
||||
const listingPublishAgreementsConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'listing.publish_agreements') || null
|
||||
)
|
||||
const orderAgreementsConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'order.agreements') || null
|
||||
)
|
||||
const postRentalNoticeConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'profile.post_rental_notice') || null
|
||||
)
|
||||
|
||||
const regularConfigs = computed(() =>
|
||||
configs.value.filter(
|
||||
(item) =>
|
||||
item =>
|
||||
item.key !== 'listing.publish_options' &&
|
||||
item.key !== 'listing.sale_price_config' &&
|
||||
item.key !== 'mobile.home_announcements' &&
|
||||
item.key !== 'mobile.home_banners' &&
|
||||
item.key !== 'listing.publish_agreements' &&
|
||||
item.key !== 'order.agreements' &&
|
||||
item.key !== 'profile.post_rental_notice',
|
||||
),
|
||||
item.key !== 'profile.post_rental_notice'
|
||||
)
|
||||
)
|
||||
|
||||
// 只读计算属性,用于渲染卡片上的统计信息
|
||||
@@ -134,7 +148,9 @@ const agreementStats = computed(() => {
|
||||
})
|
||||
|
||||
const listingPublishAgreementStats = computed(() => {
|
||||
const agreements = parseListingPublishAgreements(listingPublishAgreementsConfig.value?.value || '')
|
||||
const agreements = parseListingPublishAgreements(
|
||||
listingPublishAgreementsConfig.value?.value || ''
|
||||
)
|
||||
return {
|
||||
saleTitle: agreements.virtual_asset_sale.title || '出售协议',
|
||||
sellerTitle: agreements.seller_agreement.title || '号主协议',
|
||||
@@ -199,12 +215,24 @@ function parseOrderAgreements(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultOrderAgreements)
|
||||
return {
|
||||
virtual_asset_purchase: {
|
||||
title: readText(parsed.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title),
|
||||
content: readText(parsed.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content),
|
||||
title: readText(
|
||||
parsed.virtual_asset_purchase?.title,
|
||||
defaultOrderAgreements.virtual_asset_purchase.title
|
||||
),
|
||||
content: readText(
|
||||
parsed.virtual_asset_purchase?.content,
|
||||
defaultOrderAgreements.virtual_asset_purchase.content
|
||||
),
|
||||
},
|
||||
renter_agreement: {
|
||||
title: readText(parsed.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title),
|
||||
content: readText(parsed.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content),
|
||||
title: readText(
|
||||
parsed.renter_agreement?.title,
|
||||
defaultOrderAgreements.renter_agreement.title
|
||||
),
|
||||
content: readText(
|
||||
parsed.renter_agreement?.content,
|
||||
defaultOrderAgreements.renter_agreement.content
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -213,12 +241,24 @@ function parseListingPublishAgreements(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
|
||||
return {
|
||||
virtual_asset_sale: {
|
||||
title: readText(parsed.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title),
|
||||
content: readText(parsed.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content),
|
||||
title: readText(
|
||||
parsed.virtual_asset_sale?.title,
|
||||
defaultListingPublishAgreements.virtual_asset_sale.title
|
||||
),
|
||||
content: readText(
|
||||
parsed.virtual_asset_sale?.content,
|
||||
defaultListingPublishAgreements.virtual_asset_sale.content
|
||||
),
|
||||
},
|
||||
seller_agreement: {
|
||||
title: readText(parsed.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title),
|
||||
content: readText(parsed.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content),
|
||||
title: readText(
|
||||
parsed.seller_agreement?.title,
|
||||
defaultListingPublishAgreements.seller_agreement.title
|
||||
),
|
||||
content: readText(
|
||||
parsed.seller_agreement?.content,
|
||||
defaultListingPublishAgreements.seller_agreement.content
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -343,8 +383,12 @@ function formatConfigValue(row: SystemConfig) {
|
||||
<span>管理首页公告滚动内容和顶部轮播图,保存后移动端会从接口读取最新配置。</span>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)">编辑公告</el-button>
|
||||
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)">编辑轮播图</el-button>
|
||||
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)"
|
||||
>编辑公告</el-button
|
||||
>
|
||||
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)"
|
||||
>编辑轮播图</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="publish-stat-grid home-stat-grid">
|
||||
@@ -378,7 +422,9 @@ function formatConfigValue(row: SystemConfig) {
|
||||
<h2>发布协议配置</h2>
|
||||
<span>管理发布账号前必须勾选确认的虚拟资产出售协议和号主协议。</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)">编辑发布协议</el-button>
|
||||
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)"
|
||||
>编辑发布协议</el-button
|
||||
>
|
||||
</div>
|
||||
<div class="publish-stat-grid home-stat-grid">
|
||||
<div class="publish-stat">
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/features/admin/api/adminUsers'
|
||||
import {
|
||||
fetchAdminUsers,
|
||||
freezeAdminUser,
|
||||
unfreezeAdminUser,
|
||||
type AdminUserItem,
|
||||
} from '@/features/admin/api/adminUsers'
|
||||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
||||
import { userStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
@@ -12,7 +17,15 @@ const submitting = ref(false)
|
||||
const activeUser = ref<AdminUserItem | null>(null)
|
||||
const freezeReason = ref('')
|
||||
|
||||
const { loading, data: users, total, currentPage, currentPageSize, load: loadUsers, handleSizeChange } = useAdminPaginatedTable<AdminUserItem>({
|
||||
const {
|
||||
loading,
|
||||
data: users,
|
||||
total,
|
||||
currentPage,
|
||||
currentPageSize,
|
||||
load: loadUsers,
|
||||
handleSizeChange,
|
||||
} = useAdminPaginatedTable<AdminUserItem>({
|
||||
fetchFn: fetchAdminUsers,
|
||||
})
|
||||
|
||||
@@ -81,10 +94,23 @@ function readError(error: unknown, fallback: string) {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'active'" size="small" type="danger" :loading="submitting" @click="openFreeze(row)">
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
size="small"
|
||||
type="danger"
|
||||
:loading="submitting"
|
||||
@click="openFreeze(row)"
|
||||
>
|
||||
冻结
|
||||
</el-button>
|
||||
<el-button v-else size="small" type="primary" :loading="submitting" @click="handleUnfreeze(row)">解冻</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleUnfreeze(row)"
|
||||
>解冻</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -98,10 +124,22 @@ function readError(error: unknown, fallback: string) {
|
||||
@page-change="loadUsers"
|
||||
/>
|
||||
|
||||
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
|
||||
<el-dialog
|
||||
:model-value="!!activeUser"
|
||||
title="冻结用户"
|
||||
width="560px"
|
||||
@update:model-value="activeUser = null"
|
||||
>
|
||||
<div v-if="activeUser" class="dialog-body">
|
||||
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
|
||||
<el-input v-model="freezeReason" type="textarea" :rows="4" placeholder="填写冻结原因,便于审计追踪" />
|
||||
<p>
|
||||
<strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}
|
||||
</p>
|
||||
<el-input
|
||||
v-model="freezeReason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="填写冻结原因,便于审计追踪"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="activeUser = null">取消</el-button>
|
||||
|
||||
@@ -19,10 +19,14 @@ const filters = reactive({
|
||||
})
|
||||
|
||||
const inAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.direction === 'in').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
ledger.value
|
||||
.filter(item => item.direction === 'in')
|
||||
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
|
||||
)
|
||||
const outAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
ledger.value
|
||||
.filter(item => item.direction === 'out')
|
||||
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
|
||||
)
|
||||
|
||||
onMounted(loadLedger)
|
||||
@@ -89,7 +93,9 @@ function directionLabel(direction: string) {
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger"
|
||||
>查询</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +140,9 @@ function directionLabel(direction: string) {
|
||||
</el-table-column>
|
||||
<el-table-column label="订单" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
|
||||
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{
|
||||
row.order_no || row.order_id
|
||||
}}</RouterLink>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -104,7 +104,7 @@ async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '输入打款备注...',
|
||||
inputValidator: (value) => {
|
||||
inputValidator: value => {
|
||||
return value && value.trim().length > 0
|
||||
},
|
||||
inputErrorMessage: '请输入打款备注',
|
||||
@@ -202,19 +202,13 @@ function onDetailDialogSaved() {
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleFilter">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleFilter"> 查询 </el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 提现列表 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
class="table-panel"
|
||||
:data="withdrawals"
|
||||
>
|
||||
<el-table v-loading="loading" class="table-panel" :data="withdrawals">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" />
|
||||
<el-table-column label="用户" min-width="140">
|
||||
@@ -225,9 +219,7 @@ function onDetailDialogSaved() {
|
||||
</el-table-column>
|
||||
<el-table-column label="提现金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #f56c6c; font-weight: 600">
|
||||
¥{{ row.amount.toFixed(2) }}
|
||||
</span>
|
||||
<span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收款方式" min-width="180">
|
||||
@@ -257,9 +249,7 @@ function onDetailDialogSaved() {
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button size="small" @click="openDetail(row)"> 详情 </el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'pending'"
|
||||
size="small"
|
||||
|
||||
Reference in New Issue
Block a user