323 lines
13 KiB
Vue
323 lines
13 KiB
Vue
<template>
|
|
<div class="page">
|
|
<div class="page-body">
|
|
<div class="search-bar">
|
|
<el-input v-model="filters.keyword" placeholder="名称/账号" clearable style="width:160px" />
|
|
<el-input v-model="filters.phone" placeholder="手机号" clearable style="width:140px" />
|
|
<el-cascader v-model="filters.cascaderRegion" :options="cascadeOptions" style="width:200px" placeholder="所在地区" clearable @change="onFilterCascaderChange" @click="lazyLoadCascades" />
|
|
<el-date-picker v-model="filters.expiryRange" type="daterange" range-separator="至" start-placeholder="到期开始" end-placeholder="到期结束" value-format="YYYY-MM-DD" style="width:220px" />
|
|
<el-button type="primary" @click="doSearch">搜索</el-button>
|
|
<el-button @click="resetSearch">重置</el-button>
|
|
<span class="search-spacer"></span>
|
|
<button class="btn-primary" @click="showCreate = true; lazyLoadCascades()">新建代理商</button>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<el-table :data="list" border stripe v-loading="loading" height="100%">
|
|
<el-table-column prop="id" label="ID" width="60" />
|
|
<el-table-column prop="username" label="账号" width="140" />
|
|
<el-table-column prop="phone" label="手机号" width="130" />
|
|
<el-table-column prop="name" label="名称" />
|
|
<el-table-column label="受保护" width="80" align="center">
|
|
<template #default="{ row }">{{ row.region_protected ? '是' : '否' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="省份" width="120">
|
|
<template #default="{ row }">{{ row.province || '-' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="区域" width="120">
|
|
<template #default="{ row }">{{ row.region || '-' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="客户上限" width="100" align="right">
|
|
<template #default="{ row }">{{ row.max_customers ?? '-' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="到期" width="170">
|
|
<template #default="{ row }">{{ row.expired_at || '-' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="280" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
|
<el-button size="small" @click="handleRenew(row)">续费</el-button>
|
|
<el-button size="small" @click="handleViewRenewals(row)">续费记录</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
<div v-if="total > 0" class="pagination-wrap">
|
|
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :page-sizes="[20, 50, 100]" layout="total, sizes, prev, pager, next" background @current-change="fetchData" @size-change="onSizeChange" />
|
|
</div>
|
|
</div>
|
|
|
|
<el-dialog v-model="showCreate" title="新建代理商" width="500px">
|
|
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="90px">
|
|
<el-form-item label="账号" prop="username"><el-input v-model="createForm.username" /></el-form-item>
|
|
<el-form-item label="密码" prop="password"><el-input v-model="createForm.password" type="password" /></el-form-item>
|
|
<el-form-item label="手机号" prop="phone"><el-input v-model="createForm.phone" /></el-form-item>
|
|
<el-form-item label="名称" prop="name"><el-input v-model="createForm.name" /></el-form-item>
|
|
<el-form-item label="区域" prop="region">
|
|
<el-cascader v-model="createForm.cascaderRegion" :options="cascadeOptions" style="width:100%" placeholder="请选择省/市" @change="onCascaderChange" />
|
|
</el-form-item>
|
|
<el-form-item label="地区保护">
|
|
<el-switch v-model="createForm.region_protected" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showCreate = false">取消</el-button>
|
|
<el-button type="primary" :loading="creating" @click="handleCreate">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="showEdit" title="编辑代理商" width="500px">
|
|
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="90px">
|
|
<el-form-item label="名称" prop="name"><el-input v-model="editForm.name" /></el-form-item>
|
|
<el-form-item label="手机号" prop="phone"><el-input v-model="editForm.phone" /></el-form-item>
|
|
<el-form-item label="区域" prop="region">
|
|
<el-cascader v-model="editForm.cascaderRegion" :options="cascadeOptions" style="width:100%" placeholder="请选择省/市" @change="onEditCascaderChange" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="showEdit = false">取消</el-button>
|
|
<el-button type="primary" :loading="updating" @click="handleUpdate">保存</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<PaymentDialog v-model="showRenew" mode="admin-renew" :agent-info="renewAgentInfo" @success="onRenewSuccess" />
|
|
|
|
<el-dialog v-model="showRenewals" title="续费记录" width="800px">
|
|
<el-table :data="renewalList" stripe v-loading="renewalLoading" max-height="460">
|
|
<el-table-column label="序号" width="60" type="index" />
|
|
<el-table-column prop="order_no" label="订单号" min-width="180" />
|
|
<el-table-column label="金额" width="100">
|
|
<template #default="{ row }">{{ (row.amount / 100).toFixed(2) }} 元</template>
|
|
</el-table-column>
|
|
<el-table-column prop="subject" label="说明" min-width="160" />
|
|
<el-table-column label="状态" width="80">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
|
{{ row.status === 'success' ? '成功' : '失败' }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="时间" width="180">
|
|
<template #default="{ row }">{{ row.created_at || row.paid_at || '-' }}</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div v-if="renewalList.length === 0" style="text-align:center;color:#999;padding:32px 0">暂无续费记录</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { listAgents, createAgent, updateAgent, listAgentRenewals } from '../../api/agent.js'
|
|
import { listRegionCascades } from '../../api/regionPricing.js'
|
|
import PaymentDialog from '../../components/PaymentDialog.vue'
|
|
|
|
const list = ref([])
|
|
const total = ref(0)
|
|
const page = ref(1)
|
|
const pageSize = ref(20)
|
|
const loading = ref(false)
|
|
const cascadeOptions = ref([])
|
|
const cascadeLoaded = ref(false)
|
|
const filters = ref({ keyword: '', phone: '', province: '', region: '', cascaderRegion: [], expiryRange: null })
|
|
const pageSizes = ref([20, 50, 100])
|
|
|
|
async function buildParams() {
|
|
const params = { page: page.value, pageSize: pageSize.value }
|
|
if (filters.value.keyword) params.keyword = filters.value.keyword
|
|
if (filters.value.phone) params.phone = filters.value.phone
|
|
if (filters.value.province) params.province = filters.value.province
|
|
if (filters.value.region) params.region = filters.value.region
|
|
if (filters.value.expiryRange && filters.value.expiryRange.length === 2) {
|
|
params.expired_at_from = filters.value.expiryRange[0]
|
|
params.expired_at_to = filters.value.expiryRange[1]
|
|
}
|
|
return params
|
|
}
|
|
|
|
async function fetchData() {
|
|
loading.value = true
|
|
try { const r = await listAgents(await buildParams()); list.value = r.list; total.value = r.total }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
function onFilterCascaderChange(val) {
|
|
if (val && val.length >= 2) {
|
|
filters.value.province = val[0]
|
|
filters.value.region = val[1]
|
|
} else {
|
|
filters.value.province = ''
|
|
filters.value.region = ''
|
|
}
|
|
}
|
|
|
|
function doSearch() { page.value = 1; fetchData() }
|
|
|
|
function onSizeChange(size) { pageSize.value = size; page.value = 1; fetchData() }
|
|
|
|
function resetSearch() {
|
|
filters.value = { keyword: '', phone: '', province: '', region: '', cascaderRegion: [], expiryRange: null }
|
|
page.value = 1
|
|
fetchData()
|
|
}
|
|
|
|
async function lazyLoadCascades() {
|
|
if (cascadeLoaded.value) return
|
|
cascadeLoaded.value = true
|
|
try {
|
|
const r = await listRegionCascades()
|
|
cascadeOptions.value = (r.list || []).map(item => ({
|
|
value: item.province,
|
|
label: item.province,
|
|
children: (item.cities || []).map(city => ({ value: city, label: city }))
|
|
}))
|
|
} catch (_) {}
|
|
}
|
|
|
|
// create
|
|
const showCreate = ref(false); const creating = ref(false); const createFormRef = ref(null)
|
|
const createForm = ref({ username: '', password: '', phone: '', name: '', region: '', region_protected: false, cascaderRegion: [] })
|
|
const createRules = {
|
|
username: [
|
|
{ required: true, message: '请输入账号', trigger: 'blur' },
|
|
{ min: 5, max: 18, message: '账号长度5-18位', trigger: 'blur' },
|
|
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]{4,17}$/, message: '账号以字母开头,只能包含字母、数字和下划线', trigger: 'blur' }
|
|
],
|
|
password: [
|
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
|
{ min: 6, max: 18, message: '密码长度6-18位', trigger: 'blur' },
|
|
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]).{6,18}$/, message: '密码必须包含大写字母、小写字母、数字和特殊符号', trigger: 'blur' }
|
|
],
|
|
phone: [
|
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的11位手机号', trigger: 'blur' }
|
|
],
|
|
name: [{ required: true, message: '必填', trigger: 'blur' }],
|
|
region: [{ required: true, message: '请选择省/市', trigger: 'change' }]
|
|
}
|
|
|
|
function onCascaderChange(val) {
|
|
if (val && val.length >= 2) {
|
|
createForm.value.province = val[0]
|
|
createForm.value.region = val[1]
|
|
} else {
|
|
createForm.value.province = ''
|
|
createForm.value.region = ''
|
|
}
|
|
}
|
|
|
|
async function handleCreate() {
|
|
const v = await createFormRef.value.validate().catch(() => false)
|
|
if (!v) return; creating.value = true
|
|
try {
|
|
const payload = { ...createForm.value }
|
|
delete payload.cascaderRegion
|
|
await createAgent(payload)
|
|
ElMessage.success('创建成功'); showCreate.value = false; createForm.value = { username: '', password: '', phone: '', name: '', region: '', region_protected: false, cascaderRegion: [] }; await fetchData()
|
|
}
|
|
finally { creating.value = false }
|
|
}
|
|
|
|
// edit
|
|
const showEdit = ref(false); const updating = ref(false); const editFormRef = ref(null)
|
|
const editForm = ref({ id: 0, phone: '', name: '', province: '', region: '', cascaderRegion: [] })
|
|
const editRules = { name: [{ required: true, message: '必填', trigger: 'blur' }], phone: [{ required: true, message: '必填', trigger: 'blur' }], region: [{ required: true, message: '请选择省/市', trigger: 'change' }] }
|
|
|
|
function onEditCascaderChange(val) {
|
|
if (val && val.length >= 2) {
|
|
editForm.value.province = val[0]
|
|
editForm.value.region = val[1]
|
|
} else {
|
|
editForm.value.province = ''
|
|
editForm.value.region = ''
|
|
}
|
|
}
|
|
|
|
function handleEdit(row) {
|
|
lazyLoadCascades()
|
|
editForm.value = {
|
|
id: row.id,
|
|
phone: row.phone || '',
|
|
name: row.name,
|
|
province: row.province || '',
|
|
region: row.region,
|
|
cascaderRegion: row.province && row.region ? [row.province, row.region] : []
|
|
}
|
|
showEdit.value = true
|
|
}
|
|
|
|
async function handleUpdate() {
|
|
const v = await editFormRef.value.validate().catch(() => false)
|
|
if (!v) return
|
|
updating.value = true
|
|
try {
|
|
const payload = { ...editForm.value }
|
|
delete payload.cascaderRegion
|
|
await updateAgent(payload)
|
|
ElMessage.success('保存成功'); showEdit.value = false; await fetchData()
|
|
}
|
|
finally { updating.value = false }
|
|
}
|
|
|
|
// renew
|
|
const showRenew = ref(false)
|
|
const renewAgentInfo = ref(null)
|
|
|
|
function handleRenew(row) {
|
|
renewAgentInfo.value = {
|
|
id: row.id,
|
|
name: row.name,
|
|
expiredAt: row.expired_at || '',
|
|
province: row.province || '',
|
|
region: row.region || ''
|
|
}
|
|
showRenew.value = true
|
|
}
|
|
|
|
function onRenewSuccess() {
|
|
fetchData()
|
|
}
|
|
|
|
// renewal records
|
|
const showRenewals = ref(false)
|
|
const renewalList = ref([])
|
|
const renewalLoading = ref(false)
|
|
|
|
async function handleViewRenewals(row) {
|
|
renewalLoading.value = true
|
|
showRenewals.value = true
|
|
renewalList.value = []
|
|
try {
|
|
const r = await listAgentRenewals(row.id)
|
|
renewalList.value = r.list || []
|
|
} finally {
|
|
renewalLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(fetchData)</script>
|
|
|
|
<style scoped>
|
|
.page { padding: 24px 28px; display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
|
|
.page-body { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #e5e5e5; display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
.table-wrapper { flex: 1; min-height: 0; overflow: hidden; }
|
|
.btn-primary {
|
|
display: inline-flex; align-items: center; gap: 5px; height: 34px; padding: 0 14px;
|
|
border-radius: 4px; border: none; background: #000; color: #fff; font-size: 12px; font-weight: 500; cursor: pointer;
|
|
}
|
|
.btn-primary:hover { background: #333; }
|
|
.search-bar { display: flex; gap: 8px; flex-shrink: 0; margin-bottom: 12px; }
|
|
.search-spacer { flex: 1; }
|
|
.pagination-wrap {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 12px 0;
|
|
background: #fff;
|
|
border-top: 1px solid #e5e5e5;
|
|
flex-shrink: 0;
|
|
margin: 0 -16px;
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
}
|
|
</style>
|