<template>
|
<div class="visitor-page">
|
<div class="search-card">
|
<div class="panel-title">访客信息列表</div>
|
<el-form class="search-grid" @submit.prevent="doSearch">
|
<el-form-item label="用户名称">
|
<el-input v-model="filters.userName" placeholder="请输入内容" clearable />
|
</el-form-item>
|
<el-form-item label="用户电话">
|
<el-input v-model="filters.mobile" placeholder="请输入内容" clearable />
|
</el-form-item>
|
<el-form-item label="所属公司">
|
<el-input v-model="filters.companyName" placeholder="请输入内容" clearable />
|
</el-form-item>
|
<el-form-item label="状态">
|
<el-select v-model="filters.status" placeholder="请选择" clearable>
|
<el-option label="启用" :value="1" />
|
<el-option label="禁用" :value="0" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="注册时间" class="date-span-item">
|
<el-date-picker
|
v-model="registerDateRange"
|
type="daterange"
|
unlink-panels
|
range-separator="至"
|
start-placeholder="开始日期"
|
end-placeholder="结束日期"
|
value-format="YYYY-MM-DD"
|
/>
|
</el-form-item>
|
<div class="search-actions">
|
<el-button @click="resetSearch">
|
<el-icon><Refresh /></el-icon>
|
重置
|
</el-button>
|
<el-button type="primary" native-type="submit">
|
<el-icon><Search /></el-icon>
|
搜索
|
</el-button>
|
</div>
|
</el-form>
|
</div>
|
|
<div class="table-card">
|
<div class="table-toolbar">
|
<el-button v-permission="'sys:visitor-users:export'" type="primary" plain :loading="exporting" @click="exportExcel">
|
导出 Excel
|
</el-button>
|
</div>
|
<el-table :data="rows" v-loading="loading" stripe border>
|
<el-table-column label="序号" width="68" type="index" :index="indexMethod" />
|
<el-table-column prop="userName" label="用户" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="gender" label="性别" width="88" align="center" />
|
<el-table-column prop="mobileMasked" label="账号" width="150" />
|
<el-table-column prop="companyName" label="所属公司" min-width="180" show-overflow-tooltip />
|
<el-table-column label="预约次数" width="100" align="center">
|
<template #default="{ row }">
|
{{ row.bookingCount ?? 0 }}
|
</template>
|
</el-table-column>
|
<el-table-column label="状态" width="92" align="center">
|
<template #default="{ row }">
|
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
{{ row.status === 1 ? "启用" : "禁用" }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column label="注册时间" width="170">
|
<template #default="{ row }">
|
{{ formatTime(row.createTime) }}
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="220" fixed="right">
|
<template #default="{ row }">
|
<div class="table-ops">
|
<el-button v-permission="'sys:visitor-users:update'" type="primary" link @click="openEdit(row)">编辑</el-button>
|
<el-button
|
v-permission="'sys:visitor-users:status'"
|
v-if="row.status === 1"
|
type="warning"
|
link
|
@click="patchStatus(row, 0)"
|
>
|
禁用
|
</el-button>
|
<el-button v-else v-permission="'sys:visitor-users:status'" type="success" link @click="patchStatus(row, 1)">
|
启用
|
</el-button>
|
<el-button v-permission="'sys:visitor-users:delete'" type="danger" link @click="confirmDelete(row)">删除</el-button>
|
</div>
|
</template>
|
</el-table-column>
|
</el-table>
|
|
<div class="pager-wrap">
|
<div class="pager-total">共 {{ total }} 条</div>
|
<el-pagination
|
background
|
layout="sizes, prev, pager, next"
|
:total="total"
|
:page-size="size"
|
:page-sizes="[10, 16, 20, 50, 100]"
|
v-model:current-page="page"
|
@current-change="load"
|
@size-change="onSizeChange"
|
/>
|
</div>
|
</div>
|
|
<el-dialog v-model="dlg" title="编辑访客" width="460px" destroy-on-close @closed="resetForm">
|
<el-form :model="form" label-width="96px">
|
<el-form-item label="用户名称" required>
|
<el-input v-model="form.userName" maxlength="30" placeholder="请输入" />
|
</el-form-item>
|
<el-form-item label="用户电话" required>
|
<el-input v-model="form.mobile" maxlength="11" placeholder="11位数字" />
|
</el-form-item>
|
<el-form-item label="性别">
|
<el-radio-group v-model="form.gender">
|
<el-radio label="">未填写</el-radio>
|
<el-radio label="男">男</el-radio>
|
<el-radio label="女">女</el-radio>
|
</el-radio-group>
|
</el-form-item>
|
<el-form-item label="所属公司">
|
<el-input v-model="form.companyName" maxlength="128" placeholder="请输入" />
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<el-button @click="dlg = false">取消</el-button>
|
<el-button v-permission="'sys:visitor-users:update'" type="primary" :loading="saving" @click="submitEdit">保存</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { onMounted, reactive, ref } from "vue";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
import { Refresh, Search } from "@element-plus/icons-vue";
|
import http from "../api/http";
|
|
type VisitorRow = {
|
id: number;
|
uuid: string;
|
userName: string;
|
gender: string;
|
mobileMasked: string;
|
companyName: string;
|
bookingCount: number;
|
status: number;
|
createTime?: string;
|
};
|
|
type VisitorDetail = {
|
id: number;
|
uuid: string;
|
userName: string;
|
mobile: string;
|
gender?: string;
|
companyName?: string;
|
status: number;
|
};
|
|
const loading = ref(false);
|
const saving = ref(false);
|
const exporting = ref(false);
|
const rows = ref<VisitorRow[]>([]);
|
const page = ref(1);
|
const size = ref(16);
|
const total = ref(0);
|
const registerDateRange = ref<string[]>([]);
|
const dlg = ref(false);
|
const editingId = ref<number | null>(null);
|
|
const filters = reactive({
|
userName: "",
|
mobile: "",
|
companyName: "",
|
status: undefined as number | undefined,
|
});
|
|
const form = reactive({
|
uuid: "",
|
userName: "",
|
mobile: "",
|
gender: "",
|
companyName: "",
|
});
|
|
function indexMethod(idx: number) {
|
return (page.value - 1) * size.value + idx + 1;
|
}
|
|
function formatTime(v: unknown) {
|
if (v == null || v === "") return "—";
|
const s = String(v);
|
return s.length >= 19 ? s.slice(0, 19).replace("T", " ") : s;
|
}
|
|
function buildParams() {
|
return {
|
page: page.value,
|
size: size.value,
|
userName: filters.userName.trim() || undefined,
|
mobile: filters.mobile.trim() || undefined,
|
companyName: filters.companyName.trim() || undefined,
|
registerStartDate: registerDateRange.value?.[0] || undefined,
|
registerEndDate: registerDateRange.value?.[1] || undefined,
|
status: filters.status,
|
};
|
}
|
|
async function load() {
|
loading.value = true;
|
try {
|
const { data } = await http.get("/sys/visitor-users", { params: buildParams() });
|
rows.value = (data.result || []) as VisitorRow[];
|
total.value = Number(data.total) || 0;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载失败");
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function resolveExportFileName(contentDisposition?: string) {
|
if (!contentDisposition) return "visitor-users.xlsx";
|
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
if (utf8Match?.[1]) {
|
return decodeURIComponent(utf8Match[1]);
|
}
|
const plainMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
|
return plainMatch?.[1] || "visitor-users.xlsx";
|
}
|
|
async function exportExcel() {
|
exporting.value = true;
|
try {
|
const response = await http.get("/sys/visitor-users/export", {
|
params: buildParams(),
|
responseType: "blob",
|
});
|
const blob = response.data as Blob;
|
const contentType = response.headers["content-type"];
|
if (typeof contentType === "string" && contentType.includes("application/json")) {
|
const text = await blob.text();
|
const body = JSON.parse(text) as { message?: string };
|
throw new Error(body.message || "导出失败");
|
}
|
const fileName = resolveExportFileName(response.headers["content-disposition"]);
|
const url = window.URL.createObjectURL(blob);
|
const link = document.createElement("a");
|
link.href = url;
|
link.download = fileName;
|
document.body.appendChild(link);
|
link.click();
|
document.body.removeChild(link);
|
window.URL.revokeObjectURL(url);
|
ElMessage.success("导出成功");
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "导出失败");
|
} finally {
|
exporting.value = false;
|
}
|
}
|
|
function doSearch() {
|
page.value = 1;
|
load();
|
}
|
|
function resetSearch() {
|
filters.userName = "";
|
filters.mobile = "";
|
filters.companyName = "";
|
filters.status = undefined;
|
registerDateRange.value = [];
|
page.value = 1;
|
load();
|
}
|
|
function onSizeChange(v: number) {
|
size.value = v;
|
page.value = 1;
|
load();
|
}
|
|
async function openEdit(row: VisitorRow) {
|
editingId.value = row.id;
|
try {
|
const { data } = await http.get(`/sys/visitor-users/${row.id}`, {
|
params: { uuid: row.uuid },
|
});
|
const detail = data.result as VisitorDetail;
|
form.uuid = detail.uuid;
|
form.userName = detail.userName || "";
|
form.mobile = detail.mobile || "";
|
form.gender = detail.gender || "";
|
form.companyName = detail.companyName || "";
|
dlg.value = true;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载详情失败");
|
}
|
}
|
|
function resetForm() {
|
editingId.value = null;
|
form.uuid = "";
|
form.userName = "";
|
form.mobile = "";
|
form.gender = "";
|
form.companyName = "";
|
}
|
|
async function submitEdit() {
|
if (editingId.value == null) return;
|
if (!form.userName.trim()) {
|
ElMessage.warning("请填写用户名称");
|
return;
|
}
|
if (form.userName.trim().length > 30) {
|
ElMessage.warning("用户名称不能超过30个字");
|
return;
|
}
|
if (!/^\d{11}$/.test(form.mobile.trim())) {
|
ElMessage.warning("用户电话须为11位数字");
|
return;
|
}
|
if (form.gender && form.gender !== "男" && form.gender !== "女") {
|
ElMessage.warning("性别仅支持男/女");
|
return;
|
}
|
saving.value = true;
|
try {
|
await http.put(`/sys/visitor-users/${editingId.value}`, {
|
uuid: form.uuid,
|
userName: form.userName.trim(),
|
mobile: form.mobile.trim(),
|
gender: form.gender || undefined,
|
companyName: form.companyName.trim() || undefined,
|
});
|
ElMessage.success("已保存");
|
dlg.value = false;
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "保存失败");
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
function patchStatus(row: VisitorRow, status: number) {
|
const actionText = status === 1 ? "启用" : "禁用";
|
ElMessageBox.confirm(`确认${actionText}该访客账号?`, `确认${actionText}`, { type: "warning" })
|
.then(async () => {
|
try {
|
await http.patch(`/sys/visitor-users/${row.id}/status`, { uuid: row.uuid, status });
|
ElMessage.success(`已${actionText}`);
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "操作失败");
|
}
|
})
|
.catch(() => {});
|
}
|
|
function confirmDelete(row: VisitorRow) {
|
ElMessageBox.confirm("确认删除该访客账号?", "确认删除", { type: "warning" })
|
.then(async () => {
|
try {
|
await http.delete(`/sys/visitor-users/${row.id}`, { params: { uuid: row.uuid } });
|
ElMessage.success("已删除");
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "删除失败");
|
}
|
})
|
.catch(() => {});
|
}
|
|
onMounted(() => {
|
load();
|
});
|
</script>
|
|
<style scoped>
|
.visitor-page {
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
}
|
|
.search-card,
|
.table-card {
|
background: #ffffff;
|
border: 1px solid #e5ecfb;
|
border-radius: 10px;
|
box-shadow: 0 8px 24px rgba(77, 111, 197, 0.06);
|
}
|
|
.search-card {
|
padding: 16px 18px 10px;
|
}
|
|
.panel-title {
|
color: #2e446e;
|
font-size: 18px;
|
font-weight: 700;
|
margin-bottom: 14px;
|
}
|
|
.search-grid {
|
display: grid;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 8px 18px;
|
align-items: start;
|
}
|
|
.search-grid :deep(.el-form-item) {
|
margin-bottom: 12px;
|
}
|
|
.search-grid :deep(.el-form-item__label) {
|
color: #42567f;
|
font-weight: 600;
|
}
|
|
.search-grid :deep(.el-input),
|
.search-grid :deep(.el-select),
|
.search-grid :deep(.el-date-editor) {
|
width: 100%;
|
}
|
|
.date-span-item {
|
grid-column: span 2;
|
}
|
|
.search-actions {
|
display: flex;
|
justify-content: flex-end;
|
gap: 10px;
|
grid-column: 3 / 5;
|
padding-top: 2px;
|
}
|
|
.table-card {
|
padding: 14px 16px 12px;
|
}
|
|
.table-toolbar {
|
display: flex;
|
justify-content: flex-end;
|
margin-bottom: 12px;
|
}
|
|
.pager-wrap {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-top: 14px;
|
}
|
|
.pager-total {
|
color: #7484a6;
|
font-size: 13px;
|
}
|
|
@media (max-width: 1200px) {
|
.search-grid {
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
|
.date-span-item,
|
.search-actions {
|
grid-column: span 2;
|
}
|
}
|
</style>
|