mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-01 16:21:20 +00:00
refactor: 小程序端代码瘦身与功能补齐
- 清理冗余代码与配置: - 删除废弃的 mock 适配器、通用 mock 模块与示例API - 移除冗余的 ts 类型文件与 patch 目录 - 简化 tsconfig 组件路径配置 - 新增功能与优化: - 新增全局水印组件与水印配置 - 接入微信小程序登录/手机号登录 - 新增系统参数配置 store 与国际化支持 - 新增 markdown 渲染插件与样式配置 - 优化 pinia 持久化逻辑修复 - 新增微信小程序 access_token 缓存配置 - 修复登录趋势数据结构定义 - 优化全局请求防抖重定向逻辑 - 页面与路由优化: - 补全引导页首次启动逻辑 - 统一页面底部安全区适配 - 更新页面路由类型声明 - 代码质量优化: - 移除无用 console.log 调试代码 - 修复全局弹窗重复重登录问题 - 优化 mp-html 组件配置
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import type { PasswordChangeForm } from '@/api/module_system/user'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import UserAPI from '@/api/module_system/user'
|
||||
|
||||
definePage({
|
||||
name: 'account',
|
||||
layout: 'default',
|
||||
style: { navigationBarTitleText: '修改密码' },
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const showPasswordDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const passwordForm = reactive<PasswordChangeForm>({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
function openPasswordDialog() {
|
||||
passwordForm.old_password = ''
|
||||
passwordForm.new_password = ''
|
||||
passwordForm.confirm_password = ''
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
/** 提交修改密码:字段级校验 + 调用后端接口 */
|
||||
async function handleSubmit() {
|
||||
if (!passwordForm.old_password) {
|
||||
toast.warning(t('account.oldRequired'))
|
||||
return
|
||||
}
|
||||
if (!passwordForm.new_password) {
|
||||
toast.warning(t('account.newRequired'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.new_password.length < 6) {
|
||||
toast.warning(t('account.newLength'))
|
||||
return
|
||||
}
|
||||
if (!passwordForm.confirm_password) {
|
||||
toast.warning(t('account.confirmRequired'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.confirm_password !== passwordForm.new_password) {
|
||||
toast.warning(t('account.mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await UserAPI.changeCurrentUserPassword({
|
||||
old_password: passwordForm.old_password,
|
||||
new_password: passwordForm.new_password,
|
||||
confirm_password: passwordForm.confirm_password,
|
||||
})
|
||||
toast.success(t('account.success'))
|
||||
showPasswordDialog.value = false
|
||||
}
|
||||
catch {
|
||||
// http 层已统一错误提示
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page-wraper py-3">
|
||||
<view class="mx-3">
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
<wd-cell
|
||||
:title="$t('account.password')"
|
||||
:label="$t('account.passwordTip')"
|
||||
is-link
|
||||
@click="openPasswordDialog"
|
||||
>
|
||||
<template #value>
|
||||
<text class="wot-text-text-secondary">
|
||||
{{ $t('account.modify') }}
|
||||
</text>
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<wd-popup
|
||||
v-model="showPasswordDialog"
|
||||
position="bottom"
|
||||
round
|
||||
custom-style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
<view class="px-4 pb-4 pt-4">
|
||||
<view class="mb-4 text-center text-4 font-bold wot-text-text-main">
|
||||
{{ $t('account.password') }}
|
||||
</view>
|
||||
<wd-input
|
||||
v-model="passwordForm.old_password"
|
||||
:label="$t('account.oldPassword')"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('account.oldPlaceholder')"
|
||||
/>
|
||||
<view class="h-3" />
|
||||
<wd-input
|
||||
v-model="passwordForm.new_password"
|
||||
:label="$t('account.newPassword')"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('account.newPlaceholder')"
|
||||
/>
|
||||
<view class="h-3" />
|
||||
<wd-input
|
||||
v-model="passwordForm.confirm_password"
|
||||
:label="$t('account.confirmPassword')"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="$t('account.confirmPlaceholder')"
|
||||
/>
|
||||
<view class="mt-5">
|
||||
<wd-button type="primary" round block :loading="submitting" @click="handleSubmit">
|
||||
{{ $t('account.submit') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-gap :height="20" />
|
||||
</wd-popup>
|
||||
</view>
|
||||
</template>
|
||||
@@ -166,6 +166,8 @@ onLoad(() => loadData())
|
||||
hide-if-one-page
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
<!-- 底部安全区(全面屏 Home 条避让) -->
|
||||
<wd-gap height="100rpx" safe-area-bottom />
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserInfo, UserProfileForm } from '@/api/module_system/user'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import UserAPI from '@/api/module_system/user'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
definePage({
|
||||
name: 'profile',
|
||||
layout: 'default',
|
||||
style: { navigationBarTitleText: '个人资料' },
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const userProfile = ref<UserInfo>()
|
||||
|
||||
/** 头像上传:后端通用文件上传(upload_type=avatar),字段名 file */
|
||||
const uploadAvatarAction = `${import.meta.env.VITE_API_BASE_URL || ''}${import.meta.env.VITE_APP_BASE_API || ''}/common/file/upload?upload_type=avatar`
|
||||
const uploadHeader = { Authorization: `Bearer ${userStore.getAccessToken() || ''}` }
|
||||
const avatarFileList = ref<{ url: string }[]>([])
|
||||
|
||||
const genderText = computed(() => {
|
||||
const gender = userProfile.value?.gender
|
||||
return gender === '0' ? t('profile.male') : gender === '1' ? t('profile.female') : t('profile.unknown')
|
||||
})
|
||||
const deptText = computed(() => userProfile.value?.dept_name || '-')
|
||||
const roleText = computed(() => userProfile.value?.role_names?.join('、') || '-')
|
||||
|
||||
async function loadUserProfile() {
|
||||
loading.value = true
|
||||
try {
|
||||
userProfile.value = await UserAPI.getCurrentUserInfo()
|
||||
}
|
||||
catch {
|
||||
// http 层已统一错误提示
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存个人资料(昵称/性别/头像变更均走此接口) */
|
||||
async function saveProfile(patch: Partial<UserProfileForm>) {
|
||||
const profile = userProfile.value
|
||||
if (!profile)
|
||||
return
|
||||
saving.value = true
|
||||
try {
|
||||
await UserAPI.updateCurrentUserInfo({
|
||||
name: patch.name ?? profile.name ?? '',
|
||||
gender: patch.gender ?? profile.gender ?? '0',
|
||||
mobile: profile.mobile || '',
|
||||
email: profile.email || '',
|
||||
username: profile.username || '',
|
||||
dept_name: profile.dept_name || '',
|
||||
positions: profile.positions || [],
|
||||
roles: profile.roles || [],
|
||||
avatar: patch.avatar ?? (profile.avatar || ''),
|
||||
})
|
||||
toast.success(t('profile.saved'))
|
||||
await loadUserProfile()
|
||||
// 同步全局用户信息,使「我的」页展示最新昵称/头像
|
||||
userStore.setUserInfo({ ...userStore.userInfo, ...patch })
|
||||
}
|
||||
catch {
|
||||
// http 层已统一错误提示
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 头像上传成功:wd-upload 的 response 为字符串,解析后保存 */
|
||||
function handleAvatarSuccess(e: { file: { response?: string | Record<string, any> } }) {
|
||||
try {
|
||||
const raw = typeof e.file.response === 'string' ? e.file.response : JSON.stringify(e.file.response || {})
|
||||
const res = JSON.parse(raw) as { code: number, msg?: string, data?: { file_url?: string } }
|
||||
if (res.code === 0 && res.data?.file_url) {
|
||||
avatarFileList.value = []
|
||||
saveProfile({ avatar: res.data.file_url })
|
||||
}
|
||||
else {
|
||||
toast.error(res.msg || t('profile.uploadFailed'))
|
||||
avatarFileList.value = []
|
||||
}
|
||||
}
|
||||
catch {
|
||||
toast.error(t('profile.uploadFailed'))
|
||||
avatarFileList.value = []
|
||||
}
|
||||
}
|
||||
function handleAvatarFail() {
|
||||
toast.error(t('profile.uploadFailed'))
|
||||
avatarFileList.value = []
|
||||
}
|
||||
|
||||
/** 昵称 / 性别编辑弹窗 */
|
||||
const editDialog = reactive<{ visible: boolean, field: 'name' | 'gender' }>({ visible: false, field: 'name' })
|
||||
const editForm = reactive<{ name: string, gender: string }>({ name: '', gender: '0' })
|
||||
const editTitle = computed(() => editDialog.field === 'name' ? t('profile.editNickname') : t('profile.selectGender'))
|
||||
|
||||
function openEdit(field: 'name' | 'gender') {
|
||||
editDialog.field = field
|
||||
editDialog.visible = true
|
||||
if (field === 'name')
|
||||
editForm.name = userProfile.value?.name || ''
|
||||
else
|
||||
editForm.gender = userProfile.value?.gender || '0'
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
if (editDialog.field === 'name') {
|
||||
const name = editForm.name.trim()
|
||||
if (!name) {
|
||||
toast.warning(t('profile.nicknameRequired'))
|
||||
return
|
||||
}
|
||||
await saveProfile({ name })
|
||||
}
|
||||
else {
|
||||
await saveProfile({ gender: editForm.gender })
|
||||
}
|
||||
editDialog.visible = false
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
loadUserProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page-wraper py-3">
|
||||
<SkeletonPage v-if="loading && !userProfile" :rows="6" />
|
||||
|
||||
<template v-else>
|
||||
<!-- 头像 + 昵称 -->
|
||||
<view class="mx-3 mb-3 flex flex-col items-center gap-2 py-4">
|
||||
<wd-avatar
|
||||
size="80px"
|
||||
round
|
||||
:src="userProfile?.avatar || ''"
|
||||
:text="(userProfile?.name || userProfile?.username || '?').charAt(0)"
|
||||
bg-color="var(--primary-color)"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<text class="text-4 font-bold wot-text-text-main">
|
||||
{{ userProfile?.name || userProfile?.username || '-' }}
|
||||
</text>
|
||||
<view class="mt-2 w-full px-10">
|
||||
<wd-upload
|
||||
v-model="avatarFileList"
|
||||
:action="uploadAvatarAction"
|
||||
:header="uploadHeader"
|
||||
:limit="1"
|
||||
accept="image"
|
||||
:max-size="5242880"
|
||||
:source-type="['album', 'camera']"
|
||||
@success="handleAvatarSuccess"
|
||||
@fail="handleAvatarFail"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 资料列表 -->
|
||||
<view class="mx-3 mb-3">
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
<wd-cell :title="$t('profile.nickname')" is-link @click="openEdit('name')">
|
||||
<text class="wot-text-text-secondary">
|
||||
{{ userProfile?.name || $t('profile.notSet') }}
|
||||
</text>
|
||||
</wd-cell>
|
||||
<wd-cell :title="$t('profile.gender')" is-link @click="openEdit('gender')">
|
||||
<text class="wot-text-text-secondary">
|
||||
{{ genderText }}
|
||||
</text>
|
||||
</wd-cell>
|
||||
<wd-cell :title="$t('profile.username')" :value="userProfile?.username || '-'" />
|
||||
<wd-cell :title="$t('profile.mobile')">
|
||||
<text class="wot-text-text-secondary">
|
||||
{{ userProfile?.mobile || $t('profile.notBound') }}
|
||||
</text>
|
||||
</wd-cell>
|
||||
<wd-cell :title="$t('profile.email')">
|
||||
<text class="wot-text-text-secondary">
|
||||
{{ userProfile?.email || $t('profile.notBound') }}
|
||||
</text>
|
||||
</wd-cell>
|
||||
<wd-cell :title="$t('profile.dept')" :value="deptText" />
|
||||
<wd-cell :title="$t('profile.roles')" :value="roleText" />
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 昵称 / 性别编辑弹窗 -->
|
||||
<wd-popup
|
||||
v-model="editDialog.visible"
|
||||
position="bottom"
|
||||
round
|
||||
custom-style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
<view class="px-4 pb-4 pt-4 text-center text-4 font-bold wot-text-text-main">
|
||||
{{ editTitle }}
|
||||
</view>
|
||||
<view class="px-4 pb-4">
|
||||
<wd-input
|
||||
v-if="editDialog.field === 'name'"
|
||||
v-model="editForm.name"
|
||||
:placeholder="$t('profile.nickname')"
|
||||
clearable
|
||||
/>
|
||||
<wd-radio-group v-else v-model="editForm.gender" direction="horizontal">
|
||||
<wd-radio value="0">
|
||||
{{ $t('profile.male') }}
|
||||
</wd-radio>
|
||||
<wd-radio value="1">
|
||||
{{ $t('profile.female') }}
|
||||
</wd-radio>
|
||||
</wd-radio-group>
|
||||
<view class="mt-4">
|
||||
<wd-button type="primary" round block :loading="saving" @click="handleEditSubmit">
|
||||
{{ $t('profile.save') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-gap :height="20" />
|
||||
</wd-popup>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
@@ -3,6 +3,13 @@ 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'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { MARKDOWN_TAG_STYLE } from '@/constants/markdown.constant'
|
||||
|
||||
useShare(() => ({
|
||||
title: ticket.value ? `工单:${ticket.value.title}` : '工单详情',
|
||||
path: `/subPages/module_system/ticket-detail/index?id=${ticketId.value}`,
|
||||
}))
|
||||
|
||||
definePage({
|
||||
name: 'work-ticket-detail',
|
||||
@@ -18,6 +25,7 @@ const commentPage = reactive({ page_no: 1, page_size: 20 })
|
||||
const commentText = ref('')
|
||||
const submitting = ref(false)
|
||||
const ticketId = ref(0)
|
||||
const previewRef = ref<{ open: (options: { images: string[], startPosition?: number }) => void }>()
|
||||
|
||||
async function loadTicket() {
|
||||
if (!ticketId.value)
|
||||
@@ -93,7 +101,7 @@ function parseImages(images?: string): string[] {
|
||||
}
|
||||
|
||||
function previewImages(images: string[], current: number) {
|
||||
uni.previewImage({ urls: images, current })
|
||||
previewRef.value?.open({ images, startPosition: current })
|
||||
}
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
@@ -134,6 +142,19 @@ onLoad((options) => {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Ticket Status Steps -->
|
||||
<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">
|
||||
处理进度
|
||||
</text>
|
||||
<wd-steps :active="Number(ticket.status ?? 0)" align-center>
|
||||
<wd-step title="待处理" />
|
||||
<wd-step title="处理中" />
|
||||
<wd-step title="已完成" />
|
||||
<wd-step title="已关闭" />
|
||||
</wd-steps>
|
||||
</view>
|
||||
|
||||
<!-- Ticket Info -->
|
||||
<view class="mx-3 mb-3">
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
@@ -145,25 +166,27 @@ onLoad((options) => {
|
||||
|
||||
<!-- 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 class="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>
|
||||
<wd-divider class="my-3!" />
|
||||
<mp-html :content="ticket.summary || ticket.ticket_content" markdown :tag-style="MARKDOWN_TAG_STYLE" />
|
||||
</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 class="block text-3.5 font-bold wot-text-text-main">
|
||||
附件图片
|
||||
</text>
|
||||
<wd-divider class="my-3!" />
|
||||
<view class="flex flex-wrap gap-3">
|
||||
<image
|
||||
<wd-img
|
||||
v-for="(img, idx) in parseImages(ticket.images)"
|
||||
:key="img"
|
||||
:src="img"
|
||||
class="h-[200rpx] w-[200rpx] rounded-lg wot-bg-filled-oppo"
|
||||
width="200rpx"
|
||||
height="200rpx"
|
||||
radius="16rpx"
|
||||
mode="aspectFill"
|
||||
lazy-load
|
||||
@click="previewImages(parseImages(ticket.images), idx)"
|
||||
@@ -173,21 +196,21 @@ onLoad((options) => {
|
||||
|
||||
<!-- 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 class="block text-3.5 font-bold wot-text-text-main">
|
||||
处理回复
|
||||
</text>
|
||||
<wd-divider class="my-3!" />
|
||||
<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>
|
||||
<mp-html :content="ticket.reply" markdown :tag-style="MARKDOWN_TAG_STYLE" />
|
||||
</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">
|
||||
<text class="block text-3.5 font-bold wot-text-text-main">
|
||||
评论 ({{ commentTotal }})
|
||||
</text>
|
||||
<wd-divider class="my-3!" />
|
||||
<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">
|
||||
@@ -222,6 +245,9 @@ onLoad((options) => {
|
||||
|
||||
<wd-empty v-else tip="工单不存在或已删除" />
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<wd-image-preview ref="previewRef" />
|
||||
|
||||
<!-- Comment Input -->
|
||||
<view
|
||||
v-if="ticket"
|
||||
|
||||
@@ -21,6 +21,31 @@ const TYPE_OPTIONS = [{ value: 'suggestion', label: '建议' }, { value: 'bug',
|
||||
|
||||
const STATUS_OPTIONS = [{ value: 0, label: '待处理' }, { value: 1, label: '处理中' }, { value: 2, label: '已完成' }, { value: 3, label: '已关闭' }]
|
||||
|
||||
/** 顶部状态筛选 Tab */
|
||||
const TAB_OPTIONS = [
|
||||
{ title: '全部', value: undefined },
|
||||
{ title: '待处理', value: 0 },
|
||||
{ title: '处理中', value: 1 },
|
||||
{ title: '已完成', value: 2 },
|
||||
{ title: '已关闭', value: 3 },
|
||||
]
|
||||
const activeTab = ref<number | string>(0)
|
||||
const activeStatus = ref<number | undefined>()
|
||||
/** 类型筛选(wd-drop-menu) */
|
||||
const typeFilter = ref<string | number>('')
|
||||
const TYPE_FILTER_OPTIONS = [{ label: '全部类型', value: '' }, ...TYPE_OPTIONS]
|
||||
/** 创建时间范围筛选(时间戳数组,与 wd-datetime-picker 范围模式对齐) */
|
||||
const dateRange = ref<number[]>([])
|
||||
const timeRangeText = ref('')
|
||||
const showDatePicker = ref(false)
|
||||
|
||||
function formatDate(ts: number) {
|
||||
const d = new Date(ts)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
interface PickerConfirmEvent { value: Array<string | number>, selectedOptions: any[] }
|
||||
function handleTypeConfirm(e: PickerConfirmEvent) {
|
||||
const item = TYPE_OPTIONS.find(o => o.value === e.value[0])
|
||||
@@ -39,19 +64,44 @@ const TICKET_STATUS_MAP: Record<number, string> = { 0: 'pending', 1: 'processing
|
||||
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 || ''
|
||||
function typeLabel(type?: string) {
|
||||
return TYPE_OPTIONS.find(o => o.value === type)?.label || type || ''
|
||||
}
|
||||
|
||||
const { list, total, loading, pageParams, loadData, toFirst, loadNext } = useListPage<TicketItem>({
|
||||
fetcher: p => TicketAPI.getPage({ ...p, title: searchTitle.value || undefined }),
|
||||
fetcher: p => TicketAPI.getPage({
|
||||
...p,
|
||||
title: searchTitle.value || undefined,
|
||||
status: activeStatus.value,
|
||||
created_time: dateRange.value.length === 2 ? [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])] : undefined,
|
||||
ticket_type: typeFilter.value === '' ? undefined : String(typeFilter.value),
|
||||
}),
|
||||
onError: () => toast.error('加载失败'),
|
||||
})
|
||||
|
||||
function handleTypeFilter(e: { value: string | number }) {
|
||||
typeFilter.value = e.value
|
||||
toFirst()
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleDateConfirm(e: { value: string | number | Array<string | number> }) {
|
||||
const v = Array.isArray(e.value) ? e.value.map(Number) : []
|
||||
dateRange.value = v
|
||||
showDatePicker.value = false
|
||||
if (v.length === 2) {
|
||||
timeRangeText.value = `${formatDate(v[0])} ~ ${formatDate(v[1])}`
|
||||
toFirst()
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(e: { index: number }) {
|
||||
activeStatus.value = TAB_OPTIONS[e.index]?.value
|
||||
toFirst()
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handlePageChange({ value }: { value: number }) {
|
||||
pageParams.value.page_no = value
|
||||
loadData()
|
||||
@@ -63,6 +113,9 @@ function onSearch() {
|
||||
}
|
||||
function onReset() {
|
||||
searchTitle.value = ''
|
||||
dateRange.value = []
|
||||
timeRangeText.value = ''
|
||||
typeFilter.value = ''
|
||||
toFirst()
|
||||
loadData()
|
||||
}
|
||||
@@ -78,6 +131,11 @@ function openCreate() {
|
||||
function navigateToDetail(id: number) {
|
||||
router.push({ name: 'work-ticket-detail', query: { id: String(id) } })
|
||||
}
|
||||
/** 左滑删除:wd-swipe-action 展开后点击右侧按钮触发 */
|
||||
function onSwipeRight(item: TicketItem, e: { value: string }) {
|
||||
if (e.value === 'right')
|
||||
handleDelete(item.id!)
|
||||
}
|
||||
async function handleSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -124,6 +182,7 @@ onLoad(() => loadData())
|
||||
|
||||
<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" />
|
||||
@@ -134,41 +193,69 @@ onLoad(() => loadData())
|
||||
重置
|
||||
</wd-button>
|
||||
</view>
|
||||
<view class="mt-2 flex items-center gap-1" @click="showDatePicker = true">
|
||||
<wd-icon name="calendar" size="16px" color="var(--text-color-3, #6B7280)" />
|
||||
<text class="text-3 wot-text-text-secondary">
|
||||
{{ timeRangeText || '按创建时间筛选' }}
|
||||
</text>
|
||||
<text v-if="timeRangeText" class="ml-1 text-3 wot-text-primary" @click.stop="onReset">
|
||||
清除
|
||||
</text>
|
||||
</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 />
|
||||
|
||||
<!-- 创建时间范围选择 -->
|
||||
<wd-datetime-picker
|
||||
v-model="dateRange"
|
||||
:visible="showDatePicker"
|
||||
type="date"
|
||||
title="选择创建时间范围"
|
||||
@confirm="handleDateConfirm"
|
||||
@cancel="showDatePicker = false"
|
||||
/>
|
||||
|
||||
<!-- 状态筛选(吸顶) -->
|
||||
<wd-tabs v-model="activeTab" line-theme="text" sticky :offset-top="0" @change="handleTabChange">
|
||||
<wd-tab v-for="opt in TAB_OPTIONS" :key="opt.title" :title="opt.title" />
|
||||
</wd-tabs>
|
||||
|
||||
<!-- 类型筛选下拉 -->
|
||||
<wd-drop-menu class="mt-2" :z-index="9">
|
||||
<wd-drop-menu-item v-model="typeFilter" :options="TYPE_FILTER_OPTIONS" title="类型" @change="handleTypeFilter" />
|
||||
</wd-drop-menu>
|
||||
|
||||
<!-- 卡片列表(左滑删除) -->
|
||||
<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 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) }}
|
||||
<view class="mt-2 px-sm">
|
||||
<wd-empty v-if="!loading && list.length === 0" tip="暂无工单" />
|
||||
<template v-else>
|
||||
<wd-swipe-action
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="mb-3"
|
||||
@click="onSwipeRight(item, $event)"
|
||||
>
|
||||
<template #right>
|
||||
<view class="h-full flex items-center justify-center px-6" style="background-color: var(--danger-color); color: #fff;">
|
||||
<text class="text-3">
|
||||
删除
|
||||
</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>
|
||||
</template>
|
||||
<view class="admin-card flex items-center gap-3 rounded-2 p-4 wot-bg-filled-oppo" @click="navigateToDetail(item.id!)">
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="truncate text-3.5 font-medium wot-text-text-main">
|
||||
{{ item.title }}
|
||||
</view>
|
||||
<view class="mt-1 truncate text-2.5 wot-text-text-auxiliary">
|
||||
{{ typeLabel(item.ticket_type) }}{{ item.ticket_content ? ` · ${item.ticket_content}` : '' }}
|
||||
</view>
|
||||
</view>
|
||||
<StatusBadge :status="ticketStatus(item.status)" />
|
||||
</view>
|
||||
</wd-swipe-action>
|
||||
</template>
|
||||
</view>
|
||||
<wd-pagination
|
||||
:model-value="pageParams.page_no"
|
||||
@@ -178,30 +265,37 @@ onLoad(() => loadData())
|
||||
hide-if-one-page
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
<!-- 底部安全区(全面屏 Home 条避让) -->
|
||||
<wd-gap height="100rpx" safe-area-bottom />
|
||||
</template>
|
||||
|
||||
<!-- 新增悬浮按钮(bottom gap 上移避让 Home 条) -->
|
||||
<wd-fab position="right-bottom" :expandable="false" :gap="{ bottom: 40 }" @click="openCreate" />
|
||||
|
||||
<!-- 表单弹窗 -->
|
||||
<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-form-item label="工单标题" border>
|
||||
<wd-input v-model="formData.title" placeholder="请输入" />
|
||||
</wd-form-item>
|
||||
<wd-form-item label="内容">
|
||||
<wd-form-item label="内容" border>
|
||||
<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" />
|
||||
<wd-form-item label="类型" border>
|
||||
<view class="flex-1" @click="showPickerType = true">
|
||||
<wd-cell :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" />
|
||||
<wd-form-item v-if="currentId" label="状态" border>
|
||||
<view class="flex-1" @click="showPickerStatus = true">
|
||||
<wd-cell :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-form-item label="备注" border>
|
||||
<wd-textarea v-model="formData.description" placeholder="请输入" />
|
||||
</wd-form-item>
|
||||
</wd-form>
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
<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>
|
||||
@@ -1,315 +0,0 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user