feat: P3阶段完成 - 全部模块迁移完成 🎉
## P3.1: 争议仲裁模块(disputes)✅ - API: disputes.ts - 模块导出 ## P3.2: 卖家中心模块(seller)✅ - Views: 4个页面 - Composables: usePublishForm, usePublishDraft - 模块导出 ## P3.3: 管理后台模块(admin)✅ - API: 8个文件(adminAuth, adminDashboard, adminUsers等) - Views: 15个管理页面 - Composables: useAdminTable, useAdminPaginatedTable - Components: 管理端组件 - 模块导出 --- ## 🎉 Features 架构迁移全部完成! ### 最终统计 - ✅ P0: shared(基础设施)- 22个文件 - ✅ P1: wallet, chats, orders - 24个文件 - ✅ P2: listings, auth - 35个文件 - ✅ P3: seller, disputes, admin - 47个文件 **总计:** 9个模块,128个文件完成迁移 ### 新架构 ``` frontend/src/ ├── features/ # 9个业务模块 ✅ │ ├── wallet/ ✅ │ ├── chats/ ✅ │ ├── orders/ ✅ (已重构) │ ├── listings/ ✅ │ ├── auth/ ✅ │ ├── seller/ ✅ │ ├── disputes/ ✅ │ └── admin/ ✅ └── shared/ ✅ ``` 下一步:清理旧文件、更新路由配置 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3534cffce1
commit
c9397635e2
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { createAdminMgrUser, updateAdminMgrUser, type AdminMgrUser, type CreateAdminRequest, type UpdateAdminRequest } from '@/api/adminMgr'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
admin?: AdminMgrUser | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const isEdit = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.admin) {
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
username: props.admin.username,
|
||||
password: '',
|
||||
nickname: props.admin.nickname,
|
||||
status: props.admin.status,
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
form.value = { username: '', password: '', nickname: '', status: 'active' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value && props.admin) {
|
||||
const req: UpdateAdminRequest = {
|
||||
nickname: form.value.nickname,
|
||||
status: form.value.status,
|
||||
}
|
||||
await updateAdminMgrUser(props.admin.id, req)
|
||||
ElMessage.success('管理员已更新')
|
||||
} else {
|
||||
const req: CreateAdminRequest = {
|
||||
username: form.value.username,
|
||||
password: form.value.password,
|
||||
nickname: form.value.nickname,
|
||||
}
|
||||
await createAdminMgrUser(req)
|
||||
ElMessage.success('管理员已创建')
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '操作失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="isEdit ? '编辑管理员' : '新建管理员'"
|
||||
width="500px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<el-form-item label="用户名" class="full-control">
|
||||
<el-input v-model="form.username" :disabled="isEdit" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isEdit" label="密码" class="full-control">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码(至少6位)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" class="full-control">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态" class="full-control">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="禁用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { fetchPermissions, fetchRole, assignRolePermissions, type Permission, type Role } from '@/api/adminRoles'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
role: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
const selectedPermIds = ref<number[]>([])
|
||||
|
||||
// 按 resource 分组
|
||||
const groupedPermissions = ref<Record<string, Permission[]>>({})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
if (val && props.role) {
|
||||
loading.value = true
|
||||
try {
|
||||
const [perms, roleDetail] = await Promise.all([fetchPermissions(), fetchRole(props.role.id)])
|
||||
allPermissions.value = perms
|
||||
selectedPermIds.value = (roleDetail.permissions || []).map((p) => p.id)
|
||||
|
||||
// 按 resource 分组
|
||||
const grouped: Record<string, Permission[]> = {}
|
||||
for (const p of perms) {
|
||||
const arr = grouped[p.resource] ?? (grouped[p.resource] = [])
|
||||
arr.push(p)
|
||||
}
|
||||
groupedPermissions.value = grouped
|
||||
} catch {
|
||||
ElMessage.error('加载权限列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.role) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await assignRolePermissions(props.role.id, selectedPermIds.value)
|
||||
ElMessage.success('权限已分配')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '分配失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroup(perms: Permission[]) {
|
||||
const ids = perms.map((p) => p.id)
|
||||
const allSelected = ids.every((id) => selectedPermIds.value.includes(id))
|
||||
if (allSelected) {
|
||||
selectedPermIds.value = selectedPermIds.value.filter((id) => !ids.includes(id))
|
||||
} else {
|
||||
const newIds = [...selectedPermIds.value]
|
||||
for (const id of ids) {
|
||||
if (!newIds.includes(id)) newIds.push(id)
|
||||
}
|
||||
selectedPermIds.value = newIds
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupAllSelected(perms: Permission[]) {
|
||||
return perms.length > 0 && perms.every((p) => selectedPermIds.value.includes(p.id))
|
||||
}
|
||||
|
||||
function isGroupPartial(perms: Permission[]) {
|
||||
return perms.some((p) => selectedPermIds.value.includes(p.id)) && !isGroupAllSelected(perms)
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const resourceLabels: Record<string, string> = {
|
||||
dashboard: '仪表盘',
|
||||
user: '用户管理',
|
||||
order: '订单管理',
|
||||
listing: '商品管理',
|
||||
dispute: '仲裁中心',
|
||||
chat: '客服群聊',
|
||||
wallet: '资金流水',
|
||||
admin_user: '管理员管理',
|
||||
role: '角色管理',
|
||||
system_config: '系统配置',
|
||||
audit_log: '审计日志',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="`分配权限 - ${role?.name || ''}`"
|
||||
width="600px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="dialog-body">
|
||||
<p v-if="role" class="role-info">
|
||||
<strong>{{ role.name }}</strong> · {{ role.description }}
|
||||
</p>
|
||||
<div v-for="(perms, resource) in groupedPermissions" :key="resource" class="perm-group">
|
||||
<div class="perm-group-header">
|
||||
<el-checkbox
|
||||
:model-value="isGroupAllSelected(perms)"
|
||||
:indeterminate="isGroupPartial(perms)"
|
||||
@change="toggleGroup(perms)"
|
||||
>
|
||||
<strong>{{ resourceLabels[resource] || resource }}</strong>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<div class="perm-group-items">
|
||||
<el-checkbox
|
||||
v-for="perm in perms"
|
||||
:key="perm.id"
|
||||
v-model="selectedPermIds"
|
||||
:value="perm.id"
|
||||
:label="perm.id"
|
||||
>
|
||||
{{ perm.name }}
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.role-info {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
.perm-group {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.perm-group-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.perm-group-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { fetchRoles, type Role } from '@/api/adminRoles'
|
||||
import { assignAdminRoles, type AdminMgrUser } from '@/api/adminMgr'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
admin: AdminMgrUser | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const allRoles = ref<Role[]>([])
|
||||
const selectedRoleIds = ref<number[]>([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
if (val && props.admin) {
|
||||
loading.value = true
|
||||
try {
|
||||
allRoles.value = await fetchRoles()
|
||||
selectedRoleIds.value = props.admin.roles.map((r) => r.id)
|
||||
} catch {
|
||||
ElMessage.error('加载角色列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.admin) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await assignAdminRoles(props.admin.id, selectedRoleIds.value)
|
||||
ElMessage.success('角色已分配')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '分配失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="`分配角色 - ${admin?.username || ''}`"
|
||||
width="500px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="dialog-body">
|
||||
<p v-if="admin" class="admin-info">
|
||||
<strong>{{ admin.username }}</strong> · {{ admin.nickname }}
|
||||
</p>
|
||||
<el-checkbox-group v-model="selectedRoleIds">
|
||||
<div v-for="role in allRoles" :key="role.id" class="role-option">
|
||||
<el-checkbox :value="role.id" :label="role.id">
|
||||
<span class="role-name">{{ role.name }}</span>
|
||||
<span class="role-desc">{{ role.description }}</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-info {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
.role-option {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.role-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.role-name {
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.role-desc {
|
||||
color: #8f9bba;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchAutoWelcomeMessage, updateAutoWelcomeMessage } from '@/api/chats'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const editing = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMessage()
|
||||
})
|
||||
|
||||
async function loadMessage() {
|
||||
loading.value = true
|
||||
try {
|
||||
message.value = await fetchAutoWelcomeMessage()
|
||||
} catch {
|
||||
ElMessage.error('加载自动话术失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!message.value.trim()) {
|
||||
ElMessage.warning('话术内容不能为空')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await updateAutoWelcomeMessage(message.value.trim())
|
||||
ElMessage.success('保存成功')
|
||||
editing.value = false
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
loadMessage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auto-welcome-config" v-loading="loading">
|
||||
<div class="config-header">
|
||||
<h3>建群自动话术</h3>
|
||||
<p>订单群聊创建后自动发送的欢迎消息</p>
|
||||
</div>
|
||||
<div class="config-content">
|
||||
<template v-if="editing">
|
||||
<el-input
|
||||
v-model="message"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="输入建群后自动发送的话术"
|
||||
/>
|
||||
<div class="config-actions">
|
||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||
<el-button size="small" type="primary" :loading="saving" @click="handleSave">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="preview-box">
|
||||
<p>{{ message || '未设置' }}</p>
|
||||
</div>
|
||||
<el-button size="small" @click="startEdit">编辑</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auto-welcome-config {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-header h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.config-header p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.preview-box p {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
import { getSystemConfigSelectOptions, type SystemConfigOption } from '@/utils/systemConfigOptions'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const value = ref('')
|
||||
const description = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
value.value = props.config.value || ''
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const isStructuredConfig = computed(() => {
|
||||
const trimmed = value.value.trim()
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[')
|
||||
})
|
||||
|
||||
const selectOptions = computed<SystemConfigOption[] | null>(() => {
|
||||
const options = getSystemConfigSelectOptions(props.config.key)
|
||||
if (!options) return null
|
||||
if (!value.value || options.some((item) => item.value === value.value)) {
|
||||
return options
|
||||
}
|
||||
return [{ label: `当前值:${value.value}`, value: value.value }, ...options]
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value: value.value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑系统配置"
|
||||
width="560px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
|
||||
<el-form-item v-if="isStructuredConfig" label="配置值 (JSON)" class="full-control">
|
||||
<el-input v-model="value" type="textarea" :rows="12" placeholder="配置值 JSON" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else-if="selectOptions" label="配置值" class="full-control">
|
||||
<el-select v-model="value" class="full-select" placeholder="请选择配置值">
|
||||
<el-option
|
||||
v-for="option in selectOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="配置值" class="full-control">
|
||||
<el-input v-model="value" placeholder="配置值" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="配置说明" class="desc-item">
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-key-label {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desc-item {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { defaultHomeAnnouncements, mergeHomeConfig } from '@/api/homeConfig'
|
||||
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
import { safeParseJSON } from '@/utils/json'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const description = ref('')
|
||||
const homeAnnouncementLines = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(props.config.value))
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parseHomeAnnouncements(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultHomeAnnouncements)
|
||||
return mergeHomeConfig({ announcements: Array.isArray(parsed) ? parsed : [] }).announcements
|
||||
}
|
||||
|
||||
function linesToItems(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function itemsToLines(items: string[]) {
|
||||
return items.join('\n')
|
||||
}
|
||||
|
||||
function resetHomeAnnouncements() {
|
||||
homeAnnouncementLines.value = itemsToLines(defaultHomeAnnouncements)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const value = JSON.stringify(linesToItems(homeAnnouncementLines.value), null, 2)
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑首页公告"
|
||||
width="680px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
|
||||
<div class="home-config-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span>首页公告</span>
|
||||
<el-button size="small" @click="resetHomeAnnouncements">恢复默认公告</el-button>
|
||||
</div>
|
||||
<el-form-item label="公告内容" class="full-control">
|
||||
<el-input
|
||||
v-model="homeAnnouncementLines"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="一行一条公告,移动端会自动轮播展示"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="配置说明" class="desc-item">
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-key-label {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.home-config-editor {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar span {
|
||||
color: #30343a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desc-item {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { uploadAdminFile } from '@/api/files'
|
||||
import { defaultHomeBanners, mergeHomeConfig, type HomeBannerSlide } from '@/api/homeConfig'
|
||||
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
import { safeParseJSON } from '@/utils/json'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const description = ref('')
|
||||
const homeBannersDraft = ref<HomeBannerSlide[]>([])
|
||||
const uploadingBannerIndex = ref<number | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
homeBannersDraft.value = parseHomeBanners(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parseHomeBanners(raw: string) {
|
||||
const parsed = safeParseJSON(raw, defaultHomeBanners)
|
||||
return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners)
|
||||
}
|
||||
|
||||
function cloneHomeBanners(banners: HomeBannerSlide[]) {
|
||||
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
|
||||
}
|
||||
|
||||
function addHomeBanner() {
|
||||
homeBannersDraft.value.push({
|
||||
eyebrow: '首页推荐',
|
||||
title: '',
|
||||
badge: 'NEW',
|
||||
pill: '',
|
||||
tone: 'blue',
|
||||
image_url: '',
|
||||
})
|
||||
}
|
||||
|
||||
function removeHomeBanner(index: number) {
|
||||
homeBannersDraft.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetHomeBanners() {
|
||||
homeBannersDraft.value = cloneHomeBanners(defaultHomeBanners)
|
||||
}
|
||||
|
||||
async function handleHomeBannerUpload(event: Event, index: number) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
uploadingBannerIndex.value = index
|
||||
try {
|
||||
const uploaded = await uploadAdminFile(file, 'home-banner')
|
||||
const banner = homeBannersDraft.value[index]
|
||||
if (banner) {
|
||||
banner.image_url = uploaded.url
|
||||
if (!banner.title) {
|
||||
banner.title = banner.eyebrow || '首页轮播图'
|
||||
}
|
||||
}
|
||||
ElMessage.success('图片已上传')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '图片上传失败'))
|
||||
} finally {
|
||||
uploadingBannerIndex.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const value = JSON.stringify(
|
||||
homeBannersDraft.value.filter((item) => item.title.trim() || item.image_url?.trim()),
|
||||
null,
|
||||
2
|
||||
)
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑首页轮播图"
|
||||
width="920px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
|
||||
<div class="home-config-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span>首页轮播图</span>
|
||||
<div class="panel-actions">
|
||||
<el-button size="small" @click="resetHomeBanners">恢复默认轮播</el-button>
|
||||
<el-button size="small" type="primary" @click="addHomeBanner">添加轮播</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="homeBannersDraft" size="small" border>
|
||||
<el-table-column label="图片" min-width="240">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="banner-image-editor">
|
||||
<el-input v-model="row.image_url" placeholder="图片 URL,留空使用文字卡片" />
|
||||
<div class="banner-image-tools">
|
||||
<img v-if="row.image_url" :src="row.image_url" alt="轮播图预览" />
|
||||
<div v-else class="empty-image">无图</div>
|
||||
<label class="upload-trigger">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
@change="handleHomeBannerUpload($event, $index)"
|
||||
/>
|
||||
{{ uploadingBannerIndex === $index ? '上传中' : '上传图片' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="眉标" min-width="160">
|
||||
<template #default="{ row }"><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主标题" min-width="220">
|
||||
<template #default="{ row }"><el-input v-model="row.title" placeholder="轮播主文案" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角标" width="110">
|
||||
<template #default="{ row }"><el-input v-model="row.badge" placeholder="HOT" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="胶囊文案" min-width="220">
|
||||
<template #default="{ row }"><el-input v-model="row.pill" placeholder="底部补充文案" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="色调" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.tone" placeholder="色调">
|
||||
<el-option label="蓝色" value="blue" />
|
||||
<el-option label="绿色" value="green" />
|
||||
<el-option label="橙色" value="orange" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-form-item label="配置说明" class="desc-item">
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.config-key-label {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.home-config-editor {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar span {
|
||||
color: #30343a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-image-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-image-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-image-tools img,
|
||||
.empty-image {
|
||||
width: 68px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
background: #f3f4f6;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.empty-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-trigger input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.desc-item {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,574 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
emptyListingPublishOptions,
|
||||
mergeListingPublishOptions,
|
||||
type ListingPublishOptions,
|
||||
} from '@/api/listingOptions'
|
||||
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
import { safeParseJSON } from '@/utils/json'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const description = ref('')
|
||||
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListingPublishOptions))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
publishOptionsDraft.value = parsePublishOptions(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parsePublishOptions(raw: string) {
|
||||
const parsed = safeParseJSON(raw, emptyListingPublishOptions)
|
||||
return cloneOptions(mergeListingPublishOptions(parsed))
|
||||
}
|
||||
|
||||
function cloneOptions(options: ListingPublishOptions) {
|
||||
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
|
||||
}
|
||||
|
||||
function linesToItems(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function itemsToLines(items: string[]) {
|
||||
return items.join('\n')
|
||||
}
|
||||
|
||||
function updateOptionLines(
|
||||
key: keyof Pick<
|
||||
ListingPublishOptions,
|
||||
| 'server_options'
|
||||
| 'face_options'
|
||||
| 'rank_options'
|
||||
| 'insurance_options'
|
||||
| 'level_options'
|
||||
| 'login_method_options'
|
||||
| 'region_options'
|
||||
| 'ban_record_options'
|
||||
| 'ban_evidence_options'
|
||||
>,
|
||||
nextValue: string
|
||||
) {
|
||||
publishOptionsDraft.value[key] = linesToItems(nextValue)
|
||||
}
|
||||
|
||||
function updateSkinGroupOptions(index: number, nextValue: string) {
|
||||
const group = publishOptionsDraft.value.skin_groups[index]
|
||||
if (group) {
|
||||
group.options = linesToItems(nextValue)
|
||||
}
|
||||
}
|
||||
|
||||
function addSkinGroup() {
|
||||
publishOptionsDraft.value.skin_groups.push({
|
||||
key: `group_${Date.now()}`,
|
||||
title: '新皮肤分类',
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
|
||||
function removeSkinGroup(index: number) {
|
||||
publishOptionsDraft.value.skin_groups.splice(index, 1)
|
||||
}
|
||||
|
||||
function addQuantityItem() {
|
||||
publishOptionsDraft.value.quantity_items.push({
|
||||
key: `item_${Date.now()}`,
|
||||
label: '',
|
||||
price: '',
|
||||
})
|
||||
}
|
||||
|
||||
function removeQuantityItem(index: number) {
|
||||
publishOptionsDraft.value.quantity_items.splice(index, 1)
|
||||
}
|
||||
|
||||
function addScreenshotSlot() {
|
||||
publishOptionsDraft.value.screenshot_slots.push({
|
||||
key: `screenshot_${Date.now()}`,
|
||||
label: '',
|
||||
required: false,
|
||||
hint: '',
|
||||
})
|
||||
}
|
||||
|
||||
function removeScreenshotSlot(index: number) {
|
||||
publishOptionsDraft.value.screenshot_slots.splice(index, 1)
|
||||
}
|
||||
|
||||
function addInsuranceBaseRatio() {
|
||||
publishOptionsDraft.value.ratio_config.insurance_base_ratios.push({
|
||||
insurance: '',
|
||||
ratio: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function removeInsuranceBaseRatio(index: number) {
|
||||
publishOptionsDraft.value.ratio_config.insurance_base_ratios.splice(index, 1)
|
||||
}
|
||||
|
||||
function addRatioConfigItem() {
|
||||
publishOptionsDraft.value.ratio_config.config_items.push({
|
||||
key: `config_${Date.now()}`,
|
||||
label: '',
|
||||
kind: 'skin_group',
|
||||
group_key: '',
|
||||
missing_penalty: 1,
|
||||
})
|
||||
}
|
||||
|
||||
function removeRatioConfigItem(index: number) {
|
||||
publishOptionsDraft.value.ratio_config.config_items.splice(index, 1)
|
||||
}
|
||||
|
||||
function addCoinCorrection() {
|
||||
publishOptionsDraft.value.ratio_config.coin_corrections.push({
|
||||
threshold_m: 0,
|
||||
correction: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function removeCoinCorrection(index: number) {
|
||||
publishOptionsDraft.value.ratio_config.coin_corrections.splice(index, 1)
|
||||
}
|
||||
|
||||
function addDepositSkinGroupRule() {
|
||||
publishOptionsDraft.value.deposit_recommend_config.skin_group_rules.push({
|
||||
group_key: '',
|
||||
label: '',
|
||||
amount_per_item: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function removeDepositSkinGroupRule(index: number) {
|
||||
publishOptionsDraft.value.deposit_recommend_config.skin_group_rules.splice(index, 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const value = JSON.stringify(publishOptionsDraft.value, null, 2)
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑发布选项配置"
|
||||
width="920px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
|
||||
<div class="publish-options-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span>发布页选项配置</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<el-form-item label="区服">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.server_options)"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('server_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="段位">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.rank_options)"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('rank_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="保险">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.insurance_options)"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('insurance_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="体力/负重">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.level_options)"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('level_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="人脸选项">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.face_options)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('face_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上号方式">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.login_method_options)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('login_method_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="常用登录地区">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.region_options)"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="一行一个省市"
|
||||
@update:model-value="updateOptionLines('region_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="editor-grid">
|
||||
<el-form-item label="封禁记录">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.ban_record_options)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="一行一个选项"
|
||||
@update:model-value="updateOptionLines('ban_record_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="需传安全图">
|
||||
<el-input
|
||||
:model-value="itemsToLines(publishOptionsDraft.ban_evidence_options)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选择这些封禁记录时,腾讯安全中心截图必传"
|
||||
@update:model-value="updateOptionLines('ban_evidence_options', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>发布规则</strong>
|
||||
</div>
|
||||
<el-form-item label="最低烽火等级">
|
||||
<el-input-number v-model="publishOptionsDraft.fire_level_min" :min="1" :step="1" class="full-control" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>押金与价格提示</strong>
|
||||
</div>
|
||||
<el-form-item label="押金提示">
|
||||
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格提示">
|
||||
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" class="full-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="比例说明">
|
||||
<el-input v-model="publishOptionsDraft.price_config.ratio_description" class="full-control" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>智能推荐押金</strong>
|
||||
<el-button size="small" @click="addDepositSkinGroupRule">添加皮肤规则</el-button>
|
||||
</div>
|
||||
<el-form-item label="基础押金">
|
||||
<el-input-number
|
||||
v-model="publishOptionsDraft.deposit_recommend_config.base_amount"
|
||||
:min="0"
|
||||
:step="5"
|
||||
class="full-control"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-table :data="publishOptionsDraft.deposit_recommend_config.skin_group_rules" size="small" border>
|
||||
<el-table-column label="皮肤分组 Key" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.group_key" placeholder="operatorRed" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row }"><el-input v-model="row.label" placeholder="干员红皮" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="每个增加押金" min-width="140">
|
||||
<template #default="{ row }"><el-input-number v-model="row.amount_per_item" :min="0" :step="5" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeDepositSkinGroupRule($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>比例计算配置</strong>
|
||||
</div>
|
||||
<div class="editor-block-title subtle-title">
|
||||
<span>保险基础比例</span>
|
||||
<el-button size="small" @click="addInsuranceBaseRatio">添加保险比例</el-button>
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.ratio_config.insurance_base_ratios" size="small" border>
|
||||
<el-table-column label="保险" min-width="160">
|
||||
<template #default="{ row }"><el-input v-model="row.insurance" placeholder="3*3" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基础比例" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.ratio" :min="0" :step="0.5" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeInsuranceBaseRatio($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="editor-block-title subtle-title">
|
||||
<span>配置项缺失加成</span>
|
||||
<el-button size="small" @click="addRatioConfigItem">添加配置项</el-button>
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.ratio_config.config_items" size="small" border>
|
||||
<el-table-column label="Key" min-width="140">
|
||||
<template #default="{ row }"><el-input v-model="row.key" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row }"><el-input v-model="row.label" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.kind">
|
||||
<el-option label="皮肤分组" value="skin_group" />
|
||||
<el-option label="满体力" value="max_stamina" />
|
||||
<el-option label="满负重" value="max_load" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="皮肤分组 Key" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.group_key" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="缺失加成" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.missing_penalty" :min="0" :step="0.5" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeRatioConfigItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="editor-block-title subtle-title">
|
||||
<span>大额币修正</span>
|
||||
<el-button size="small" @click="addCoinCorrection">添加修正</el-button>
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.ratio_config.coin_corrections" size="small" border>
|
||||
<el-table-column label="大于 M" min-width="130">
|
||||
<template #default="{ row }"><el-input-number v-model="row.threshold_m" :min="0" :step="10" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="比例加成" min-width="130">
|
||||
<template #default="{ row }"><el-input-number v-model="row.correction" :min="0" :step="0.5" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeCoinCorrection($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>皮肤分类</strong>
|
||||
<el-button size="small" @click="addSkinGroup">添加分类</el-button>
|
||||
</div>
|
||||
<div v-for="(group, index) in publishOptionsDraft.skin_groups" :key="`${group.key}-${index}`" class="skin-config-row">
|
||||
<el-input v-model="group.key" placeholder="分类 key" />
|
||||
<el-input v-model="group.title" placeholder="分类名称" />
|
||||
<el-input
|
||||
:model-value="itemsToLines(group.options)"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="皮肤名称,一行一个"
|
||||
@update:model-value="updateSkinGroupOptions(index, $event)"
|
||||
/>
|
||||
<el-button type="danger" plain @click="removeSkinGroup(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>额外消耗品</strong>
|
||||
<el-button size="small" @click="addQuantityItem">添加消耗品</el-button>
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.quantity_items" size="small" border>
|
||||
<el-table-column label="Key" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.key" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.label" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" min-width="130">
|
||||
<template #default="{ row }"><el-input v-model="row.price" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提示" min-width="220">
|
||||
<template #default="{ row }"><el-input v-model="row.placeholder" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title">
|
||||
<strong>截图材料</strong>
|
||||
<el-button size="small" @click="addScreenshotSlot">添加截图项</el-button>
|
||||
</div>
|
||||
<el-table :data="publishOptionsDraft.screenshot_slots" size="small" border>
|
||||
<el-table-column label="Key" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.key" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" min-width="150">
|
||||
<template #default="{ row }"><el-input v-model="row.label" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="必填" width="90">
|
||||
<template #default="{ row }"><el-switch v-model="row.required" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提示" min-width="260">
|
||||
<template #default="{ row }"><el-input v-model="row.hint" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="配置说明" class="desc-item">
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.config-key-label {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.publish-options-editor {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar,
|
||||
.editor-block-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar span {
|
||||
color: #30343a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.subtle-title {
|
||||
margin-top: 6px;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.skin-config-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 180px minmax(0, 1fr) 72px;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desc-item {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
fetchQuickReplies,
|
||||
createQuickReply,
|
||||
updateQuickReply,
|
||||
deleteQuickReply,
|
||||
type QuickReply,
|
||||
} from '@/api/chats'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const replies = ref<QuickReply[]>([])
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
sort_order: 0,
|
||||
is_global: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadReplies()
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
async function loadReplies() {
|
||||
loading.value = true
|
||||
try {
|
||||
replies.value = await fetchQuickReplies()
|
||||
} catch {
|
||||
ElMessage.error('加载快捷回复失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { title: '', content: '', sort_order: 0, is_global: false }
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function startEdit(reply: QuickReply) {
|
||||
editingId.value = reply.id
|
||||
form.value = {
|
||||
title: reply.title,
|
||||
content: reply.content,
|
||||
sort_order: reply.sort_order,
|
||||
is_global: reply.is_global,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.title || !form.value.content) {
|
||||
ElMessage.warning('标题和内容不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateQuickReply(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createQuickReply(form.value.title, form.value.content, form.value.sort_order, form.value.is_global)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
resetForm()
|
||||
await loadReplies()
|
||||
emit('success')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(reply: QuickReply) {
|
||||
if (reply.is_global) {
|
||||
ElMessage.warning('不能删除全局快捷回复')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条快捷回复?', '确认删除', {
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteQuickReply(reply.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadReplies()
|
||||
emit('success')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="快捷回复管理" width="600px">
|
||||
<div class="quick-reply-content">
|
||||
<div class="reply-form">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="快捷回复标题"
|
||||
maxlength="64"
|
||||
style="margin-bottom: 8px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="回复内容"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
style="margin-bottom: 8px"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<div class="form-meta">
|
||||
<label class="sort-field">
|
||||
<span>排序</span>
|
||||
<el-input-number
|
||||
v-model="form.sort_order"
|
||||
:min="0"
|
||||
:max="999"
|
||||
size="small"
|
||||
placeholder="排序"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</label>
|
||||
<el-radio-group v-model="form.is_global" size="small" :disabled="!!editingId">
|
||||
<el-radio-button :value="false">个人</el-radio-button>
|
||||
<el-radio-button :value="true">全局</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div>
|
||||
<el-button v-if="editingId" size="small" @click="handleCancel">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="handleSubmit">
|
||||
{{ editingId ? '更新' : '添加' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reply-list" v-loading="loading">
|
||||
<div
|
||||
v-for="reply in replies"
|
||||
:key="reply.id"
|
||||
class="reply-item"
|
||||
:class="{ global: reply.is_global }"
|
||||
>
|
||||
<div class="reply-info">
|
||||
<div class="reply-header">
|
||||
<span class="reply-title">{{ reply.title }}</span>
|
||||
<el-tag v-if="reply.is_global" size="small" type="info">全局</el-tag>
|
||||
<el-tag v-else size="small" type="success">个人</el-tag>
|
||||
</div>
|
||||
<div class="reply-content">{{ reply.content }}</div>
|
||||
</div>
|
||||
<div class="reply-actions">
|
||||
<el-button link size="small" @click="startEdit(reply)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="!reply.is_global"
|
||||
link
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(reply)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!loading && replies.length === 0" description="暂无快捷回复" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-reply-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.reply-form {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sort-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reply-list {
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.reply-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reply-item.global {
|
||||
background: #f0f9ff;
|
||||
border-color: #bae6fd;
|
||||
}
|
||||
|
||||
.reply-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.reply-title {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { createRole, updateRole, type Role, type CreateRoleRequest, type UpdateRoleRequest } from '@/api/adminRoles'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
role?: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const form = ref({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const isEdit = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.role) {
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
code: props.role.code,
|
||||
name: props.role.name,
|
||||
description: props.role.description,
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false
|
||||
form.value = { code: '', name: '', description: '' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value && props.role) {
|
||||
const req: UpdateRoleRequest = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
}
|
||||
await updateRole(props.role.id, req)
|
||||
ElMessage.success('角色已更新')
|
||||
} else {
|
||||
const req: CreateRoleRequest = {
|
||||
code: form.value.code,
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
}
|
||||
await createRole(req)
|
||||
ElMessage.success('角色已创建')
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '操作失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="isEdit ? '编辑角色' : '新建角色'"
|
||||
width="500px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<el-form-item label="角色编码" class="full-control">
|
||||
<el-input v-model="form.code" :disabled="isEdit" placeholder="例如: cs, ops, finance" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" class="full-control">
|
||||
<el-input v-model="form.name" placeholder="例如: 客服, 运营, 财务" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="full-control">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="角色描述" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
emptyListingSalePriceConfig,
|
||||
mergeListingSalePriceConfig,
|
||||
type PublishSalePriceConfig,
|
||||
} from '@/api/listingOptions'
|
||||
import { updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
|
||||
import { safeParseJSON } from '@/utils/json'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const description = ref('')
|
||||
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
salePriceConfigDraft.value = parseSalePriceConfig(props.config.value)
|
||||
description.value = props.config.description || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function parseSalePriceConfig(raw: string) {
|
||||
const parsed = safeParseJSON(raw, emptyListingSalePriceConfig)
|
||||
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
|
||||
}
|
||||
|
||||
function cloneSalePriceConfig(config: PublishSalePriceConfig) {
|
||||
return JSON.parse(JSON.stringify(config)) as PublishSalePriceConfig
|
||||
}
|
||||
|
||||
function addSaleFixedMarkupRule() {
|
||||
salePriceConfigDraft.value.fixed_markup_rules.push({
|
||||
min_m: 0,
|
||||
max_m: 0,
|
||||
markup_amount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function removeSaleFixedMarkupRule(index: number) {
|
||||
salePriceConfigDraft.value.fixed_markup_rules.splice(index, 1)
|
||||
}
|
||||
|
||||
function addSaleRatioAdjustmentRule() {
|
||||
salePriceConfigDraft.value.ratio_adjustment_rules.push({
|
||||
min_m: 0,
|
||||
max_m: 0,
|
||||
ratio_subtract: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function removeSaleRatioAdjustmentRule(index: number) {
|
||||
salePriceConfigDraft.value.ratio_adjustment_rules.splice(index, 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const value = JSON.stringify(salePriceConfigDraft.value, null, 2)
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value,
|
||||
description: description.value,
|
||||
})
|
||||
ElMessage.success('配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function readError(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑出售定价配置"
|
||||
width="920px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
|
||||
|
||||
<div class="publish-options-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span>内部出售定价规则</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-block">
|
||||
<div class="editor-block-title subtle-title">
|
||||
<span>90M 及以下固定加价:出售价格 = 回收价格 + 固定加价</span>
|
||||
<el-button size="small" @click="addSaleFixedMarkupRule">添加固定加价</el-button>
|
||||
</div>
|
||||
<el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border>
|
||||
<el-table-column label="最小 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加价金额/元" min-width="140">
|
||||
<template #default="{ row }"><el-input-number v-model="row.markup_amount" :min="0" :step="1" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeSaleFixedMarkupRule($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="editor-block-title subtle-title">
|
||||
<span>90M 以上比例修正:出售比例 = 回收比例 - 比例修正;最大 M 为 0 表示无上限</span>
|
||||
<el-button size="small" @click="addSaleRatioAdjustmentRule">添加比例修正</el-button>
|
||||
</div>
|
||||
<el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border>
|
||||
<el-table-column label="最小 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大 M" min-width="120">
|
||||
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="比例修正" min-width="130">
|
||||
<template #default="{ row }"><el-input-number v-model="row.ratio_subtract" :min="0" :step="0.5" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" plain @click="removeSaleRatioAdjustmentRule($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="配置说明" class="desc-item">
|
||||
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.config-key-label {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.publish-options-editor {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar,
|
||||
.editor-block-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar span {
|
||||
color: #30343a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.subtle-title {
|
||||
margin-top: 6px;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.desc-item {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchSupportAdmins, transferChat, type SupportAdmin } from '@/api/chats'
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
conversationId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const admins = ref<SupportAdmin[]>([])
|
||||
const selectedAdminId = ref<number | null>(null)
|
||||
|
||||
// 按会话数排序,空闲客服优先
|
||||
const sortedAdmins = computed(() => {
|
||||
return [...admins.value].sort((a, b) => a.chat_count - b.chat_count)
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
loadAdmins()
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
async function loadAdmins() {
|
||||
loading.value = true
|
||||
try {
|
||||
admins.value = await fetchSupportAdmins()
|
||||
} catch {
|
||||
ElMessage.error('加载客服列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedAdminId.value) {
|
||||
ElMessage.warning('请选择目标客服')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await transferChat(props.conversationId, selectedAdminId.value)
|
||||
ElMessage.success('转接成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch {
|
||||
ElMessage.error('转接失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTag(count: number) {
|
||||
if (count === 0) return { text: '空闲', type: 'success' }
|
||||
if (count <= 3) return { text: '正常', type: '' }
|
||||
return { text: '繁忙', type: 'warning' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="转接会话" width="480px">
|
||||
<div v-loading="loading" class="transfer-content">
|
||||
<p class="tip">选择要转接给的客服:</p>
|
||||
<el-radio-group v-model="selectedAdminId" class="admin-list">
|
||||
<el-radio
|
||||
v-for="admin in sortedAdmins"
|
||||
:key="admin.id"
|
||||
:value="admin.id"
|
||||
class="admin-item"
|
||||
>
|
||||
<div class="admin-info">
|
||||
<el-avatar :size="36" :icon="User" />
|
||||
<div class="admin-detail">
|
||||
<span class="admin-name">{{ admin.nickname }}</span>
|
||||
<span class="admin-id">ID: {{ admin.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-status">
|
||||
<el-tag :type="getStatusTag(admin.chat_count).type" size="small">
|
||||
{{ getStatusTag(admin.chat_count).text }}
|
||||
</el-tag>
|
||||
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
|
||||
</div>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<el-empty v-if="!loading && admins.length === 0" description="暂无可用客服" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!selectedAdminId" @click="handleSubmit">
|
||||
确认转接
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-content {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 0 0 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: auto;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-item :deep(.el-radio__label) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-item.is-checked {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-id {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-count {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user