feat(F006-W17.4): UserListView 新建/修改/导出 完整化

- 新增 UserFormDialog.vue (新建+修改共用弹窗)
  字段: 昵称/手机号/密码/头像/性别/企业认证/禁用标识/备注
  新建: POST /user/user/save
  修改: GET /user/user/getUserInfo 回填, POST /user/user/update
- 新建按钮: 顶部 + 新建用户 → 打开弹窗(空表单)
- 修改按钮: 操作列 修改 → 打开弹窗(回填)
- 导出按钮: 顶部 导出 → 调 GET /user/user/export (Excel 流, 自动下载)
  带当前搜索条件, 后端响应头 filename 自动识别
This commit is contained in:
direv 2026-07-07 18:31:54 +08:00
parent 9664b3d954
commit c69fa216f8
2 changed files with 326 additions and 4 deletions

View File

@ -0,0 +1,255 @@
<script setup lang="ts">
/**
* F006 用户表单弹窗 (新建/修改 共用)
*
* 后端:
* - 新建: POST /user/user/save (UserDTO body)
* - 详情: GET /user/user/getUserInfo?id=...
* - 修改: POST /user/user/update (UserFeignDTO body, 字段子集)
*
* 字段 (沿用老 /views/api/user.vue + UserDTO.java):
* nickName 昵称 (必填)
* mobile 手机号 (必填, 11 位)
* password 密码 (新建必填, 修改可选-留空表示不修改)
* header 头像 URL (选填, 暂不实现上传, 直接 URL 输入)
* sex 性别 0男1女 (选填)
* isEnterprise 是否企业认证 0否1是 (选填)
* enabled 禁用标识 0正常1禁用 (选填)
* remark 备注 (选填)
*/
import { ref, reactive, watch, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request'
interface UserForm {
id: number | null
nickName: string
mobile: string
password: string
header: string
sex: '' | '0' | '1'
isEnterprise: '' | '0' | '1'
enabled: '' | '0' | '1'
remark: string
}
const props = defineProps<{
visible: boolean
userId: number | string | null
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'success'): void
}>()
const mode = computed<'create' | 'update'>(() => (props.userId ? 'update' : 'create'))
const loading = ref(false)
const submitting = ref(false)
const formRef = ref()
const form = reactive<UserForm>({
id: null,
nickName: '',
mobile: '',
password: '',
header: '',
sex: '',
isEnterprise: '',
enabled: '0',
remark: ''
})
const rules = {
nickName: [{ required: true, message: '请输入用户昵称', trigger: 'blur' }],
mobile: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
password: [
{
validator(_: any, value: string, cb: (err?: Error) => void) {
if (mode.value === 'create' && !value) {
return cb(new Error('新建用户必须输入密码'))
}
if (value && (value.length < 6 || value.length > 20)) {
return cb(new Error('密码长度 6-20 位'))
}
cb()
},
trigger: 'blur'
}
]
}
watch(
() => props.visible,
async (v) => {
if (!v) return
resetForm()
if (props.userId) {
await loadDetail()
}
}
)
function resetForm() {
form.id = null
form.nickName = ''
form.mobile = ''
form.password = ''
form.header = ''
form.sex = ''
form.isEnterprise = ''
form.enabled = '0'
form.remark = ''
}
async function loadDetail() {
loading.value = true
try {
const data: any = await httpGet('/user/user/getUserInfo', { id: props.userId })
if (data) {
form.id = Number(data.id ?? props.userId)
form.nickName = data.nickName || ''
form.mobile = data.mobile || ''
form.password = '' // 修改时不回显密码
form.header = data.header || ''
form.sex = (data.sex === 0 || data.sex === 1 ? String(data.sex) : '') as UserForm['sex']
form.isEnterprise = (data.isEnterprise === 0 || data.isEnterprise === 1 ? String(data.isEnterprise) : '') as UserForm['isEnterprise']
form.enabled = (data.enabled === 0 || data.enabled === 1 ? String(data.enabled) : '0') as UserForm['enabled']
form.remark = data.remark || ''
}
await nextTick()
formRef.value?.clearValidate()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载用户详情失败')
close()
} finally {
loading.value = false
}
}
function close() {
emit('update:visible', false)
}
async function submit() {
try {
await formRef.value?.validate()
} catch {
return
}
submitting.value = true
try {
// 后端 save 接口吃 UserDTO (全字段), update 接口吃 UserFeignDTO (字段子集)
// 为安全起见, 两种模式都按字段子集发, 后端可接受的最小 payload
const payload: Record<string, any> = {
id: form.id,
nickName: form.nickName.trim(),
mobile: form.mobile.trim()
}
if (form.password) payload.password = form.password
if (form.header) payload.header = form.header
if (form.sex !== '') payload.sex = Number(form.sex)
if (form.isEnterprise !== '') payload.isEnterprise = Number(form.isEnterprise)
if (form.enabled !== '') payload.enabled = Number(form.enabled)
if (form.remark) payload.remark = form.remark
if (mode.value === 'create') {
await httpPost('/user/user/save', payload)
ElMessage.success('新建成功')
} else {
await httpPost('/user/user/update', payload)
ElMessage.success('修改成功')
}
emit('success')
close()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || (mode.value === 'create' ? '新建失败' : '修改失败'))
} finally {
submitting.value = false
}
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="mode === 'create' ? '新建用户' : '修改用户'"
width="560"
:style="{ borderRadius: '12px', overflow: 'hidden' }"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="(v: boolean) => emit('update:visible', v)"
@closed="resetForm"
>
<div v-loading="loading">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
>
<el-form-item label="用户昵称" prop="nickName">
<el-input v-model="form.nickName" placeholder="请输入用户昵称" maxlength="50" show-word-limit clearable />
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="form.mobile" placeholder="请输入手机号" maxlength="11" clearable />
</el-form-item>
<el-form-item label="登录密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
:placeholder="mode === 'create' ? '请输入密码 (6-20 位)' : '留空表示不修改密码'"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="头像 URL">
<el-input v-model="form.header" placeholder="选填, 头像图片地址" maxlength="200" clearable />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.sex">
<el-radio value="">未知</el-radio>
<el-radio value="0">男</el-radio>
<el-radio value="1">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否企业认证">
<el-radio-group v-model="form.isEnterprise">
<el-radio value="">未设置</el-radio>
<el-radio value="0">否</el-radio>
<el-radio value="1">是</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="禁用标识">
<el-radio-group v-model="form.enabled">
<el-radio value="0">正常</el-radio>
<el-radio value="1">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" maxlength="200" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ mode === 'create' ? '确认新建' : '确认修改' }}
</el-button>
</template>
</el-dialog>
</template>

View File

@ -5,13 +5,14 @@
* 按 ui-preview-v2.html 模板: 9 列 + 6 搜索 + 4 操作 + 状态 tab
*/
import { onMounted, ref, reactive, computed } from 'vue'
import { httpGet, httpPost } from '@/utils/request'
import { http, httpGet, httpPost } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import UserFormDialog from './UserFormDialog.vue'
interface FrontUser {
id: string
@ -33,6 +34,12 @@ const total = ref(0)
const enabledStats = reactive({ normal: 0, disabled: 0 })
const loading = ref(false)
const activeTab = ref('all')
// 表单弹窗状态
const dialogVisible = ref(false)
const editingUserId = ref<string | number | null>(null)
const exporting = ref(false)
const query = reactive({
page: 1,
limit: 10,
@ -192,7 +199,62 @@ function onReset() {
}
function onEdit(row: FrontUser) {
ElMessageBox.alert(`用户详情编辑待 W17 完善 (当前用户: ${row.nickName}, ID: ${row.id})`, '修改用户', { type: 'info' })
editingUserId.value = row.id
dialogVisible.value = true
}
function onCreate() {
editingUserId.value = null
dialogVisible.value = true
}
function onDialogSuccess() {
load()
}
/**
* 导出当前搜索条件下的用户列表为 Excel
* 后端: GET /user/user/export (返 Excel 文件流, Content-Type: application/vnd.ms-excel)
* 走 http 实例 (自动带 Bearer token), responseType: 'blob' 接二进制流
*/
async function exportExcel() {
exporting.value = true
try {
const params = buildSearchParams()
if (query.enabled !== '') params.enabled = Number(query.enabled)
const resp = await http.get<Blob>('/user/user/export', {
params,
responseType: 'blob'
})
const blob = resp.data
if (!blob || blob.size === 0) {
ElMessage.error('导出内容为空')
return
}
// 优先从响应头取文件名 (后端 ExcelUtils 会设置)
const dispo = resp.headers?.['content-disposition'] || resp.headers?.['Content-Disposition']
let filename = `用户列表_${new Date().toISOString().slice(0, 10)}.xls`
if (typeof dispo === 'string') {
const m = dispo.match(/filename\*?=(?:UTF-8\u0027\u0027)?["']?([^;"']+)["']?/i)
if (m && m[1]) {
try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] }
}
}
const url = URL.createObjectURL(new Blob([blob]))
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${filename}`)
} catch (e: any) {
const msg = e?.msg || e?.message || '导出失败'
ElMessage.error(msg)
} finally {
exporting.value = false
}
}
async function toggleEnabled(row: FrontUser) {
@ -230,12 +292,17 @@ onMounted(load)
<template>
<div class="rr-page">
<PageHeader title="用户列表" :sub="`管理前台注册用户,共 ${enabledStats.normal + enabledStats.disabled} 个账号`">
<el-button @click="ElMessageBox.alert('导出功能待 W17 完善', '提示', { type: 'info' })">导出</el-button>
<el-button type="primary" @click="ElMessageBox.alert('新增用户功能待 W17 完善', '提示', { type: 'info' })">
<el-button @click="exportExcel" :loading="exporting">导出</el-button>
<el-button type="primary" @click="onCreate">
<el-icon style="margin-right: 4px; vertical-align: -2px;"><Plus /></el-icon>
新建用户
</el-button>
</PageHeader>
<UserFormDialog
v-model:visible="dialogVisible"
:user-id="editingUserId"
@success="onDialogSuccess"
/>
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" />