refactor: 小程序端代码瘦身与功能补齐

- 清理冗余代码与配置:
  - 删除废弃的 mock 适配器、通用 mock 模块与示例API
  - 移除冗余的 ts 类型文件与 patch 目录
  - 简化 tsconfig 组件路径配置
- 新增功能与优化:
  - 新增全局水印组件与水印配置
  - 接入微信小程序登录/手机号登录
  - 新增系统参数配置 store 与国际化支持
  - 新增 markdown 渲染插件与样式配置
  - 优化 pinia 持久化逻辑修复
  - 新增微信小程序 access_token 缓存配置
  - 修复登录趋势数据结构定义
  - 优化全局请求防抖重定向逻辑
- 页面与路由优化:
  - 补全引导页首次启动逻辑
  - 统一页面底部安全区适配
  - 更新页面路由类型声明
- 代码质量优化:
  - 移除无用 console.log 调试代码
  - 修复全局弹窗重复重登录问题
  - 优化 mp-html 组件配置
This commit is contained in:
zhangtao
2026-08-05 23:47:49 +08:00
parent 372b277a9f
commit ffbe2453f2
126 changed files with 5288 additions and 12246 deletions
@@ -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>