feat: 新增12个业务页面功能

- 新增模块监控模块:在线用户页、登录日志页、操作日志页、数据概览页
- 新增AI助手模块:AI模型配置页、聊天会话页
- 新增系统管理模块:通知公告页、用户详情页、用户管理页、工单管理页、工单详情页
This commit is contained in:
zhangtao
2026-08-05 23:47:27 +08:00
parent ee8e23410f
commit 372b277a9f
11 changed files with 0 additions and 0 deletions
@@ -0,0 +1,208 @@
<script setup lang="ts">
import type { NoticeForm, NoticeItem } from '@/api/module_system/notice'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { NoticeAPI } from '@/api/module_system/notice'
definePage({ name: 'work-notices', style: { navigationBarTitleText: '通知公告' } })
const toast = useToast()
const searchTitle = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<NoticeForm>({ notice_title: '', notice_content: '', notice_type: '1', status: 0, description: '' })
const NOTICE_TYPE_OPTIONS = [{ value: '1', label: '通知' }, { value: '2', label: '公告' }]
const STATUS_OPTIONS = [{ value: 0, label: '草稿' }, { value: 1, label: '已发布' }, { value: 2, label: '已归档' }]
function statusLabel(s: number | string | undefined) {
const n = Number(s)
if (n === 1)
return 'published'
if (n === 2)
return 'archived'
return 'draft'
}
const { list, total, loading, pageParams, loadData, toFirst, loadNext } = useListPage<NoticeItem>({
fetcher: p => NoticeAPI.getPage({ ...p, notice_title: searchTitle.value || undefined }),
onError: () => toast.error('加载失败'),
})
function handlePageChange({ value }: { value: number }) {
pageParams.value.page_no = value
loadData()
}
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchTitle.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { notice_title: '', notice_content: '', notice_type: '1', status: 0, description: '' })
}
function openCreate() {
formTitle.value = '新增公告'
currentId.value = undefined
resetForm()
showForm.value = true
}
async function openEdit(id: number) {
formTitle.value = '编辑公告'
currentId.value = id
try {
const detail = await NoticeAPI.getDetail(id)
Object.assign(formData, {
notice_title: detail.notice_title || '',
notice_content: detail.notice_content || '',
notice_type: detail.notice_type || '1',
status: detail.status ?? 0,
description: detail.description || '',
})
showForm.value = true
}
catch { toast.error('获取详情失败') }
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await NoticeAPI.update(currentId.value, formData)
toast.success('更新成功')
}
else {
await NoticeAPI.create(formData)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch { toast.error('操作失败') }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await NoticeAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch { toast.error('删除失败') }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchTitle" placeholder="搜索公告标题" clearable class="flex-1" />
<wd-button size="small" type="primary" variant="plain" @click="onSearch">
搜索
</wd-button>
<wd-button size="small" variant="plain" @click="onReset">
重置
</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md text-muted font-bold">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">
+ 新增
</wd-button>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<wd-empty v-if="!loading && list.length === 0" tip="暂无公告" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" center is-link @click="openEdit(item.id!)">
<template #title>
<view>
<text class="text-md font-medium">
{{ item.notice_title }}
</text><text class="text-muted mt-xs block text-xs">
{{ item.description || '' }}
</text>
</view>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="statusLabel(item.status)" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<wd-pagination
:model-value="pageParams.page_no"
:total="total"
:page-size="pageParams.page_size"
button-variant="plain"
hide-if-one-page
@change="handlePageChange"
/>
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="公告标题" prop="notice_title" border>
<wd-input v-model="formData.notice_title" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="公告内容" prop="notice_content" border>
<wd-textarea v-model="formData.notice_content" placeholder="请输入内容" />
</wd-form-item>
<wd-form-item label="公告类型" border>
<wd-radio-group v-model="formData.notice_type">
<wd-radio v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="状态" border>
<wd-radio-group v-model="formData.status">
<wd-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="备注" border>
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="gap-md mt-xl flex">
<wd-button block variant="plain" @click="showForm = false">
取消
</wd-button><wd-button block type="primary" :loading="loading" @click="handleSubmit">
保存
</wd-button>
</view>
</view>
</wd-popup>
</template>
</view>
</template>
@@ -0,0 +1,248 @@
<script setup lang="ts">
import type { TicketComment, TicketItem } from '@/api/module_system/ticket'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { TicketAPI } from '@/api/module_system/ticket'
definePage({
name: 'work-ticket-detail',
style: { navigationBarTitleText: '工单详情' },
})
const toast = useToast()
const loading = ref(false)
const ticket = ref<TicketItem | null>(null)
const comments = ref<TicketComment[]>([])
const commentTotal = ref(0)
const commentPage = reactive({ page_no: 1, page_size: 20 })
const commentText = ref('')
const submitting = ref(false)
const ticketId = ref(0)
async function loadTicket() {
if (!ticketId.value)
return
loading.value = true
try {
ticket.value = await TicketAPI.getDetail(ticketId.value)
}
catch {
toast.error('加载工单详情失败')
}
finally {
loading.value = false
}
}
async function loadComments() {
if (!ticketId.value)
return
try {
const res = await TicketAPI.getComments(ticketId.value, { page_no: commentPage.page_no, page_size: commentPage.page_size })
comments.value = res.list || []
commentTotal.value = res.total || 0
}
catch (e) {
console.error('加载评论失败', e)
}
}
async function submitComment() {
if (!commentText.value.trim())
return toast.warning('请输入评论内容')
submitting.value = true
try {
await TicketAPI.createComment(ticketId.value, { content: commentText.value.trim() })
commentText.value = ''
toast.success('评论成功')
loadComments()
}
catch {
toast.error('评论失败')
}
finally {
submitting.value = false
}
}
function getTypeLabel(type?: string) {
const map: Record<string, string> = { suggestion: '建议', bug: '缺陷', optimize: '优化', other: '其他' }
return map[type || ''] || type || '其他'
}
function getTypeColor(type?: string) {
const map: Record<string, string> = { suggestion: '#4F8CFF', bug: '#EF4444', optimize: '#F59E0B', other: '#6B7280' }
return map[type || ''] || '#6B7280'
}
function getStatusLabel(status?: string | number) {
const map: Record<string, string> = { 0: '待处理', 1: '处理中', 2: '已完成', 3: '已关闭' }
return map[String(status ?? '')] || '未知'
}
function getStatusColor(status?: string | number) {
const map: Record<string, string> = { 0: '#F59E0B', 1: '#4F8CFF', 2: '#10B981', 3: '#6B7280' }
return map[String(status ?? '')] || '#6B7280'
}
function parseImages(images?: string): string[] {
if (!images)
return []
try {
return JSON.parse(images)
}
catch {
return []
}
}
function previewImages(images: string[], current: number) {
uni.previewImage({ urls: images, current })
}
onPullDownRefresh(async () => {
try {
await Promise.all([loadTicket(), loadComments()])
}
finally {
uni.stopPullDownRefresh()
}
})
onLoad((options) => {
ticketId.value = Number(options?.id || 0)
if (ticketId.value) {
loadTicket()
loadComments()
}
})
</script>
<template>
<view class="page-wraper" style="padding-bottom: 140rpx;">
<SkeletonPage v-if="loading && !ticket" :rows="5" />
<template v-else-if="ticket">
<!-- Ticket Header -->
<view class="mx-3 mb-3 rounded-2 p-4 wot-bg-filled-oppo">
<text class="block text-4 font-bold wot-text-text-main">
{{ ticket.title || '未命名工单' }}
</text>
<view class="mt-3 flex gap-2">
<wd-tag size="small" round :bg-color="`${getTypeColor(ticket.ticket_type)}18`" :color="getTypeColor(ticket.ticket_type)">
{{ getTypeLabel(ticket.ticket_type) }}
</wd-tag>
<wd-tag size="small" round :bg-color="`${getStatusColor(ticket.status)}18`" :color="getStatusColor(ticket.status)">
{{ getStatusLabel(ticket.status) }}
</wd-tag>
</view>
</view>
<!-- Ticket Info -->
<view class="mx-3 mb-3">
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="创建时间" :value="ticket.created_time || '—'" />
<wd-cell title="处理人" :value="ticket.assigned_by?.name || '未分配'" />
<wd-cell title="更新时间" :value="ticket.updated_time || '—'" />
</wd-cell-group>
</view>
<!-- Ticket Content -->
<view v-if="ticket.ticket_content || ticket.summary" class="mx-3 mb-3 rounded-2 p-4 wot-bg-filled-oppo">
<text class="mb-3 block text-3.5 font-bold wot-text-text-main">
工单内容
</text>
<text class="block text-3 leading-relaxed wot-text-text-secondary">
{{ ticket.summary || ticket.ticket_content }}
</text>
</view>
<!-- Images -->
<view v-if="parseImages(ticket.images).length > 0" class="mx-3 mb-3 rounded-2 p-4 wot-bg-filled-oppo">
<text class="mb-3 block text-3.5 font-bold wot-text-text-main">
附件图片
</text>
<view class="flex flex-wrap gap-3">
<image
v-for="(img, idx) in parseImages(ticket.images)"
:key="img"
:src="img"
class="h-[200rpx] w-[200rpx] rounded-lg wot-bg-filled-oppo"
mode="aspectFill"
lazy-load
@click="previewImages(parseImages(ticket.images), idx)"
/>
</view>
</view>
<!-- Reply -->
<view v-if="ticket.reply" class="mx-3 mb-3 rounded-2 p-4 wot-bg-filled-oppo">
<text class="mb-3 block text-3.5 font-bold wot-text-text-main">
处理回复
</text>
<view class="rounded-lg p-3" style="background: var(--primary-color-light, rgba(1, 77, 178, 0.06));">
<text class="block text-3 leading-relaxed wot-text-text-secondary">
{{ ticket.reply }}
</text>
</view>
</view>
<!-- Comments -->
<view class="mx-3 mb-3 rounded-2 p-4 wot-bg-filled-oppo">
<text class="mb-3 block text-3.5 font-bold wot-text-text-main">
评论 ({{ commentTotal }})
</text>
<wd-empty v-if="comments.length === 0" tip="暂无评论,快来抢沙发" />
<view v-else class="flex flex-col gap-4">
<view v-for="comment in comments" :key="comment.id" class="flex gap-3">
<view class="h-8 w-8 flex shrink-0 items-center justify-center rounded-full" style="background: var(--primary-color);">
<text class="text-3.5 font-semibold" style="color: #FFFFFF;">
{{ (comment.username || '匿').charAt(0) }}
</text>
</view>
<view class="min-w-0 flex-1">
<view class="flex items-center gap-3">
<text class="text-3 font-semibold wot-text-text-main">
{{ comment.username || '匿名用户' }}
</text>
<text class="text-2.5 wot-text-text-auxiliary">
{{ comment.created_time || '' }}
</text>
</view>
<text class="mt-1 block text-3 leading-relaxed wot-text-text-secondary">
{{ comment.content }}
</text>
</view>
</view>
<view v-if="commentTotal > comments.length" class="flex items-center justify-center py-1" @click="commentPage.page_no++; loadComments()">
<text class="text-3 wot-text-primary">
加载更多
</text>
</view>
</view>
</view>
</template>
<wd-empty v-else tip="工单不存在或已删除" />
<!-- Comment Input -->
<view
v-if="ticket"
class="fixed inset-x-0 bottom-0 flex items-center gap-3 px-4 py-3"
style="z-index: 100; background: var(--card-bg-color, #FFFFFF); border-top: 1rpx solid var(--border-color, #F0F0F0); padding-bottom: calc(12px + env(safe-area-inset-bottom));"
>
<wd-input
v-model="commentText"
placeholder="写下你的评论..."
clearable
class="flex-1"
/>
<wd-button
size="small"
type="primary"
:loading="submitting"
:disabled="!commentText.trim()"
@click="submitComment"
>
发送
</wd-button>
</view>
</view>
</template>
@@ -0,0 +1,218 @@
<script setup lang="ts">
import type { TicketForm, TicketItem } from '@/api/module_system/ticket'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { TicketAPI } from '@/api/module_system/ticket'
definePage({ name: 'work-tickets', style: { navigationBarTitleText: '工单管理' } })
const router = useRouter()
const toast = useToast()
const searchTitle = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const initForm: TicketForm = { title: '', ticket_content: '', ticket_type: '', status: undefined, description: '' }
const formData = reactive<TicketForm>({ ...initForm })
const showPickerType = ref(false)
const showPickerStatus = ref(false)
const TYPE_OPTIONS = [{ value: 'suggestion', label: '建议' }, { value: 'bug', label: '缺陷' }, { value: 'optimize', label: '优化' }, { value: 'other', label: '其他' }]
const STATUS_OPTIONS = [{ value: 0, label: '待处理' }, { value: 1, label: '处理中' }, { value: 2, label: '已完成' }, { value: 3, label: '已关闭' }]
interface PickerConfirmEvent { value: Array<string | number>, selectedOptions: any[] }
function handleTypeConfirm(e: PickerConfirmEvent) {
const item = TYPE_OPTIONS.find(o => o.value === e.value[0])
if (item)
formData.ticket_type = item.value
showPickerType.value = false
}
function handleStatusConfirm(e: PickerConfirmEvent) {
const item = STATUS_OPTIONS.find(o => o.value === e.value[0])
if (item)
formData.status = item.value
showPickerStatus.value = false
}
const TICKET_STATUS_MAP: Record<number, string> = { 0: 'pending', 1: 'processing', 2: 'completed', 3: 'closed' }
function ticketStatus(status: number | string | undefined) {
return TICKET_STATUS_MAP[Number(status)] || String(status)
}
function displayContent(item: TicketItem) {
return item.ticket_content || ''
}
function displayType(item: TicketItem) {
return item.ticket_type || ''
}
const { list, total, loading, pageParams, loadData, toFirst, loadNext } = useListPage<TicketItem>({
fetcher: p => TicketAPI.getPage({ ...p, title: searchTitle.value || undefined }),
onError: () => toast.error('加载失败'),
})
function handlePageChange({ value }: { value: number }) {
pageParams.value.page_no = value
loadData()
}
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchTitle.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { ...initForm })
}
function openCreate() {
formTitle.value = '新增'
currentId.value = undefined
resetForm()
showForm.value = true
}
function navigateToDetail(id: number) {
router.push({ name: 'work-ticket-detail', query: { id: String(id) } })
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await TicketAPI.update(currentId.value, { ...formData })
toast.success('更新成功')
}
else {
await TicketAPI.create({ ...formData })
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch { toast.error('操作失败') }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await TicketAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch { toast.error('删除失败') }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchTitle" placeholder="搜索标题" clearable class="flex-1" />
<wd-button size="small" type="primary" variant="plain" @click="onSearch">
搜索
</wd-button>
<wd-button size="small" variant="plain" @click="onReset">
重置
</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md text-muted font-bold">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">
+ 新增
</wd-button>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<wd-empty v-if="!loading && list.length === 0" tip="暂无数据" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" center is-link @click="navigateToDetail(item.id!)">
<template #title>
<view>
<text class="text-md font-medium">
{{ item.title }}
</text>
</view>
</template>
<template #label>
<text class="text-muted text-xs">
{{ displayType(item) ? `${displayType(item)} · ` : '' }}{{ displayContent(item) }}
</text>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="ticketStatus(item.status)" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<wd-pagination
:model-value="pageParams.page_no"
:total="total"
:page-size="pageParams.page_size"
button-variant="plain"
hide-if-one-page
@change="handlePageChange"
/>
</template>
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="工单标题">
<wd-input v-model="formData.title" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="内容">
<wd-textarea v-model="formData.ticket_content" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="类型">
<view @click="showPickerType = true">
<wd-cell title="类型" :value="TYPE_OPTIONS.find(o => o.value === formData.ticket_type)?.label || '请选择'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerType" :columns="[TYPE_OPTIONS]" @confirm="handleTypeConfirm" @cancel="showPickerType = false" />
</wd-form-item>
<wd-form-item v-if="currentId" label="状态">
<view @click="showPickerStatus = true">
<wd-cell title="状态" :value="STATUS_OPTIONS.find(o => o.value === formData.status)?.label || '请选择'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerStatus" :columns="[STATUS_OPTIONS]" @confirm="handleStatusConfirm" @cancel="showPickerStatus = false" />
</wd-form-item>
<wd-form-item label="备注">
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="gap-md mt-xl flex">
<wd-button block variant="plain" @click="showForm = false">
取消
</wd-button><wd-button block type="primary" :loading="loading" @click="handleSubmit">
保存
</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
@@ -0,0 +1,227 @@
<script setup lang="ts">
import type { DeptItem } from '@/api/module_system/dept'
import type { UserForm } from '@/api/module_system/user'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { DeptAPI } from '@/api/module_system/dept'
import { RoleAPI } from '@/api/module_system/role'
import UserAPI from '@/api/module_system/user'
definePage({
name: 'work-user-detail',
style: { navigationBarTitleText: '用户详情' },
})
const toast = useToast()
const loading = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const userId = ref<number>()
const user = ref<UserForm>({})
const deptOptions = ref<{ value: number, label: string }[]>([])
const roleOptions = ref<{ value: number, label: string }[]>([])
const showPickerDept = ref(false)
const showPickerRole = ref(false)
interface PickerConfirmEvent { value: Array<string | number>, selectedOptions: any[] }
function handleDeptConfirm(e: PickerConfirmEvent) {
const item = deptOptions.value.find(o => o.value === e.value[0])
if (item)
user.value.dept_id = item.value
showPickerDept.value = false
}
function handleRoleConfirm(e: PickerConfirmEvent) {
const item = roleOptions.value.find(o => o.value === e.value[0])
if (item)
user.value.role_ids = [item.value]
showPickerRole.value = false
}
const GENDER_OPTIONS = [
{ value: 0, label: '男' },
{ value: 1, label: '女' },
{ value: 2, label: '未知' },
]
function genderLabel(gender?: number) {
return GENDER_OPTIONS.find(g => g.value === gender)?.label || '未知'
}
function flattenDept(items: DeptItem[], prefix = ''): { value: number, label: string }[] {
let r: { value: number, label: string }[] = []
for (const item of items) {
r.push({ value: item.id, label: prefix + item.name })
if (item.children?.length)
r = r.concat(flattenDept(item.children, `${prefix} `))
}
return r
}
async function loadOptions() {
try {
const [deptTree, roleRes] = await Promise.all([
DeptAPI.getTree().catch(() => [] as DeptItem[]),
RoleAPI.getOptions().catch(() => []),
])
deptOptions.value = flattenDept(deptTree || [])
roleOptions.value = (roleRes || []).map(r => ({ value: r.value, label: r.label }))
}
catch { /* silent */ }
}
async function loadUser() {
if (!userId.value)
return
loading.value = true
try {
const res = await UserAPI.getUserDetail(userId.value)
user.value = res
}
catch {
toast.error('加载用户详情失败')
}
finally {
loading.value = false
}
}
async function handleSave() {
if (!user.value.id)
return
saving.value = true
try {
await UserAPI.updateUser(user.value)
toast.success('保存成功')
isEdit.value = false
}
catch {
toast.error('保存失败')
}
finally {
saving.value = false
}
}
function handleToggleStatus() {
// 后端规范: 0=启用, 1=禁用
const newStatus = user.value.status === 1 ? 0 : 1
const action = newStatus === 0 ? '启用' : '禁用'
uni.showModal({
title: '确认操作',
content: `确认${action}该用户吗?`,
success: async (res) => {
if (res.confirm) {
try {
user.value.status = newStatus
await UserAPI.updateUser(user.value)
toast.success(`${action}成功`)
}
catch {
toast.error('操作失败')
}
}
},
})
}
onLoad((query) => {
userId.value = Number(query?.id)
loadOptions()
loadUser()
})
</script>
<template>
<view class="page-wraper py-3">
<SkeletonPage v-if="loading" :rows="6" />
<template v-else>
<!-- Avatar -->
<view class="mx-3 mb-3 flex flex-col items-center gap-2 py-4">
<wd-avatar
size="80px"
round
:text="(user.name || user.username || '?').charAt(0)"
bg-color="var(--primary-color)"
color="#FFFFFF"
/>
<text class="text-4 font-bold wot-text-text-main">
{{ user.name || user.username || '未知用户' }}
</text>
</view>
<!-- View mode -->
<view v-if="!isEdit" class="mx-3 mb-3">
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="用户名" :value="user.username || '-'" />
<wd-cell title="邮箱" :value="user.email || '-'" />
<wd-cell title="手机号" :value="user.mobile || '-'" />
<wd-cell title="性别" :value="genderLabel(user.gender)" />
<wd-cell title="角色" :value="user.role_names?.join(', ') || roleOptions.find(r => user.role_ids?.includes(r.value))?.label || '-'" />
<wd-cell title="部门" :value="user.dept_name || deptOptions.find(d => d.value === user.dept_id)?.label || '-'" />
<wd-cell title="状态">
<StatusBadge :status="user.status" />
</wd-cell>
</wd-cell-group>
</view>
<!-- Edit mode -->
<view v-else class="mx-3 mb-3">
<view class="rounded-2 p-3 wot-bg-filled-oppo">
<wd-form :model="user">
<wd-form-item label="用户名" border>
<wd-input v-model="user.username" placeholder="请输入用户名" />
</wd-form-item>
<wd-form-item label="姓名" border>
<wd-input v-model="user.name" placeholder="请输入姓名" />
</wd-form-item>
<wd-form-item label="邮箱" border>
<wd-input v-model="user.email" placeholder="请输入邮箱" />
</wd-form-item>
<wd-form-item label="手机号" border>
<wd-input v-model="user.mobile" placeholder="请输入手机号" />
</wd-form-item>
<wd-form-item label="性别" border>
<wd-radio-group v-model="user.gender">
<wd-radio v-for="g in GENDER_OPTIONS" :key="g.value" :value="g.value">
{{ g.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="部门" border>
<view class="flex-1" @click="showPickerDept = true">
<wd-cell :value="deptOptions.find(o => o.value === user.dept_id)?.label || '选择部门'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerDept" :columns="[deptOptions]" @confirm="handleDeptConfirm" @cancel="showPickerDept = false" />
</wd-form-item>
<wd-form-item label="角色" border>
<view class="flex-1" @click="showPickerRole = true">
<wd-cell :value="roleOptions.find(o => o.value === user.role_ids?.[0])?.label || '选择角色'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerRole" :columns="[roleOptions]" @confirm="handleRoleConfirm" @cancel="showPickerRole = false" />
</wd-form-item>
</wd-form>
</view>
</view>
<!-- Actions -->
<view v-if="isEdit" class="mx-3 flex flex-col gap-3">
<wd-button type="primary" round block :loading="saving" @click="handleSave">
保存修改
</wd-button>
<wd-button variant="plain" round block @click="isEdit = false">
取消
</wd-button>
</view>
<view v-else class="mx-3 flex flex-col gap-3">
<wd-button type="primary" round block @click="isEdit = true">
编辑资料
</wd-button>
<wd-button type="danger" plain round block @click="handleToggleStatus">
{{ user.status === 1 ? '启用账号' : '禁用账号' }}
</wd-button>
</view>
</template>
</view>
</template>
@@ -0,0 +1,315 @@
<script setup lang="ts">
import type { DeptItem } from '@/api/module_system/dept'
import type { PositionItem } from '@/api/module_system/position'
import type { RoleItem } from '@/api/module_system/role'
import type { UserForm, UserInfo } from '@/api/module_system/user'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { DeptAPI } from '@/api/module_system/dept'
import { PositionAPI } from '@/api/module_system/position'
import { RoleAPI } from '@/api/module_system/role'
import UserAPI from '@/api/module_system/user'
definePage({ name: 'work-users', style: { navigationBarTitleText: '用户管理' } })
const router = useRouter()
const toast = useToast()
const searchName = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<UserForm>({
username: '',
name: '',
password: '',
dept_id: undefined,
role_ids: [],
position_ids: [],
email: '',
mobile: '',
gender: 2,
status: 1,
description: '',
})
const GENDER_OPTIONS = [{ value: 0, label: '男' }, { value: 1, label: '女' }, { value: 2, label: '未知' }]
const STATUS_OPTIONS = [{ value: 0, label: '正常' }, { value: 1, label: '禁用' }]
const deptOptions = ref<{ value: number, label: string }[]>([])
const roleOptions = ref<{ value: number, label: string }[]>([])
const positionOptions = ref<{ value: number, label: string }[]>([])
const showPickerDept = ref(false)
const showPickerRole = ref(false)
const showPickerPosition = ref(false)
interface PickerConfirmEvent { value: Array<string | number>, selectedOptions: any[] }
function handleDeptConfirm(e: PickerConfirmEvent) {
const item = deptOptions.value.find(o => o.value === e.value[0])
if (item)
formData.dept_id = item.value
showPickerDept.value = false
}
function handleRoleConfirm(e: PickerConfirmEvent) {
const item = roleOptions.value.find(o => o.value === e.value[0])
if (item)
formData.role_ids = [item.value]
showPickerRole.value = false
}
function handlePositionConfirm(e: PickerConfirmEvent) {
const item = positionOptions.value.find(o => o.value === e.value[0])
if (item)
formData.position_ids = [item.value]
showPickerPosition.value = false
}
function flattenDept(items: DeptItem[], prefix = ''): { value: number, label: string }[] {
let r: { value: number, label: string }[] = []
for (const item of items) {
r.push({ value: item.id, label: prefix + item.name })
if (item.children?.length)
r = r.concat(flattenDept(item.children, `${prefix} `))
}
return r
}
async function loadOptions() {
try {
const [depts, roles, positions] = await Promise.all([
DeptAPI.getTree(),
RoleAPI.getPage({ page_no: 1, page_size: 1000 }),
PositionAPI.getPage({ page_no: 1, page_size: 1000 }),
])
deptOptions.value = flattenDept(depts || [])
roleOptions.value = (roles.list || []).map((r: RoleItem) => ({ value: r.id, label: r.name || '' }))
positionOptions.value = (positions.list || []).map((p: PositionItem) => ({ value: p.id, label: p.name || '' }))
}
catch {
deptOptions.value = []
roleOptions.value = []
positionOptions.value = []
}
}
const { list, total, loading, pageParams, loadData, toFirst, loadNext } = useListPage<UserInfo>({
fetcher: p => UserAPI.getUserPage({ ...p, name: searchName.value || undefined }),
onError: () => toast.error('加载失败'),
})
function handlePageChange({ value }: { value: number }) {
pageParams.value.page_no = value
loadData()
}
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchName.value = ''
toFirst()
loadData()
}
const initForm: UserForm = { username: '', name: '', password: '', dept_id: undefined, role_ids: [], position_ids: [], email: '', mobile: '', gender: 2, status: 1, description: '' }
function openCreate() {
formTitle.value = '新增用户'
currentId.value = undefined
Object.assign(formData, { ...initForm })
if (deptOptions.value.length === 0)
loadOptions()
showForm.value = true
}
function openEdit(id: number) {
router.push({ name: 'work-user-detail', query: { id: String(id) } })
}
async function handleSubmit() {
loading.value = true
try {
const payload = { ...formData }
if (currentId.value) {
await UserAPI.updateUser({ ...payload, id: currentId.value })
toast.success('更新成功')
}
else {
await UserAPI.addUser(payload)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch { toast.error('操作失败') }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({ title: '提示', content: '确认删除该用户?', success: async (res) => {
if (res.confirm) {
try {
await UserAPI.deleteUser([id])
toast.success('删除成功')
loadData()
}
catch { toast.error('删除失败') }
}
} })
}
// Avatar color generator
const avatarColors = ['#014DB2', '#F59E0B', '#10B981', '#8B5CF6', '#EF4444', '#EC4899', '#06B6D4']
function getAvatarColor(index: number) {
return avatarColors[index % avatarColors.length]
}
function getAvatarInitial(name: string) {
return (name || '?').charAt(0)
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<!-- Search bar -->
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchName" placeholder="搜索姓名或用户名" clearable class="flex-1" />
<wd-button size="small" type="primary" variant="plain" @click="onSearch">
搜索
</wd-button>
<wd-button size="small" variant="plain" @click="onReset">
重置
</wd-button>
</view>
</view>
<!-- Meta bar -->
<view class="action-bar">
<text class="text-md text-muted font-bold">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">
+ 新增
</wd-button>
</view>
<!-- List -->
<SkeletonPage v-if="loading && list.length === 0" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<wd-empty v-if="!loading && list.length === 0" tip="暂无用户" />
<wd-cell-group v-else>
<wd-cell v-for="(item, index) in list" :key="item.id" center @click="openEdit(item.id!)">
<template #title>
<view class="flex items-center gap-2">
<wd-avatar
size="40px"
round
:text="getAvatarInitial(item.name || item.username || '')"
:bg-color="getAvatarColor(index)"
color="#FFFFFF"
/>
<view class="min-w-0">
<view class="truncate text-3.5 font-medium wot-text-text-main">
{{ item.name || item.username }}
</view>
<view class="mt-1 truncate text-2.5 wot-text-text-auxiliary">
{{ item.username }} · {{ item.email || '-' }}
</view>
</view>
</view>
</template>
<template #default>
<view class="flex items-center gap-2">
<StatusBadge :status="item.status" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<wd-pagination
:model-value="pageParams.page_no"
:total="total"
:page-size="pageParams.page_size"
button-variant="plain"
hide-if-one-page
@change="handlePageChange"
/>
</template>
<!-- Form popup -->
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="用户名" border>
<wd-input v-model="formData.username" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="姓名" border>
<wd-input v-model="formData.name" placeholder="请输入" />
</wd-form-item>
<wd-form-item v-if="!currentId" label="密码" border>
<wd-input v-model="formData.password" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="部门" border>
<view class="flex-1" @click="showPickerDept = true">
<wd-cell :value="deptOptions.find(o => o.value === formData.dept_id)?.label || '请选择部门'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerDept" :columns="[deptOptions]" @confirm="handleDeptConfirm" @cancel="showPickerDept = false" />
</wd-form-item>
<wd-form-item label="角色" border>
<view class="flex-1" @click="showPickerRole = true">
<wd-cell :value="roleOptions.find(o => o.value === formData.role_ids?.[0])?.label || '请选择角色'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerRole" :columns="[roleOptions]" @confirm="handleRoleConfirm" @cancel="showPickerRole = false" />
</wd-form-item>
<wd-form-item label="岗位" border>
<view class="flex-1" @click="showPickerPosition = true">
<wd-cell :value="positionOptions.find(o => o.value === formData.position_ids?.[0])?.label || '请选择岗位'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerPosition" :columns="[positionOptions]" @confirm="handlePositionConfirm" @cancel="showPickerPosition = false" />
</wd-form-item>
<wd-form-item label="性别" border>
<wd-radio-group v-model="formData.gender">
<wd-radio v-for="opt in GENDER_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="状态" border>
<wd-radio-group v-model="formData.status">
<wd-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="邮箱" border>
<wd-input v-model="formData.email" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="手机号" border>
<wd-input v-model="formData.mobile" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="备注" border>
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="gap-md mt-xl flex">
<wd-button variant="plain" block @click="showForm = false">
取消
</wd-button>
<wd-button block type="primary" :loading="loading" @click="handleSubmit">
保存
</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>