Bladeren bron

feat:增加主包(tabbar)、system(系统管理)、infra(基础设施)、bpm(工作流程)的页面

YunaiV 4 maanden geleden
bovenliggende
commit
cc94b2a5f7
97 gewijzigde bestanden met toevoegingen van 12198 en 81 verwijderingen
  1. 13 0
      src/api/bpm/category/index.ts
  2. 20 0
      src/api/bpm/definition/index.ts
  3. 68 0
      src/api/bpm/processInstance/index.ts
  4. 50 0
      src/api/bpm/task/index.ts
  5. 36 0
      src/api/infra/apiAccessLog/index.ts
  6. 45 0
      src/api/infra/apiErrorLog/index.ts
  7. 82 0
      src/api/infra/file/index.ts
  8. 111 23
      src/api/login.ts
  9. 45 0
      src/api/system/dept/index.ts
  10. 20 0
      src/api/system/dict/data/index.ts
  11. 51 0
      src/api/system/menu/index.ts
  12. 39 0
      src/api/system/notify/index.ts
  13. 43 0
      src/api/system/post/index.ts
  14. 46 0
      src/api/system/role/index.ts
  15. 72 0
      src/api/system/user/index.ts
  16. 48 0
      src/api/system/user/profile/index.ts
  17. 14 2
      src/api/types/login.ts
  18. 233 0
      src/pages-bpm/processInstance/create/index.vue
  19. 128 0
      src/pages-bpm/processInstance/detail/audit/index.vue
  20. 267 0
      src/pages-bpm/processInstance/detail/index.vue
  21. 97 0
      src/pages-infra/apiAccessLog/components/search-form.vue
  22. 132 0
      src/pages-infra/apiAccessLog/detail/index.vue
  23. 173 0
      src/pages-infra/apiAccessLog/index.vue
  24. 119 0
      src/pages-infra/apiErrorLog/components/search-form.vue
  25. 165 0
      src/pages-infra/apiErrorLog/detail/index.vue
  26. 167 0
      src/pages-infra/apiErrorLog/index.vue
  27. 490 0
      src/pages-infra/webSocket/index.vue
  28. 84 0
      src/pages-system/dept/components/breadcrumb.vue
  29. 152 0
      src/pages-system/dept/detail/index.vue
  30. 258 0
      src/pages-system/dept/form/index.vue
  31. 153 0
      src/pages-system/dept/index.vue
  32. 98 0
      src/pages-system/menu/components/breadcrumb.vue
  33. 155 0
      src/pages-system/menu/detail/index.vue
  34. 143 0
      src/pages-system/menu/form/components/menu-picker.vue
  35. 257 0
      src/pages-system/menu/form/index.vue
  36. 180 0
      src/pages-system/menu/index.vue
  37. 116 0
      src/pages-system/post/components/search-form.vue
  38. 123 0
      src/pages-system/post/detail/index.vue
  39. 160 0
      src/pages-system/post/form/index.vue
  40. 171 0
      src/pages-system/post/index.vue
  41. 116 0
      src/pages-system/role/components/search-form.vue
  42. 124 0
      src/pages-system/role/detail/index.vue
  43. 161 0
      src/pages-system/role/form/index.vue
  44. 174 0
      src/pages-system/role/index.vue
  45. 96 0
      src/pages-system/user/components/search-form.vue
  46. 106 0
      src/pages-system/user/detail/components/password-form.vue
  47. 80 0
      src/pages-system/user/detail/components/role-assign-form.vue
  48. 207 0
      src/pages-system/user/detail/index.vue
  49. 129 0
      src/pages-system/user/form/components/dept-picker.vue
  50. 82 0
      src/pages-system/user/form/components/post-picker.vue
  51. 201 0
      src/pages-system/user/form/index.vue
  52. 183 0
      src/pages-system/user/index.vue
  53. 135 0
      src/pages/auth/code-login.vue
  54. 90 0
      src/pages/auth/components/code-input.vue
  55. 12 0
      src/pages/auth/components/header.vue
  56. 134 0
      src/pages/auth/components/tenant-picker.vue
  57. 160 0
      src/pages/auth/forget-password.vue
  58. 185 0
      src/pages/auth/login.vue
  59. 179 0
      src/pages/auth/register.vue
  60. 63 0
      src/pages/auth/styles/auth.scss
  61. 148 0
      src/pages/bpm/components/copy-list.vue
  62. 164 0
      src/pages/bpm/components/copy-search-form.vue
  63. 147 0
      src/pages/bpm/components/done-list.vue
  64. 246 0
      src/pages/bpm/components/done-search-form.vue
  65. 139 0
      src/pages/bpm/components/index.scss
  66. 172 0
      src/pages/bpm/components/my-list.vue
  67. 246 0
      src/pages/bpm/components/my-search-form.vue
  68. 167 0
      src/pages/bpm/components/todo-list.vue
  69. 246 0
      src/pages/bpm/components/todo-search-form.vue
  70. 85 0
      src/pages/bpm/index.vue
  71. 84 0
      src/pages/contact/components/breadcrumb.vue
  72. 169 0
      src/pages/contact/index.vue
  73. 30 0
      src/pages/error/404.vue
  74. 33 0
      src/pages/index/components/banner.vue
  75. 87 0
      src/pages/index/components/menu-grid.vue
  76. 79 0
      src/pages/index/components/menu-section.vue
  77. 76 0
      src/pages/index/components/user-header.vue
  78. 173 0
      src/pages/index/index.ts
  79. 20 42
      src/pages/index/index.vue
  80. 200 0
      src/pages/index/settings/index.vue
  81. 0 13
      src/pages/me/me.vue
  82. 82 0
      src/pages/message/components/detail-popup.vue
  83. 172 0
      src/pages/message/components/search-form.vue
  84. 237 0
      src/pages/message/index.vue
  85. 90 0
      src/pages/user/contact/index.vue
  86. 66 0
      src/pages/user/faq/data.ts
  87. 98 0
      src/pages/user/faq/index.vue
  88. 205 0
      src/pages/user/feedback/index.vue
  89. 176 0
      src/pages/user/index.vue
  90. 149 0
      src/pages/user/profile/components/form.vue
  91. 138 0
      src/pages/user/profile/index.vue
  92. 122 0
      src/pages/user/security/components/password-form.vue
  93. 83 0
      src/pages/user/security/index.vue
  94. 122 0
      src/pages/user/settings/agreement/index.vue
  95. 148 0
      src/pages/user/settings/index.vue
  96. 157 0
      src/pages/user/settings/privacy/index.vue
  97. 1 1
      uno.config.ts

+ 13 - 0
src/api/bpm/category/index.ts

@@ -0,0 +1,13 @@
+import { http } from '@/http/http'
+
+/** 流程分类 */
+export interface Category {
+  id: number
+  name: string
+  code: string
+}
+
+/** 获取流程分类简单列表 */
+export function getCategorySimpleList() {
+  return http.get<Category[]>('/bpm/category/simple-list')
+}

+ 20 - 0
src/api/bpm/definition/index.ts

@@ -0,0 +1,20 @@
+import { http } from '@/http/http'
+
+/** 流程定义 */
+export interface ProcessDefinition {
+  id: string
+  key: string
+  name: string
+  description?: string
+  category: string
+  formType?: number
+  formId?: number
+  formCustomCreatePath?: string
+  formCustomViewPath?: string
+  suspensionState: number
+}
+
+/** 获取流程定义列表 */
+export function getProcessDefinitionList(params?: { suspensionState?: number }) {
+  return http.get<ProcessDefinition[]>('/bpm/process-definition/list', params)
+}

+ 68 - 0
src/api/bpm/processInstance/index.ts

@@ -0,0 +1,68 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 流程实例用户信息 */
+export interface User {
+  id: number
+  nickname: string
+  avatar?: string
+  deptName?: string
+}
+
+/** 流程实例 */
+export interface ProcessInstance {
+  id: string
+  name: string
+  status: number
+  category?: string
+  categoryName?: string
+  createTime?: number
+  startTime?: number
+  endTime?: number
+  startUser?: User
+  summary?: {
+    key: string
+    value: string
+  }[]
+}
+
+/** 抄送流程实例 */
+export interface ProcessInstanceCopy {
+  id: string
+  processInstanceId: string
+  processInstanceName: string
+  startUser: User
+  createTime: number
+  summary?: {
+    key: string
+    value: string
+  }[]
+}
+
+/** 查询我发起的流程分页列表 */
+export function getProcessInstanceMyPage(params: PageParam) {
+  return http.get<PageResult<ProcessInstance>>('/bpm/process-instance/my-page', params)
+}
+
+/** 查询抄送我的流程分页列表 */
+export function getProcessInstanceCopyPage(params: PageParam) {
+  return http.get<PageResult<ProcessInstanceCopy>>('/bpm/process-instance/copy/page', params)
+}
+
+/** 查询流程实例详情 */
+export function getProcessInstance(id: string) {
+  return http.get<ProcessInstance>(`/bpm/process-instance/get?id=${id}`)
+}
+
+/** 新增流程实例 */
+export function createProcessInstance(data: {
+  processDefinitionId: string
+  variables: Record<string, any>
+}) {
+  return http.post<string>('/bpm/process-instance/create', data)
+}
+
+/** 申请人取消流程实例 */
+export function cancelProcessInstanceByStartUser(id: string, reason: string) {
+  return http.delete<boolean>('/bpm/process-instance/cancel-by-start-user', { id, reason })
+}

+ 50 - 0
src/api/bpm/task/index.ts

@@ -0,0 +1,50 @@
+import type { ProcessInstance } from '@/api/bpm/processInstance'
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 任务处理人 */
+// TODO @芋艿:貌似暂时不需要这个?!
+export interface TaskUser {
+  id: number
+  nickname: string
+  avatar?: string
+  deptName?: string
+}
+
+/** 流程任务 */
+export interface Task {
+  id: string
+  name: string
+  status: number
+  createTime: number
+  endTime?: number
+  reason?: string
+  assigneeUser?: TaskUser
+  ownerUser?: TaskUser
+  processInstance: ProcessInstance
+}
+
+/** 查询待办任务分页列表 */
+export function getTaskTodoPage(params: PageParam) {
+  return http.get<PageResult<Task>>('/bpm/task/todo-page', params)
+}
+
+/** 查询已办任务分页列表 */
+export function getTaskDonePage(params: PageParam) {
+  return http.get<PageResult<Task>>('/bpm/task/done-page', params)
+}
+
+/** 审批通过 */
+export function approveTask(data: { id: string, reason: string }) {
+  return http.put<boolean>('/bpm/task/approve', data)
+}
+
+/** 审批拒绝 */
+export function rejectTask(data: { id: string, reason: string }) {
+  return http.put<boolean>('/bpm/task/reject', data)
+}
+
+/** 根据流程实例 ID 查询任务列表 */
+export function getTaskListByProcessInstanceId(processInstanceId: string) {
+  return http.get<Task[]>(`/bpm/task/list-by-process-instance-id?processInstanceId=${processInstanceId}`)
+}

+ 36 - 0
src/api/infra/apiAccessLog/index.ts

@@ -0,0 +1,36 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** API 访问日志信息 */
+export interface ApiAccessLog {
+  id: number
+  traceId: string
+  userId: number
+  userType: number
+  applicationName: string
+  requestMethod: string
+  requestParams: string
+  responseBody: string
+  requestUrl: string
+  userIp: string
+  userAgent: string
+  operateModule: string
+  operateName: string
+  operateType: number
+  beginTime: string
+  endTime: string
+  duration: number
+  resultCode: number
+  resultMsg: string
+  createTime: string
+}
+
+/** 获取 API 访问日志分页列表 */
+export function getApiAccessLogPage(params: PageParam) {
+  return http.get<PageResult<ApiAccessLog>>('/infra/api-access-log/page', params)
+}
+
+/** 获取 API 访问日志详情 */
+export function getApiAccessLog(id: number) {
+  return http.get<ApiAccessLog>(`/infra/api-access-log/get?id=${id}`)
+}

+ 45 - 0
src/api/infra/apiErrorLog/index.ts

@@ -0,0 +1,45 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** API 错误日志信息 */
+export interface ApiErrorLog {
+  id: number
+  traceId: string
+  userId: number
+  userType: number
+  applicationName: string
+  requestMethod: string
+  requestParams: string
+  requestUrl: string
+  userIp: string
+  userAgent: string
+  exceptionTime: string
+  exceptionName: string
+  exceptionMessage: string
+  exceptionRootCauseMessage: string
+  exceptionStackTrace: string
+  exceptionClassName: string
+  exceptionFileName: string
+  exceptionMethodName: string
+  exceptionLineNumber: number
+  processUserId: number
+  processStatus: number
+  processTime: string
+  resultCode: number
+  createTime: string
+}
+
+/** 获取 API 错误日志分页列表 */
+export function getApiErrorLogPage(params: PageParam) {
+  return http.get<PageResult<ApiErrorLog>>('/infra/api-error-log/page', params)
+}
+
+/** 获取 API 错误日志详情 */
+export function getApiErrorLog(id: number) {
+  return http.get<ApiErrorLog>(`/infra/api-error-log/get?id=${id}`)
+}
+
+/** 更新 API 错误日志的处理状态 */
+export function updateApiErrorLogStatus(id: number, processStatus: number) {
+  return http.put<boolean>(`/infra/api-error-log/update-status?id=${id}&processStatus=${processStatus}`)
+}

+ 82 - 0
src/api/infra/file/index.ts

@@ -0,0 +1,82 @@
+import { http } from '@/http/http'
+import { useTokenStore } from '@/store/token'
+import { useUserStore } from '@/store/user'
+
+/** 文件预签名信息 */
+export interface FilePresignedUrlRespVO {
+  configId: number // 配置编号
+  uploadUrl: string // 文件上传 URL
+  url: string // 文件访问 URL
+  path: string // 文件路径
+}
+
+/** 创建文件请求 */
+export interface FileCreateReqVO {
+  configId: number
+  url: string
+  path: string
+  name: string
+  type?: string
+  size?: number
+}
+
+/**
+ * 获取文件预签名地址
+ *
+ * @param name 文件名
+ * @param directory 目录(可选)
+ */
+export function getFilePresignedUrl(name: string, directory?: string) {
+  return http.get<FilePresignedUrlRespVO>('/infra/file/presigned-url', { name, directory })
+}
+
+/**
+ * 创建文件记录
+ *
+ * @param data 文件信息
+ */
+export function createFile(data: FileCreateReqVO) {
+  return http.post<string>('/infra/file/create', data)
+}
+
+/**
+ * 上传文件到后端
+ *
+ * @param filePath 本地文件路径
+ * @param directory 目录(可选)
+ * @returns 文件访问 URL
+ */
+export function uploadFile(filePath: string, directory?: string): Promise<string> {
+  const tokenStore = useTokenStore()
+  const userStore = useUserStore()
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: `${import.meta.env.VITE_SERVER_BASEURL}/infra/file/upload`,
+      filePath,
+      name: 'file',
+      header: {
+        'Accept': '*/*',
+        'tenant-id': userStore.tenantId,
+        'Authorization': `Bearer ${tokenStore.validToken}`,
+      },
+      formData: directory ? { directory } : undefined,
+      success: (res) => {
+        if (res.statusCode === 200) {
+          const result = JSON.parse(res.data)
+          if (result.code === 0) {
+            resolve(result.data)
+          } else {
+            uni.showToast({ icon: 'none', title: result.msg || '上传失败' })
+            reject(new Error(result.msg || '上传失败'))
+          }
+        } else {
+          reject(new Error('上传失败'))
+        }
+      },
+      fail: (err) => {
+        console.error('上传失败:', err)
+        reject(err)
+      },
+    })
+  })
+}

+ 111 - 23
src/api/login.ts

@@ -1,12 +1,68 @@
-import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login'
-import { http } from '@/http/http'
+import type {
+  AuthPermissionInfo,
+  IAuthLoginRes,
+  ICaptcha,
+  IDoubleTokenRes,
+  IUpdateInfo,
+  IUpdatePassword,
+  IUserInfoRes,
+} from "./types/login";
+import { http } from "@/http/http";
 
 /**
  * 登录表单
  */
 export interface ILoginForm {
-  username: string
-  password: string
+  type: "username" | "register" | "sms";
+  username?: string;
+  password?: string;
+  nickname?: string;
+  captchaVerification?: string;
+  mobile?: string;
+  code?: string;
+}
+
+/** 账号密码登录 Request VO */
+export interface AuthLoginReqVO {
+  password?: string;
+  username?: string;
+  captchaVerification?: string;
+  // 绑定社交登录时,需要传递如下参数
+  socialType?: number;
+  socialCode?: string;
+  socialState?: string;
+}
+
+/** 注册 Request VO */
+export interface AuthRegisterReqVO {
+  username: string;
+  password: string;
+  captchaVerification: string;
+}
+
+/** 短信登录 Request VO */
+export interface AuthSmsLoginReqVO {
+  mobile: string;
+  code: string;
+}
+
+/** 发送短信验证码 Request VO */
+export interface AuthSmsSendReqVO {
+  mobile: string;
+  scene: number;
+}
+
+/** 租户信息 */
+export interface TenantVO {
+  id: number;
+  name: string;
+}
+
+/** 重置密码 Request VO */
+export interface AuthResetPasswordReqVO {
+  mobile: string;
+  code: string;
+  password: string;
 }
 
 /**
@@ -14,15 +70,42 @@ export interface ILoginForm {
  * @returns ICaptcha 验证码
  */
 export function getCode() {
-  return http.get<ICaptcha>('/user/getCode')
+  return http.get<ICaptcha>("/user/getCode");
 }
 
-/**
- * 用户登录
- * @param loginForm 登录表单
- */
-export function login(loginForm: ILoginForm) {
-  return http.post<IAuthLoginRes>('/auth/login', loginForm)
+/** 使用账号密码登录 */
+export function login(data: AuthLoginReqVO) {
+  return http.post<IAuthLoginRes>("/system/auth/login", data);
+}
+
+/** 注册用户 */
+export function register(data: AuthRegisterReqVO) {
+  return http.post<IAuthLoginRes>("/system/auth/register", data);
+}
+
+/** 短信登录 */
+export function smsLogin(data: AuthSmsLoginReqVO) {
+  return http.post<IAuthLoginRes>("/system/auth/sms-login", data);
+}
+
+/** 发送短信验证码 */
+export function sendSmsCode(data: AuthSmsSendReqVO) {
+  return http.post<boolean>("/system/auth/send-sms-code", data);
+}
+
+/** 获取租户简单列表 */
+export function getTenantSimpleList() {
+  return http.get<TenantVO[]>("/system/tenant/simple-list");
+}
+
+/** 根据租户域名获取租户信息 */
+export function getTenantByWebsite(website: string) {
+  return http.get<TenantVO>(`/system/tenant/get-by-website?website=${website}`);
+}
+
+/** 通过短信重置密码 */
+export function smsResetPassword(data: AuthResetPasswordReqVO) {
+  return http.post<boolean>("/system/auth/reset-password", data);
 }
 
 /**
@@ -30,35 +113,40 @@ export function login(loginForm: ILoginForm) {
  * @param refreshToken 刷新token
  */
 export function refreshToken(refreshToken: string) {
-  return http.post<IDoubleTokenRes>('/auth/refreshToken', { refreshToken })
+  return http.post<IDoubleTokenRes>(`/system/auth/refresh-token?refreshToken=${refreshToken}`);
 }
 
 /**
  * 获取用户信息
  */
 export function getUserInfo() {
-  return http.get<IUserInfoRes>('/user/info')
+  return http.get<IUserInfoRes>("/user/info");
 }
 
 /**
- * 退出登录
+ * 获取权限信息
  */
+export function getAuthPermissionInfo() {
+  return http.get<AuthPermissionInfo>("/system/auth/get-permission-info");
+}
+
+/** 退出登录 */
 export function logout() {
-  return http.get<void>('/auth/logout')
+  return http.post<void>("/system/auth/logout");
 }
 
 /**
  * 修改用户信息
  */
 export function updateInfo(data: IUpdateInfo) {
-  return http.post('/user/updateInfo', data)
+  return http.post("/user/updateInfo", data);
 }
 
 /**
  * 修改用户密码
  */
 export function updateUserPassword(data: IUpdatePassword) {
-  return http.post('/user/updatePassword', data)
+  return http.post("/user/updatePassword", data);
 }
 
 /**
@@ -68,11 +156,11 @@ export function updateUserPassword(data: IUpdatePassword) {
 export function getWxCode() {
   return new Promise<UniApp.LoginRes>((resolve, reject) => {
     uni.login({
-      provider: 'weixin',
-      success: res => resolve(res),
-      fail: err => reject(new Error(err)),
-    })
-  })
+      provider: "weixin",
+      success: (res) => resolve(res),
+      fail: (err) => reject(new Error(err)),
+    });
+  });
 }
 
 /**
@@ -81,5 +169,5 @@ export function getWxCode() {
  * @returns Promise 包含登录结果
  */
 export function wxLogin(data: { code: string }) {
-  return http.post<IAuthLoginRes>('/auth/wxLogin', data)
+  return http.post<IAuthLoginRes>("/auth/wxLogin", data);
 }

+ 45 - 0
src/api/system/dept/index.ts

@@ -0,0 +1,45 @@
+import { http } from '@/http/http'
+
+/** 部门信息 */
+export interface Dept {
+  id?: number
+  name: string
+  parentId: number
+  status: number
+  sort: number
+  leaderUserId?: number
+  phone?: string
+  email?: string
+  createTime?: string
+  children?: Dept[]
+}
+
+/** 获取部门列表 */
+export function getDeptList(params?: { name?: string; status?: number }) {
+  return http.get<Dept[]>('/system/dept/list', params)
+}
+
+/** 获取部门精简列表 */
+export function getSimpleDeptList() {
+  return http.get<Dept[]>('/system/dept/simple-list')
+}
+
+/** 获取部门详情 */
+export function getDept(id: number) {
+  return http.get<Dept>(`/system/dept/get?id=${id}`)
+}
+
+/** 创建部门 */
+export function createDept(data: Dept) {
+  return http.post<number>('/system/dept/create', data)
+}
+
+/** 更新部门 */
+export function updateDept(data: Dept) {
+  return http.put<boolean>('/system/dept/update', data)
+}
+
+/** 删除部门 */
+export function deleteDept(id: number) {
+  return http.delete<boolean>(`/system/dept/delete?id=${id}`)
+}

+ 20 - 0
src/api/system/dict/data/index.ts

@@ -0,0 +1,20 @@
+import { http } from '@/http/http'
+
+/** 字典数据 */
+export interface DictData {
+  id?: number
+  dictType: string
+  label: string
+  value: string
+  colorType?: string
+  cssClass?: string
+  sort?: number
+  status: number
+  remark?: string
+  createTime?: string
+}
+
+/** 查询字典数据(精简)列表 */
+export function getSimpleDictDataList() {
+  return http.get<DictData[]>('/system/dict-data/simple-list')
+}

+ 51 - 0
src/api/system/menu/index.ts

@@ -0,0 +1,51 @@
+import { http } from '@/http/http'
+
+/** 菜单信息 */
+export interface Menu {
+  id?: number
+  name: string
+  permission: string
+  type: number
+  sort: number
+  parentId: number
+  path: string
+  icon: string
+  component: string
+  componentName?: string
+  status: number
+  visible: boolean
+  keepAlive: boolean
+  alwaysShow?: boolean
+  createTime?: string
+  children?: Menu[]
+}
+
+/** 获取菜单列表 */
+export function getMenuList(params?: { name?: string, status?: number }) {
+  return http.get<Menu[]>('/system/menu/list', params)
+}
+
+/** 获取菜单精简列表 */
+export function getSimpleMenuList() {
+  return http.get<Menu[]>('/system/menu/simple-list')
+}
+
+/** 获取菜单详情 */
+export function getMenu(id: number) {
+  return http.get<Menu>(`/system/menu/get?id=${id}`)
+}
+
+/** 创建菜单 */
+export function createMenu(data: Menu) {
+  return http.post<number>('/system/menu/create', data)
+}
+
+/** 更新菜单 */
+export function updateMenu(data: Menu) {
+  return http.put<boolean>('/system/menu/update', data)
+}
+
+/** 删除菜单 */
+export function deleteMenu(id: number) {
+  return http.delete<boolean>(`/system/menu/delete?id=${id}`)
+}

+ 39 - 0
src/api/system/notify/index.ts

@@ -0,0 +1,39 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 站内信消息 */
+export interface NotifyMessage {
+  id: number
+  userId: number
+  userType: number
+  templateId: number
+  templateCode: string
+  templateNickname: string
+  templateContent: string
+  templateType: number
+  templateParams: string
+  readStatus: boolean
+  readTime: string
+  createTime: string
+}
+
+/** 获取我的站内信分页 */
+export function getMyNotifyMessagePage(params: PageParam) {
+  return http.get<PageResult<NotifyMessage>>('/system/notify-message/my-page', params)
+}
+
+/** 批量标记站内信已读 */
+export function updateNotifyMessageRead(ids: number | number[]) {
+  const idsArray = Array.isArray(ids) ? ids : [ids]
+  return http.put<boolean>('/system/notify-message/update-read', undefined, { ids: idsArray })
+}
+
+/** 标记所有站内信为已读 */
+export function updateAllNotifyMessageRead() {
+  return http.put<boolean>('/system/notify-message/update-all-read')
+}
+
+/** 获取当前用户的未读站内信数量 */
+export function getUnreadNotifyMessageCount() {
+  return http.get<number>('/system/notify-message/get-unread-count')
+}

+ 43 - 0
src/api/system/post/index.ts

@@ -0,0 +1,43 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 岗位信息 */
+export interface Post {
+  id?: number
+  name: string
+  code: string
+  sort: number
+  status: number
+  remark?: string
+  createTime?: string
+}
+
+/** 获取岗位分页列表 */
+export function getPostPage(params: PageParam) {
+  return http.get<PageResult<Post>>('/system/post/page', params)
+}
+
+/** 获取岗位精简列表 */
+export function getSimplePostList() {
+  return http.get<Post[]>('/system/post/simple-list')
+}
+
+/** 获取岗位详情 */
+export function getPost(id: number) {
+  return http.get<Post>(`/system/post/get?id=${id}`)
+}
+
+/** 创建岗位 */
+export function createPost(data: Post) {
+  return http.post<number>('/system/post/create', data)
+}
+
+/** 更新岗位 */
+export function updatePost(data: Post) {
+  return http.put<boolean>('/system/post/update', data)
+}
+
+/** 删除岗位 */
+export function deletePost(id: number) {
+  return http.delete<boolean>(`/system/post/delete?id=${id}`)
+}

+ 46 - 0
src/api/system/role/index.ts

@@ -0,0 +1,46 @@
+import { http } from '@/http/http'
+import { PageParam, PageResult } from '@/http/types';
+
+/** 角色信息 */
+export interface Role {
+  id: number
+  name: string
+  code: string
+  sort: number
+  status: number
+  type?: number
+  remark?: string
+  dataScope?: number
+  dataScopeDeptIds?: number[]
+  createTime: string
+}
+
+/** 获取角色分页列表 */
+export function getRolePage(params: PageParam) {
+  return http.get<PageResult<Role>>('/system/role/page', params)
+}
+
+/** 获取角色详情 */
+export function getRole(id: number) {
+  return http.get<Role>(`/system/role/get?id=${id}`)
+}
+
+/** 创建角色 */
+export function createRole(data: Role) {
+  return http.post<number>('/system/role/create', data)
+}
+
+/** 更新角色 */
+export function updateRole(data: Role) {
+  return http.put<boolean>('/system/role/update', data)
+}
+
+/** 删除角色 */
+export function deleteRole(id: number) {
+  return http.delete<boolean>(`/system/role/delete?id=${id}`)
+}
+
+/** 获取角色精简列表 */
+export function getSimpleRoleList() {
+  return http.get<Role[]>('/system/role/simple-list')
+}

+ 72 - 0
src/api/system/user/index.ts

@@ -0,0 +1,72 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 用户信息 */
+export interface User {
+  id?: number
+  username: string
+  nickname: string
+  password?: string
+  deptId?: number
+  deptName?: string
+  postIds?: number[]
+  email?: string
+  mobile?: string
+  sex?: number
+  avatar?: string
+  status: number
+  remark?: string
+  loginIp?: string
+  loginDate?: string
+  createTime?: string
+}
+
+/** 获取用户分页列表 */
+export function getUserPage(params: PageParam) {
+  return http.get<PageResult<User>>('/system/user/page', params)
+}
+
+/** 获取用户详情 */
+export function getUser(id: number) {
+  return http.get<User>(`/system/user/get?id=${id}`)
+}
+
+/** 创建用户 */
+export function createUser(data: User) {
+  return http.post<number>('/system/user/create', data)
+}
+
+/** 更新用户 */
+export function updateUser(data: User) {
+  return http.put<boolean>('/system/user/update', data)
+}
+
+/** 删除用户 */
+export function deleteUser(id: number) {
+  return http.delete<boolean>(`/system/user/delete?id=${id}`)
+}
+
+/** 重置用户密码 */
+export function resetUserPassword(id: number, password: string) {
+  return http.put<boolean>('/system/user/update-password', { id, password })
+}
+
+/** 修改用户状态 */
+export function updateUserStatus(id: number, status: number) {
+  return http.put<boolean>('/system/user/update-status', { id, status })
+}
+
+/** 获取用户拥有的角色列表 */
+export function getUserRoleIds(userId: number) {
+  return http.get<number[]>(`/system/permission/list-user-roles?userId=${userId}`)
+}
+
+/** 分配用户角色 */
+export function assignUserRole(userId: number, roleIds: number[]) {
+  return http.post<boolean>('/system/permission/assign-user-role', { userId, roleIds })
+}
+
+/** 获取用户精简列表 */
+export function getSimpleUserList() {
+  return http.get<User[]>('/system/user/simple-list')
+}

+ 48 - 0
src/api/system/user/profile/index.ts

@@ -0,0 +1,48 @@
+import { http } from '@/http/http'
+
+/** 用户个人中心信息 */
+export interface UserProfileVO {
+  id: number
+  username: string
+  nickname: string
+  email?: string
+  mobile?: string
+  sex?: number
+  avatar?: string
+  loginIp: string
+  loginDate: string
+  createTime: string
+  roles: { id: number, name: string }[]
+  dept: { id: number, name: string }
+  posts: { id: number, name: string }[]
+}
+
+/** 更新个人信息请求 */
+export interface UpdateProfileReqVO {
+  nickname?: string
+  email?: string
+  mobile?: string
+  sex?: number
+  avatar?: string
+}
+
+/** 更新密码请求 */
+export interface UpdatePasswordReqVO {
+  oldPassword: string
+  newPassword: string
+}
+
+/** 获取登录用户个人信息 */
+export function getUserProfile() {
+  return http.get<UserProfileVO>('/system/user/profile/get')
+}
+
+/** 修改用户个人信息 */
+export function updateUserProfile(data: UpdateProfileReqVO) {
+  return http.put<boolean>('/system/user/profile/update', data)
+}
+
+/** 修改用户个人密码 */
+export function updateUserPassword(data: UpdatePasswordReqVO) {
+  return http.put<boolean>('/system/user/profile/update-password', data)
+}

+ 14 - 2
src/api/types/login.ts

@@ -11,8 +11,9 @@ export interface ISingleTokenRes {
 export interface IDoubleTokenRes {
   accessToken: string
   refreshToken: string
-  accessExpiresIn: number // 访问令牌有效期(秒)
-  refreshExpiresIn: number // 刷新令牌有效期(秒)
+  // accessExpiresIn: number // 访问令牌有效期(秒)
+  // refreshExpiresIn: number // 刷新令牌有效期(秒)
+  expiresTime: number // 访问令牌过期时间,单位:毫秒
 }
 
 /**
@@ -31,7 +32,18 @@ export interface IUserInfoRes {
   [key: string]: any // 允许其他扩展字段
 }
 
+/**
+ * 权限信息
+ */
+export interface AuthPermissionInfo {
+  user: IUserInfoRes;
+  roles: string[];
+  permissions: string[];
+  // menus: AppRouteRecordRaw[]; // add by 芋艿:暂时用不到
+}
+
 // 认证存储数据结构
+// TODO @芋艿:可以考虑删除
 export interface AuthStorage {
   mode: AuthMode
   tokens: ISingleTokenRes | IDoubleTokenRes

+ 233 - 0
src/pages-bpm/processInstance/create/index.vue

@@ -0,0 +1,233 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="发起申请"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 搜索框 -->
+    <view class="bg-white p-24rpx">
+      <wd-search
+        v-model="searchName"
+        placeholder="请输入流程名称"
+        placeholder-left
+        hide-cancel
+        @search="handleSearch"
+        @clear="handleSearch"
+      />
+    </view>
+
+    <!-- 分类标签 -->
+    <view class="flex overflow-x-auto bg-white px-16rpx">
+      <view
+        v-for="(item, index) in categoryList"
+        :key="item.id"
+        class="relative whitespace-nowrap px-24rpx py-20rpx text-28rpx"
+        :class="activeIndex === index ? 'font-bold text-[#1890ff]' : 'text-[#666]'"
+        @click="switchCategory(index)"
+      >
+        {{ item.name }}
+        <view
+          v-if="activeIndex === index"
+          class="absolute bottom-0 left-24rpx right-24rpx h-4rpx bg-[#1890ff]"
+        />
+      </view>
+    </view>
+
+    <!-- 流程定义列表 -->
+    <scroll-view
+      scroll-y
+      class="h-[calc(100vh-280rpx)]"
+      :scroll-into-view="scrollIntoView"
+      scroll-with-animation
+    >
+      <view
+        v-for="(definitions, category) in groupedDefinitions"
+        :id="`category-${category}`"
+        :key="category"
+        class="mx-24rpx mt-24rpx"
+      >
+        <!-- 分类标题 -->
+        <view class="mb-16rpx flex items-center justify-between">
+          <text class="text-28rpx text-[#333] font-bold">{{ getCategoryName(category as string) }}</text>
+          <wd-icon
+            :name="expandedCategories[category as string] ? 'arrow-up' : 'arrow-down'"
+            size="32rpx"
+            @click="toggleCategory(category as string)"
+          />
+        </view>
+        <!-- 流程列表 -->
+        <view v-if="expandedCategories[category as string]" class="overflow-hidden rounded-16rpx bg-white">
+          <view
+            v-for="(item, index) in definitions"
+            :key="item.id"
+            class="flex items-center border-b border-[#f5f5f5] p-24rpx last:border-b-0"
+            @click="handleSelect(item)"
+          >
+            <view
+              class="mr-16rpx h-64rpx w-64rpx flex items-center justify-center rounded-12rpx"
+              :style="{ backgroundColor: getIconColor(index) }"
+            >
+              <wd-icon :name="getIconName(index)" size="40rpx" color="#fff" />
+            </view>
+            <text class="text-28rpx text-[#333]">{{ item.name }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-if="Object.keys(groupedDefinitions).length === 0" class="py-100rpx">
+        <wd-status-tip image="content" tip="暂无可发起的流程" />
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Category } from '@/api/bpm/category'
+import type { ProcessDefinition } from '@/api/bpm/definition'
+import { onLoad } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { getCategorySimpleList } from '@/api/bpm/category'
+import { getProcessDefinitionList } from '@/api/bpm/definition'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const searchName = ref('')
+const activeIndex = ref(0)
+const scrollIntoView = ref('')
+const categoryList = ref<Category[]>([])
+const definitionList = ref<ProcessDefinition[]>([])
+const expandedCategories = ref<Record<string, boolean>>({})
+
+/** 图标配置 */
+// TODO @芋艿:这个研发,是否需要弄?!
+const iconConfig = [
+  { icon: 'warning', color: '#D98469' },
+  { icon: 'heart', color: '#7BC67C' },
+  { icon: 'cart', color: '#4A7FEB' },
+  { icon: 'home', color: '#4A7FEB' },
+  { icon: 'location', color: '#4A9DEB' },
+]
+
+/** 过滤后的流程定义 */
+const filteredDefinitions = computed(() => {
+  if (!searchName.value.trim()) {
+    return definitionList.value
+  }
+  return definitionList.value.filter(item =>
+    item.name.toLowerCase().includes(searchName.value.toLowerCase()),
+  )
+})
+
+/** 按分类分组的流程定义 */
+const groupedDefinitions = computed<Record<string, ProcessDefinition[]>>(() => {
+  const grouped: Record<string, ProcessDefinition[]> = {}
+  filteredDefinitions.value.forEach((item) => {
+    if (!item.category)
+      return
+    if (!grouped[item.category])
+      grouped[item.category] = []
+    grouped[item.category].push(item)
+  })
+  // 按 categoryList 顺序排序
+  const ordered: Record<string, ProcessDefinition[]> = {}
+  categoryList.value.forEach((cat) => {
+    if (grouped[cat.code])
+      ordered[cat.code] = grouped[cat.code]
+  })
+  return ordered
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 搜索 */
+function handleSearch() {
+  // 搜索时展开所有分类
+  categoryList.value.forEach((cat) => {
+    expandedCategories.value[cat.code] = true
+  })
+}
+
+/** 切换分类 */
+function switchCategory(index: number) {
+  activeIndex.value = index
+  const category = categoryList.value[index]
+  if (category) {
+    expandedCategories.value[category.code] = true
+    // 滚动到对应分类
+    scrollIntoView.value = ''
+    setTimeout(() => {
+      scrollIntoView.value = `category-${category.code}`
+    }, 50)
+  }
+}
+
+/** 切换分类展开/收起 */
+function toggleCategory(code: string) {
+  expandedCategories.value[code] = !expandedCategories.value[code]
+}
+
+/** 获取分类名称 */
+function getCategoryName(code: string) {
+  return categoryList.value.find(item => item.code === code)?.name || code
+}
+
+/** 获取图标名称 */
+function getIconName(index: number) {
+  return iconConfig[index % iconConfig.length].icon
+}
+
+/** 获取图标颜色 */
+function getIconColor(index: number) {
+  return iconConfig[index % iconConfig.length].color
+}
+
+/** 选择流程定义 */
+function handleSelect(item: ProcessDefinition) {
+  // TODO @芋艿:跳转到流程表单页面
+  uni.showToast({
+    title: `选择了: ${item.name}`,
+    icon: 'none',
+  })
+}
+
+/** 加载分类列表 */
+async function loadCategoryList() {
+  try {
+    categoryList.value = await getCategorySimpleList()
+    // 默认展开所有分类
+    categoryList.value.forEach((cat) => {
+      expandedCategories.value[cat.code] = true
+    })
+  }
+  catch (error) {
+    console.error('[create] 加载分类失败:', error)
+  }
+}
+
+/** 加载流程定义列表 */
+async function loadDefinitionList() {
+  try {
+    definitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
+  }
+  catch (error) {
+    console.error('[create] 加载流程定义失败:', error)
+  }
+}
+
+/** 初始化 */
+onLoad(async () => {
+  await Promise.all([loadCategoryList(), loadDefinitionList()])
+})
+</script>

+ 128 - 0
src/pages-bpm/processInstance/detail/audit/index.vue

@@ -0,0 +1,128 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- TODO @芋艿:还有一些细节,在审批通过没搞完! -->
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="isApprove ? '审批同意' : '审批拒绝'"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 审批表单 -->
+    <view class="p-24rpx">
+      <view class="overflow-hidden rounded-12rpx bg-white shadow-sm">
+        <view class="p-24rpx">
+          <view class="mb-16rpx text-32rpx text-[#333] font-semibold">
+            审批意见
+          </view>
+          <wd-textarea
+            v-model="formData.reason"
+            placeholder="请输入审批意见"
+            :maxlength="500"
+            show-word-limit
+            clearable
+            custom-class="border border-solid border-[#e5e5e5] rounded-8rpx"
+          />
+        </view>
+      </view>
+
+      <!-- 提交按钮 -->
+      <view class="mt-48rpx">
+        <wd-button
+          :type="isApprove ? 'primary' : 'error'"
+          block
+          :loading="submitting"
+          :disabled="submitting"
+          @click="handleSubmit"
+        >
+          {{ isApprove ? '同意' : '拒绝' }}
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { onLoad } from '@dcloudio/uni-app'
+import { computed, reactive, ref } from 'vue'
+import { approveTask, rejectTask } from '@/api/bpm/task'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const taskId = ref('')
+const pass = ref(true) // true: 同意, false: 拒绝
+const submitting = ref(false)
+const formData = reactive({
+  reason: '',
+})
+
+/** 是否为同意操作 */
+const isApprove = computed(() => pass.value)
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 校验表单 */
+function validateForm() {
+  if (!formData.reason.trim()) {
+    uni.showToast({
+      title: '请输入审批意见',
+      icon: 'none',
+    })
+    return false
+  }
+  return true
+}
+
+/** 提交审批 */
+async function handleSubmit() {
+  if (submitting.value)
+    return
+  if (!validateForm())
+    return
+
+  submitting.value = true
+  try {
+    const api = isApprove.value ? approveTask : rejectTask
+    const result = await api({
+      id: taskId.value,
+      reason: formData.reason,
+    })
+    if (result) {
+      uni.showToast({
+        title: '审批成功',
+        icon: 'success',
+      })
+      setTimeout(() => {
+        uni.navigateBack()
+      }, 1500)
+    }
+  }
+  catch (error) {
+    console.error('[audit] 审批失败:', error)
+  }
+  finally {
+    submitting.value = false
+  }
+}
+
+/** 初始化 */
+onLoad((options) => {
+  if (!options?.id) {
+    uni.showToast({
+      title: '参数错误',
+      icon: 'none',
+    })
+    return
+  }
+  taskId.value = options.id
+  pass.value = options.pass !== 'false' // 默认为同意
+})
+</script>

+ 267 - 0
src/pages-bpm/processInstance/detail/index.vue

@@ -0,0 +1,267 @@
+<template>
+  <!-- TODO @芋艿:功能待 review;先从老版本迁移过来! -->
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="审批详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 流程信息卡片 -->
+    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+      <view class="p-24rpx">
+        <!-- 标题和状态 -->
+        <view class="mb-16rpx flex items-center justify-between">
+          <text class="text-32rpx text-[#333] font-bold">{{ processInstance.name }}</text>
+          <wd-tag :type="getStatusType(processInstance.status)">
+            {{ getStatusText(processInstance.status) }}
+          </wd-tag>
+        </view>
+        <!-- 发起人信息 -->
+        <view class="flex items-center">
+          <view class="mr-12rpx h-64rpx w-64rpx flex items-center justify-center rounded-full bg-[#1890ff] text-white">
+            {{ processInstance.startUser?.nickname?.[0] || '?' }}
+          </view>
+          <view>
+            <text class="text-28rpx text-[#333]">{{ processInstance.startUser?.nickname }}</text>
+            <text v-if="processInstance.startUser?.deptName" class="ml-8rpx text-24rpx text-[#999]">
+              {{ processInstance.startUser?.deptName }}
+            </text>
+          </view>
+        </view>
+        <!-- 提交时间 -->
+        <view class="mt-16rpx text-24rpx text-[#999]">
+          提交于 {{ formatDateTime(processInstance.startTime) }}
+        </view>
+      </view>
+    </view>
+
+    <!-- 摘要信息 -->
+    <view v-if="processInstance.summary?.length" class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+      <view class="p-24rpx">
+        <view class="mb-16rpx text-28rpx text-[#333] font-bold">
+          审批信息
+        </view>
+        <view v-for="(item, index) in processInstance.summary" :key="index" class="mb-8rpx flex">
+          <text class="text-26rpx text-[#999]">{{ item.key }}:</text>
+          <text class="text-26rpx text-[#333]">{{ item.value }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 审批记录 -->
+    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
+      <view class="p-24rpx">
+        <view class="mb-16rpx flex items-center justify-between">
+          <text class="text-28rpx text-[#333] font-bold">审批记录</text>
+          <wd-icon :name="orderAsc ? 'arrow-up' : 'arrow-down'" size="32rpx" @click="toggleOrder" />
+        </view>
+        <!-- 任务列表 -->
+        <view v-for="(task, index) in sortedTasks" :key="task.id || index" class="relative pb-24rpx pl-40rpx">
+          <!-- 时间线 -->
+          <view
+            class="absolute left-12rpx top-8rpx h-16rpx w-16rpx rounded-full"
+            :class="getTaskDotClass(task)"
+          />
+          <view v-if="index < sortedTasks.length - 1" class="absolute bottom-0 left-18rpx top-28rpx w-2rpx bg-[#e5e5e5]" />
+          <!-- 任务内容 -->
+          <view>
+            <text class="text-28rpx text-[#333] font-bold">{{ task.name }}</text>
+            <view v-if="task.assigneeUser" class="mt-8rpx flex items-center">
+              <view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
+                {{ task.assigneeUser?.nickname?.[0] || '?' }}
+              </view>
+              <view class="flex-1">
+                <view class="flex items-center justify-between">
+                  <view class="flex items-center">
+                    <text class="text-26rpx text-[#333]">{{ task.assigneeUser?.nickname }}</text>
+                    <text v-if="task.assigneeUser?.deptName" class="ml-8rpx text-22rpx text-[#999]">
+                      {{ task.assigneeUser?.deptName }}
+                    </text>
+                  </view>
+                  <text v-if="task.endTime" class="text-22rpx text-[#999]">{{ formatPast(task.endTime) }}</text>
+                </view>
+                <view class="mt-4rpx flex items-center">
+                  <text :class="getStatusTextClass(task.status)" class="text-24rpx">
+                    {{ getStatusText(task.status) }}
+                  </text>
+                  <text v-if="task.reason" class="ml-8rpx text-24rpx text-[#666]">{{ task.reason }}</text>
+                </view>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部操作栏 -->
+    <view v-if="runningTask" class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 flex gap-24rpx bg-white p-24rpx">
+      <wd-button type="error" plain class="flex-1" @click="handleReject">
+        拒绝
+      </wd-button>
+      <wd-button type="primary" class="flex-1" @click="handleApprove">
+        同意
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+// TODO @芋艿:缺少功能的补全!!!!
+import type { ProcessInstance } from '@/api/bpm/processInstance'
+import type { Task } from '@/api/bpm/task'
+import { onLoad } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { getProcessInstance } from '@/api/bpm/processInstance'
+import { getTaskListByProcessInstanceId } from '@/api/bpm/task'
+import { useUserStore } from '@/store'
+import { formatDateTime, formatPast } from '@/utils/date'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const userStore = useUserStore()
+const processInstanceId = ref('')
+const processInstance = ref<Partial<ProcessInstance>>({})
+const tasks = ref<Task[]>([])
+const orderAsc = ref(true)
+
+/** 当前用户需要处理的任务 */
+const runningTask = computed(() => {
+  return tasks.value.find((task) => {
+    // 待处理状态
+    if (task.status !== 1 && task.status !== 6)
+      return false
+    // 当前用户是处理人
+    return task.assigneeUser?.id === userStore.userInfo?.id
+  })
+})
+
+/** 排序后的任务列表 */
+const sortedTasks = computed(() => {
+  const list = [...tasks.value].filter(t => t.status !== 4) // 过滤已取消
+  list.sort((a, b) => {
+    if (a.endTime && b.endTime)
+      return orderAsc.value ? a.endTime - b.endTime : b.endTime - a.endTime
+    if (a.endTime)
+      return orderAsc.value ? -1 : 1
+    if (b.endTime)
+      return orderAsc.value ? 1 : -1
+    return orderAsc.value ? a.createTime - b.createTime : b.createTime - a.createTime
+  })
+  return list
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 切换排序 */
+function toggleOrder() {
+  orderAsc.value = !orderAsc.value
+}
+
+/** 获取状态文本 */
+function getStatusText(status?: number) {
+  const map: Record<number, string> = {
+    0: '待审批',
+    1: '审批中',
+    2: '审批通过',
+    3: '审批不通过',
+    4: '已取消',
+    5: '已退回',
+    6: '委派中',
+    7: '审批通过中',
+  }
+  return map[status ?? 0] || '待审批'
+}
+
+/** 获取状态标签类型 */
+function getStatusType(status?: number): 'default' | 'primary' | 'success' | 'warning' | 'danger' {
+  if ([1, 6, 7].includes(status ?? 0))
+    return 'primary'
+  if (status === 2)
+    return 'success'
+  if (status === 3)
+    return 'danger'
+  if (status === 4 || status === 5)
+    return 'warning'
+  return 'default'
+}
+
+/** 获取任务圆点样式 */
+function getTaskDotClass(task: Task) {
+  if ([1, 6, 7].includes(task.status))
+    return 'bg-[#1890ff]'
+  if (task.status === 2)
+    return 'bg-[#52c41a]'
+  if (task.status === 3)
+    return 'bg-[#ff4d4f]'
+  if (task.status === 5)
+    return 'bg-[#faad14]'
+  return 'bg-[#d9d9d9]'
+}
+
+/** 获取状态文本样式 */
+function getStatusTextClass(status: number) {
+  if ([1, 6, 7].includes(status))
+    return 'text-[#1890ff]'
+  if (status === 2)
+    return 'text-[#52c41a]'
+  if (status === 3)
+    return 'text-[#ff4d4f]'
+  if (status === 5)
+    return 'text-[#faad14]'
+  return 'text-[#999]'
+}
+
+/** 同意 */
+function handleApprove() {
+  if (!runningTask.value)
+    return
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?id=${runningTask.value.id}&pass=true` })
+}
+
+/** 拒绝 */
+function handleReject() {
+  if (!runningTask.value)
+    return
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?id=${runningTask.value.id}&pass=false` })
+}
+
+/** 加载流程实例 */
+async function loadProcessInstance() {
+  try {
+    processInstance.value = await getProcessInstance(processInstanceId.value)
+  }
+  catch (error) {
+    console.error('[detail] 加载流程实例失败:', error)
+  }
+}
+
+/** 加载任务列表 */
+async function loadTasks() {
+  try {
+    tasks.value = await getTaskListByProcessInstanceId(processInstanceId.value)
+  }
+  catch (error) {
+    console.error('[detail] 加载任务列表失败:', error)
+  }
+}
+
+/** 初始化 */
+onLoad(async (options) => {
+  if (!options?.id) {
+    uni.showToast({ title: '参数错误', icon: 'none' })
+    return
+  }
+  processInstanceId.value = options.id
+  await Promise.all([loadProcessInstance(), loadTasks()])
+})
+</script>

+ 97 - 0
src/pages-infra/apiAccessLog/components/search-form.vue

@@ -0,0 +1,97 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索日志
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          用户编号
+        </view>
+        <wd-input
+          v-model="formData.userId"
+          placeholder="请输入用户编号"
+          clearable
+        />
+      </view>
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          应用名
+        </view>
+        <wd-input
+          v-model="formData.applicationName"
+          placeholder="请输入应用名"
+          clearable
+        />
+      </view>
+      <!-- TODO @芋艿:后续增加时间范围的检索 -->
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, watch } from 'vue'
+
+/** 搜索表单数据 */
+export interface SearchFormData {
+  userId?: number
+  applicationName?: string
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<SearchFormData> // 初始搜索参数
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: SearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<SearchFormData>({
+  userId: undefined,
+  applicationName: undefined,
+})
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.userId = props.searchParams.userId
+    formData.applicationName = props.searchParams.applicationName
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.userId = undefined
+  formData.applicationName = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 132 - 0
src/pages-infra/apiAccessLog/detail/index.vue

@@ -0,0 +1,132 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="访问日志详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="日志编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="链路追踪" :value="formData?.traceId || '-'" />
+        <wd-cell title="应用名" :value="formData?.applicationName || '-'" />
+        <wd-cell title="用户编号" :value="String(formData?.userId ?? '-')" />
+        <wd-cell title="用户类型" :value="getDictLabel(DICT_TYPE.USER_TYPE, formData?.userType) || '-'" />
+        <wd-cell title="用户 IP" :value="formData?.userIp || '-'" />
+        <wd-cell title="用户 UA" :value="formData?.userAgent || '-'" />
+        <wd-cell title="请求信息" :value="getRequestInfo()" />
+        <wd-cell title="请求参数" is-link @click="handleCopyText(formData?.requestParams, '请求参数')">
+          <view class="max-w-400rpx truncate text-right">
+            {{ formData?.requestParams || '-' }}
+          </view>
+        </wd-cell>
+        <wd-cell title="请求结果" is-link @click="handleCopyText(formData?.responseBody, '请求结果')">
+          <view class="max-w-400rpx truncate text-right">
+            {{ formData?.responseBody || '-' }}
+          </view>
+        </wd-cell>
+        <wd-cell title="请求时间" :value="getRequestTimeRange()" />
+        <wd-cell title="请求耗时" :value="formData?.duration ? `${formData.duration} ms` : '-'" />
+        <wd-cell title="操作结果">
+          <template v-if="formData?.resultCode === 0">
+            <wd-tag type="success" plain>
+              正常
+            </wd-tag>
+          </template>
+          <template v-else-if="formData?.resultCode">
+            <text>失败 | {{ formData.resultCode }} | {{ formData.resultMsg }}</text>
+          </template>
+          <template v-else>
+            <text>-</text>
+          </template>
+        </wd-cell>
+        <wd-cell title="操作模块" :value="formData?.operateModule || '-'" />
+        <wd-cell title="操作名" :value="formData?.operateName || '-'" />
+        <wd-cell title="操作类型" :value="getDictLabel(DICT_TYPE.INFRA_OPERATE_TYPE, formData?.operateType) || '-'" />
+      </wd-cell-group>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ApiAccessLog } from '@/api/infra/apiAccessLog'
+import { onMounted, ref } from 'vue'
+import { getApiAccessLog } from '@/api/infra/apiAccessLog'
+import { getDictLabel } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const formData = ref<ApiAccessLog>() // 详情数据
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 复制文本并提示 */
+function handleCopyText(text?: string, title?: string) {
+  if (!text || text === '-') {
+    return
+  }
+  uni.setClipboardData({
+    data: text,
+    success: () => {
+      uni.showToast({
+        title: `${title || '内容'}已复制`,
+        icon: 'success',
+      })
+    },
+  })
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getApiAccessLog(props.id)
+}
+
+/** 获取请求信息 */
+function getRequestInfo() {
+  if (formData.value?.requestMethod && formData.value?.requestUrl) {
+    return `${formData.value.requestMethod} ${formData.value.requestUrl}`
+  }
+  return '-'
+}
+
+/** 获取请求时间范围 */
+function getRequestTimeRange() {
+  if (formData.value?.beginTime && formData.value?.endTime) {
+    return `${formatDateTime(formData.value.beginTime)} ~ ${formatDateTime(formData.value.endTime)}`
+  }
+  return '-'
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+</style>

+ 173 - 0
src/pages-infra/apiAccessLog/index.vue

@@ -0,0 +1,173 @@
+<template>
+  <!-- TODO @芋艿:【优化:全局样式】后续要全局样式么 -->
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="API 访问日志"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    >
+      <template #right>
+        <view class="flex items-center" @click="searchVisible = !searchVisible">
+          <wd-icon name="search" size="20px" />
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- 日志列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between gap-16rpx">
+            <view class="min-w-0 flex-1 truncate text-28rpx text-[#333] font-semibold">
+              {{ item.requestMethod }} {{ item.requestUrl }}
+            </view>
+            <view class="flex-shrink-0">
+              <wd-tag v-if="item.resultCode === 0" type="success" plain>
+                成功
+              </wd-tag>
+              <wd-tag v-else type="danger" plain>
+                失败
+              </wd-tag>
+            </view>
+          </view>
+          <view class="mb-12rpx flex items-center text-26rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">应用名:</text>
+            <text>{{ item.applicationName }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-26rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">用户编号:</text>
+            <text>{{ item.userId }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-26rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">执行时长:</text>
+            <text>{{ item.duration }} ms</text>
+          </view>
+          <view v-if="item.operateName" class="mb-12rpx flex items-center text-26rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">操作名:</text>
+            <text class="line-clamp-1">{{ item.operateName }}</text>
+          </view>
+          <view class="flex items-center text-24rpx text-[#999]">
+            <text>{{ formatDateTime(item.beginTime) }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无日志数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <SearchForm
+      v-model="searchVisible"
+      :search-params="queryParams"
+      @search="handleQuery"
+      @reset="handleReset"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SearchFormData } from './components/search-form.vue'
+import type { ApiAccessLog } from '@/api/infra/apiAccessLog'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref } from 'vue'
+import { getApiAccessLogPage } from '@/api/infra/apiAccessLog'
+import { formatDateTime } from '@/utils/date'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const total = ref(0) // 列表的总页数
+const list = ref<ApiAccessLog[]>([]) // 列表的数据
+const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
+const searchVisible = ref(false) // 搜索弹窗
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined as number | undefined,
+  applicationName: undefined as string | undefined,
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 查询日志列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getApiAccessLogPage({
+      ...queryParams,
+    })
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: SearchFormData) {
+  queryParams.userId = data?.userId
+  queryParams.applicationName = data?.applicationName
+  queryParams.pageNo = 1
+  list.value = [] // 清空列表
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 查看详情 */
+function handleDetail(item: ApiAccessLog) {
+  uni.navigateTo({
+    url: `/pages-infra/apiAccessLog/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 119 - 0
src/pages-infra/apiErrorLog/components/search-form.vue

@@ -0,0 +1,119 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索日志
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          用户编号
+        </view>
+        <wd-input
+          v-model="formData.userId"
+          placeholder="请输入用户编号"
+          clearable
+        />
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          应用名
+        </view>
+        <wd-input
+          v-model="formData.applicationName"
+          placeholder="请输入应用名"
+          clearable
+        />
+      </view>
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          处理状态
+        </view>
+        <wd-radio-group v-model="formData.processStatus" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio :value="0">
+            未处理
+          </wd-radio>
+          <wd-radio :value="1">
+            已处理
+          </wd-radio>
+          <wd-radio :value="2">
+            已忽略
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, watch } from 'vue'
+
+/** 搜索表单数据 */
+export interface SearchFormData {
+  userId?: number
+  applicationName?: string
+  processStatus: number // -1 表示全部
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<SearchFormData> // 初始搜索参数
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: SearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<SearchFormData>({
+  userId: undefined,
+  applicationName: undefined,
+  processStatus: -1,
+})
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.userId = props.searchParams.userId
+    formData.applicationName = props.searchParams.applicationName
+    formData.processStatus = props.searchParams.processStatus ?? -1
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.userId = undefined
+  formData.applicationName = undefined
+  formData.processStatus = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 165 - 0
src/pages-infra/apiErrorLog/detail/index.vue

@@ -0,0 +1,165 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="错误日志详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="日志编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="链路追踪" :value="formData?.traceId || '-'" />
+        <wd-cell title="应用名" :value="formData?.applicationName || '-'" />
+        <wd-cell title="用户编号" :value="String(formData?.userId ?? '-')" />
+        <wd-cell title="用户类型" :value="getDictLabel(DICT_TYPE.USER_TYPE, formData?.userType) || '-'" />
+        <wd-cell title="用户 IP" :value="formData?.userIp || '-'" />
+        <wd-cell title="用户 UA" :value="formData?.userAgent || '-'" />
+        <wd-cell title="请求信息" :value="getRequestInfo()" />
+        <wd-cell title="请求参数" is-link @click="handleCopyText(formData?.requestParams, '请求参数')">
+          <view class="max-w-400rpx truncate text-right">
+            {{ formData?.requestParams || '-' }}
+          </view>
+        </wd-cell>
+        <wd-cell title="异常时间" :value="formatDateTime(formData?.exceptionTime) || '-'" />
+        <wd-cell title="异常名" :value="formData?.exceptionName || '-'" />
+        <wd-cell title="处理状态">
+          <dict-tag :type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS" :value="formData?.processStatus" />
+        </wd-cell>
+        <wd-cell v-if="formData?.processUserId" title="处理人" :value="String(formData.processUserId)" />
+        <wd-cell v-if="formData?.processTime" title="处理时间" :value="formatDateTime(formData.processTime) || '-'" />
+      </wd-cell-group>
+
+      <!-- 异常堆栈 -->
+      <view v-if="formData?.exceptionStackTrace" class="mt-24rpx">
+        <view class="mb-16rpx text-28rpx text-[#333] font-semibold">
+          异常堆栈
+        </view>
+        <view class="rounded-12rpx bg-white p-24rpx shadow-sm">
+          <scroll-view scroll-y class="max-h-600rpx">
+            <text class="whitespace-pre-wrap break-all text-24rpx text-[#666] leading-relaxed">{{ formData.exceptionStackTrace }}</text>
+          </scroll-view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view v-if="formData?.processStatus === InfraApiErrorLogProcessStatusEnum.INIT" class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button class="flex-1" type="success" :loading="processing" @click="handleProcess(InfraApiErrorLogProcessStatusEnum.DONE)">
+          已处理
+        </wd-button>
+        <wd-button class="flex-1" type="warning" :loading="processing" @click="handleProcess(InfraApiErrorLogProcessStatusEnum.IGNORE)">
+          已忽略
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ApiErrorLog } from '@/api/infra/apiErrorLog'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getApiErrorLog, updateApiErrorLogStatus } from '@/api/infra/apiErrorLog'
+import { getDictLabel } from '@/hooks/useDict'
+import { DICT_TYPE, InfraApiErrorLogProcessStatusEnum } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<ApiErrorLog>() // 详情数据
+const processing = ref(false) // 处理中
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 复制文本并提示 */
+function handleCopyText(text?: string, title?: string) {
+  if (!text || text === '-') {
+    return
+  }
+  uni.setClipboardData({
+    data: text,
+    success: () => {
+      uni.showToast({
+        title: `${title || '内容'}已复制`,
+        icon: 'success',
+      })
+    },
+  })
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getApiErrorLog(props.id)
+}
+
+/** 获取请求信息 */
+function getRequestInfo() {
+  if (formData.value?.requestMethod && formData.value?.requestUrl) {
+    return `${formData.value.requestMethod} ${formData.value.requestUrl}`
+  }
+  return '-'
+}
+
+/** 处理日志 */
+function handleProcess(processStatus: number) {
+  if (!props.id) {
+    return
+  }
+  const statusText = processStatus === InfraApiErrorLogProcessStatusEnum.DONE ? '已处理' : '已忽略'
+  uni.showModal({
+    title: '提示',
+    content: `确定标记为${statusText}吗?`,
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      processing.value = true
+      try {
+        await updateApiErrorLogStatus(props.id, processStatus)
+        toast.success('操作成功')
+        // 刷新详情
+        await getDetail()
+      } finally {
+        processing.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 167 - 0
src/pages-infra/apiErrorLog/index.vue

@@ -0,0 +1,167 @@
+<template>
+  <!-- TODO @芋艿:【优化:全局样式】后续要全局样式么 -->
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="API 错误日志"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    >
+      <template #right>
+        <view class="flex items-center" @click="searchVisible = !searchVisible">
+          <wd-icon name="search" size="20px" />
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- 日志列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="line-clamp-1 mr-16rpx flex-1 text-28rpx text-[#333] font-semibold">
+              {{ item.exceptionName }}
+            </view>
+            <!-- DONE @芽艺:字典 -->
+            <dict-tag :type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS" :value="item.processStatus" />
+          </view>
+          <view class="mb-12rpx flex text-26rpx text-[#666]">
+            <text class="mr-8rpx flex-shrink-0 text-[#999]">请求:</text>
+            <text class="line-clamp-2 break-all">{{ item.requestMethod }} {{ item.requestUrl }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-26rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">应用名:</text>
+            <text>{{ item.applicationName }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-26rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">用户编号:</text>
+            <text>{{ item.userId }}</text>
+          </view>
+          <view class="flex items-center text-24rpx text-[#999]">
+            <text>{{ formatDateTime(item.exceptionTime) }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无日志数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <SearchForm
+      v-model="searchVisible"
+      :search-params="queryParams"
+      @search="handleQuery"
+      @reset="handleReset"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SearchFormData } from './components/search-form.vue'
+import type { ApiErrorLog } from '@/api/infra/apiErrorLog'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref } from 'vue'
+import { getApiErrorLogPage } from '@/api/infra/apiErrorLog'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const total = ref(0) // 列表的总页数
+const list = ref<ApiErrorLog[]>([]) // 列表的数据
+const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
+const searchVisible = ref(false) // 搜索弹窗
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined as number | undefined,
+  applicationName: undefined as string | undefined,
+  processStatus: -1 as number, // -1 表示全部
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 查询日志列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getApiErrorLogPage({
+      ...queryParams,
+      processStatus: queryParams.processStatus === -1 ? undefined : queryParams.processStatus,
+    })
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: SearchFormData) {
+  queryParams.userId = data?.userId
+  queryParams.applicationName = data?.applicationName
+  queryParams.processStatus = data?.processStatus ?? -1
+  queryParams.pageNo = 1
+  list.value = [] // 清空列表
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 查看详情 */
+function handleDetail(item: ApiErrorLog) {
+  uni.navigateTo({
+    url: `/pages-infra/apiErrorLog/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 490 - 0
src/pages-infra/webSocket/index.vue

@@ -0,0 +1,490 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="WebSocket 测试"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 连接状态卡片 -->
+    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white shadow-sm">
+      <view class="p-32rpx">
+        <!-- 状态指示器 -->
+        <view class="mb-24rpx flex items-center">
+          <view
+            class="mr-16rpx h-20rpx w-20rpx rounded-full"
+            :class="isConnected ? 'bg-[#07c160]' : 'bg-[#fa5151]'"
+          />
+          <text class="text-32rpx text-[#333] font-semibold">连接管理</text>
+        </view>
+        <!-- 连接状态 -->
+        <view class="mb-24rpx flex items-center rounded-12rpx bg-[#f7f8fa] p-24rpx">
+          <text class="mr-16rpx text-28rpx text-[#666]">连接状态:</text>
+          <wd-tag :type="isConnected ? 'success' : 'danger'">
+            {{ statusText }}
+          </wd-tag>
+        </view>
+        <!-- 服务地址 -->
+        <view class="mb-24rpx">
+          <text class="mb-12rpx block text-26rpx text-[#999]">服务地址</text>
+          <view class="rounded-12rpx bg-[#f7f8fa] p-24rpx">
+            <text class="break-all text-26rpx text-[#666]">{{ serverUrl }}</text>
+          </view>
+        </view>
+        <!-- 连接按钮 -->
+        <wd-button
+          block
+          :type="isConnected ? 'error' : 'primary'"
+          @click="toggleConnection"
+        >
+          {{ isConnected ? '断开连接' : '建立连接' }}
+        </wd-button>
+      </view>
+    </view>
+
+    <!-- 发送消息卡片 -->
+    <view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white shadow-sm">
+      <view class="p-32rpx">
+        <view class="mb-24rpx flex items-center">
+          <wd-icon name="chat" size="36rpx" color="#1989fa" class="mr-12rpx" />
+          <text class="text-32rpx text-[#333] font-semibold">发送消息</text>
+        </view>
+        <!-- 接收人选择 -->
+        <view class="mb-24rpx">
+          <text class="mb-12rpx block text-26rpx text-[#999]">接收人</text>
+          <wd-picker
+            v-model="sendUserId"
+            :columns="userColumns"
+            :disabled="!isConnected"
+            @confirm="handleUserChange"
+          >
+            <view class="flex items-center justify-between rounded-12rpx bg-[#f7f8fa] p-24rpx">
+              <text class="text-28rpx" :class="isConnected ? 'text-[#333]' : 'text-[#c8c9cc]'">
+                {{ selectedUserLabel }}
+              </text>
+              <wd-icon name="arrow-down" size="32rpx" :color="isConnected ? '#666' : '#c8c9cc'" />
+            </view>
+          </wd-picker>
+        </view>
+        <!-- 消息内容 -->
+        <view class="mb-24rpx">
+          <text class="mb-12rpx block text-26rpx text-[#999]">消息内容</text>
+          <wd-textarea
+            v-model="sendText"
+            placeholder="请输入要发送的消息..."
+            :disabled="!isConnected"
+            :maxlength="500"
+            show-word-limit
+            auto-height
+            :min-height="120"
+          />
+        </view>
+
+        <!-- 发送按钮 -->
+        <wd-button
+          block
+          type="primary"
+          :disabled="!isConnected || !sendText.trim()"
+          @click="handleSend"
+        >
+          <wd-icon name="send" size="32rpx" class="mr-8rpx" />
+          发送消息
+        </wd-button>
+      </view>
+    </view>
+
+    <!-- 消息记录卡片 -->
+    <view class="mx-24rpx mb-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white shadow-sm">
+      <view class="p-32rpx">
+        <view class="mb-24rpx flex items-center justify-between">
+          <view class="flex items-center">
+            <wd-icon name="list" size="36rpx" color="#1989fa" class="mr-12rpx" />
+            <text class="text-32rpx text-[#333] font-semibold">消息记录</text>
+            <wd-tag v-if="messageList.length > 0" type="primary" plain class="ml-16rpx">
+              {{ messageList.length }} 条
+            </wd-tag>
+          </view>
+          <wd-button
+            v-if="messageList.length > 0"
+            size="small"
+            type="error"
+            plain
+            @click="clearMessages"
+          >
+            清空
+          </wd-button>
+        </view>
+
+        <!-- 消息列表 -->
+        <scroll-view
+          scroll-y
+          class="message-list rounded-12rpx bg-[#f7f8fa]"
+          :style="{ height: '600rpx' }"
+        >
+          <view v-if="messageList.length === 0" class="h-full flex flex-col items-center justify-center">
+            <wd-icon name="inbox" size="80rpx" color="#c8c9cc" />
+            <text class="mt-16rpx text-26rpx text-[#c8c9cc]">暂无消息记录</text>
+          </view>
+          <view v-else class="p-20rpx">
+            <view
+              v-for="(msg, index) in messageReverseList"
+              :key="index"
+              class="mb-20rpx rounded-12rpx bg-white p-24rpx shadow-sm"
+            >
+              <view class="mb-12rpx flex items-center justify-between">
+                <view class="flex items-center">
+                  <view
+                    class="mr-12rpx h-16rpx w-16rpx rounded-full"
+                    :style="{ backgroundColor: getMessageBadgeColor(msg.type) }"
+                  />
+                  <text class="text-26rpx text-[#666] font-medium">
+                    {{ getMessageTypeText(msg.type) }}
+                  </text>
+                  <text v-if="msg.userId" class="ml-16rpx text-24rpx text-[#999]">
+                    用户 ID: {{ msg.userId }}
+                  </text>
+                </view>
+                <text class="text-22rpx text-[#c8c9cc]">
+                  {{ formatDateTime(msg.time) }}
+                </text>
+              </view>
+              <view class="break-words text-28rpx text-[#333]">
+                {{ msg.text }}
+              </view>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <!-- 底部安全区域 -->
+    <view class="h-env(safe-area-inset-bottom)" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { User } from '@/api/system/user'
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { getSimpleUserList } from '@/api/system/user'
+import { useTokenStore } from '@/store/token'
+import { getEnvBaseUrlRoot } from '@/utils'
+import { formatDateTime } from '@/utils/date'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+// ======================= 状态定义 =======================
+const tokenStore = useTokenStore()
+
+// WebSocket 相关状态
+const socketTask = ref<UniApp.SocketTask | null>(null)
+const isConnected = ref(false)
+const statusText = computed(() => (isConnected.value ? '已连接' : '未连接'))
+
+// 服务地址
+const serverUrl = computed(() => {
+  const baseUrl = getEnvBaseUrlRoot()
+  const wsUrl = baseUrl.replace('http', 'ws')
+  const tokenInfo = tokenStore.tokenInfo as any
+  const token = tokenInfo?.refreshToken || tokenStore.validToken
+  return `${wsUrl}/infra/ws?token=${token}`
+})
+
+// 消息相关状态
+interface Message {
+  text: string
+  time: number
+  type?: 'single' | 'group' | 'system'
+  userId?: string
+}
+const messageList = ref<Message[]>([])
+const messageReverseList = computed(() => [...messageList.value].reverse())
+
+// 发送消息相关
+const sendText = ref('')
+const sendUserId = ref('all')
+const userList = ref<User[]>([])
+const userColumns = computed(() => {
+  const list = [
+    { value: 'all', label: '所有人' },
+    ...userList.value.map(user => ({
+      value: String(user.id),
+      label: user.nickname || user.username,
+    })),
+  ]
+  return [list]
+}) // 用户选择器列表
+const selectedUserLabel = computed(() => {
+  if (sendUserId.value === 'all') {
+    return '所有人'
+  }
+  const user = userList.value.find(u => String(u.id) === sendUserId.value)
+  return user?.nickname || user?.username || '所有人'
+}) // 选中的用户标签
+
+// ======================= WebSocket 方法 =======================
+
+/** 建立 WebSocket 连接 */
+function connect() {
+  if (socketTask.value) {
+    return
+  }
+
+  // 1.1 发起连接请求
+  socketTask.value = uni.connectSocket({
+    url: serverUrl.value,
+    success: () => {
+      console.log('WebSocket 连接请求已发送')
+    },
+    fail: (err) => {
+      console.error('WebSocket 连接失败:', err)
+      uni.showToast({ title: '连接失败', icon: 'error' })
+    },
+  })
+  // 1.2 监听连接打开
+  socketTask.value.onOpen(() => {
+    console.log('WebSocket 连接已打开')
+    isConnected.value = true
+    uni.showToast({ title: '连接成功', icon: 'success' })
+    // 开始心跳
+    startHeartbeat()
+  })
+
+  // 2. 监听消息
+  socketTask.value.onMessage((res) => {
+    handleMessage(res.data as string)
+  })
+
+  // 3.1 监听连接关闭
+  socketTask.value.onClose(() => {
+    console.log('WebSocket 连接已关闭')
+    isConnected.value = false
+    socketTask.value = null
+    stopHeartbeat()
+  })
+  // 3.2 监听错误
+  socketTask.value.onError((err) => {
+    console.error('WebSocket 错误:', err)
+    isConnected.value = false
+    socketTask.value = null
+    stopHeartbeat()
+    uni.showToast({ title: '连接异常', icon: 'error' })
+  })
+}
+
+/** 关闭 WebSocket 连接 */
+function disconnect() {
+  if (!socketTask.value) {
+    return
+  }
+  socketTask.value.close({
+    success: () => {
+      console.log('WebSocket 连接已主动关闭')
+      uni.showToast({ title: '已断开', icon: 'success' })
+    },
+  })
+  socketTask.value = null
+  isConnected.value = false
+  stopHeartbeat()
+}
+
+/** 切换连接状态 */
+function toggleConnection() {
+  if (isConnected.value) {
+    disconnect()
+  } else {
+    connect()
+  }
+}
+
+// ======================= 心跳机制 =======================
+let heartbeatTimer: ReturnType<typeof setInterval> | null = null
+
+/** 启动心跳机制 */
+function startHeartbeat() {
+  stopHeartbeat()
+  // 30 秒发送一次心跳
+  heartbeatTimer = setInterval(() => {
+    if (socketTask.value && isConnected.value) {
+      socketTask.value.send({
+        data: 'ping',
+        fail: (err) => {
+          console.error('心跳发送失败:', err)
+        },
+      })
+    }
+  }, 30000)
+}
+
+/** 停止心跳机制 */
+function stopHeartbeat() {
+  if (heartbeatTimer) {
+    clearInterval(heartbeatTimer)
+    heartbeatTimer = null
+  }
+}
+
+// ======================= 消息处理 =======================
+
+/** 处理接收到的消息 */
+function handleMessage(data: string) {
+  if (!data) {
+    return
+  }
+
+  try {
+    // 心跳响应
+    if (data === 'pong') {
+      return
+    }
+
+    // 1. 解析消息
+    const jsonMessage = JSON.parse(data)
+    const type = jsonMessage.type
+    const content = JSON.parse(jsonMessage.content)
+    if (!type) {
+      console.warn('未知的消息类型:', data)
+      return
+    }
+
+    // 2.1 处理 demo-message-receive 消息
+    if (type === 'demo-message-receive') {
+      const single = content.single
+      messageList.value.push({
+        text: content.text,
+        time: Date.now(),
+        type: single ? 'single' : 'group',
+        userId: content.fromUserId,
+      })
+      return
+    }
+
+    // 2.2 处理 notice-push 消息
+    if (type === 'notice-push') {
+      messageList.value.push({
+        text: content.title,
+        time: Date.now(),
+        type: 'system',
+      })
+      return
+    }
+
+    console.warn('未处理的消息类型:', type, data)
+  } catch (error) {
+    console.error('消息解析失败:', error, data)
+  }
+}
+
+/** 发送消息 */
+function handleSend() {
+  if (!sendText.value.trim()) {
+    uni.showToast({ title: '请输入消息内容', icon: 'none' })
+    return
+  }
+  if (!socketTask.value || !isConnected.value) {
+    uni.showToast({ title: '请先建立连接', icon: 'none' })
+    return
+  }
+
+  // 1.1 构建消息内容
+  const messageContent = JSON.stringify({
+    text: sendText.value,
+    toUserId: sendUserId.value === 'all' ? undefined : sendUserId.value,
+  })
+  // 1.2 构建完整消息
+  const jsonMessage = JSON.stringify({
+    type: 'demo-message-send',
+    content: messageContent,
+  })
+
+  // 2. 发送消息
+  socketTask.value.send({
+    data: jsonMessage,
+    success: () => {
+      uni.showToast({ title: '发送成功', icon: 'success' })
+      sendText.value = ''
+    },
+    fail: (err) => {
+      console.error('消息发送失败:', err)
+      uni.showToast({ title: '发送失败', icon: 'error' })
+    },
+  })
+}
+
+/** 清空消息记录 */
+function clearMessages() {
+  messageList.value = []
+}
+
+// ======================= 工具方法 =======================
+
+/** 获取消息类型的徽标颜色 */
+function getMessageBadgeColor(type?: string) {
+  switch (type) {
+    case 'group':
+      return '#07c160'
+    case 'single':
+      return '#1989fa'
+    case 'system':
+      return '#fa5151'
+    default:
+      return '#c8c9cc'
+  }
+}
+
+/** 获取消息类型的文本 */
+function getMessageTypeText(type?: string) {
+  switch (type) {
+    case 'group':
+      return '群发'
+    case 'single':
+      return '单发'
+    case 'system':
+      return '系统'
+    default:
+      return '未知'
+  }
+}
+
+/** 处理用户选择变化 */
+function handleUserChange({ value }: { value: string[] }) {
+  sendUserId.value = value[0] || 'all'
+}
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+// ======================= 生命周期 =======================
+
+/** 初始化 */
+onMounted(async () => {
+  // 获取用户列表
+  try {
+    userList.value = await getSimpleUserList()
+  }
+  catch (error) {
+    console.error('获取用户列表失败:', error)
+  }
+})
+
+/** 页面卸载 */
+onUnmounted(() => {
+  // 页面销毁时断开连接
+  disconnect()
+})
+</script>
+
+<style lang="scss" scoped>
+.message-list {
+  &::-webkit-scrollbar {
+    display: none;
+  }
+}
+</style>

+ 84 - 0
src/pages-system/dept/components/breadcrumb.vue

@@ -0,0 +1,84 @@
+<template>
+  <view v-if="breadcrumbList.length > 0" class="bg-white px-24rpx py-16rpx">
+    <scroll-view scroll-x class="whitespace-nowrap">
+      <view class="inline-flex items-center">
+        <view
+          class="flex items-center text-28rpx"
+          :class="breadcrumbList.length > 0 ? 'text-[#1890ff]' : 'text-[#333]'"
+          @click="handleClick(-1)"
+        >
+          <text>全部部门</text>
+        </view>
+        <template v-for="(item, index) in breadcrumbList" :key="item.id">
+          <wd-icon name="arrow-right" size="12px" color="#999" custom-class="mx-8rpx" />
+          <view
+            class="flex items-center text-28rpx"
+            :class="index < breadcrumbList.length - 1 ? 'text-[#1890ff]' : 'text-[#333]'"
+            @click="handleClick(index)"
+          >
+            <text>{{ item.name }}</text>
+          </view>
+        </template>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+
+interface BreadcrumbItem {
+  id: number
+  name: string
+}
+
+const props = defineProps<{
+  modelValue: number
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: number]
+}>()
+
+const breadcrumbList = ref<BreadcrumbItem[]>([])
+
+/** 监听外部值变化 */
+watch(() => props.modelValue, (val) => {
+  if (val === 0) {
+    breadcrumbList.value = []
+  }
+})
+
+/** 点击面包屑 */
+function handleClick(index: number) {
+  if (index === -1) {
+    // 点击"全部部门"
+    breadcrumbList.value = []
+    emit('update:modelValue', 0)
+  } else if (index < breadcrumbList.value.length - 1) {
+    // 点击中间层级
+    const item = breadcrumbList.value[index]
+    breadcrumbList.value = breadcrumbList.value.slice(0, index + 1)
+    emit('update:modelValue', item.id)
+  }
+}
+
+/** 进入子层级 */
+function enter(item: BreadcrumbItem) {
+  breadcrumbList.value.push(item)
+  emit('update:modelValue', item.id)
+}
+
+/** 返回上一层级 */
+function back(): boolean {
+  if (breadcrumbList.value.length === 0) {
+    return false
+  }
+  breadcrumbList.value.pop()
+  const lastItem = breadcrumbList.value[breadcrumbList.value.length - 1]
+  emit('update:modelValue', lastItem?.id ?? 0)
+  return true
+}
+
+defineExpose({ enter, back })
+</script>

+ 152 - 0
src/pages-system/dept/detail/index.vue

@@ -0,0 +1,152 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="部门详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="部门名称" :value="formData?.name || '-'" />
+        <wd-cell title="上级部门" :value="getParentName() || '-'" />
+        <wd-cell title="负责人" :value="getLeaderName() || '-'" />
+        <wd-cell title="联系电话" :value="formData?.phone || '-'" />
+        <wd-cell title="邮箱" :value="formData?.email || '-'" />
+        <wd-cell title="显示顺序" :value="String(formData?.sort ?? '-')" />
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button class="flex-1" type="warning" @click="handleEdit">
+          编辑
+        </wd-button>
+        <wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Dept } from '@/api/system/dept'
+import type { User } from '@/api/system/user'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteDept, getDept, getSimpleDeptList } from '@/api/system/dept'
+import { getSimpleUserList } from '@/api/system/user'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<Dept>() // 详情数据
+const deleting = ref(false) // 删除中
+const deptList = ref<Dept[]>([]) // 部门列表
+const userList = ref<User[]>([]) // 用户列表
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 获取上级部门名称 */
+function getParentName(): string {
+  if (!formData.value?.parentId || formData.value.parentId === 0) {
+    return '顶级部门'
+  }
+  const parent = deptList.value.find(d => d.id === formData.value?.parentId)
+  return parent?.name || '未知'
+}
+
+/** 获取负责人名称 */
+function getLeaderName(): string {
+  if (!formData.value?.leaderUserId) {
+    return '未设置'
+  }
+  const user = userList.value.find(u => u.id === formData.value?.leaderUserId)
+  return user?.nickname || '未知'
+}
+
+/** 加载部门详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getDept(props.id)
+}
+
+/** 编辑部门 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/dept/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除部门 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该部门吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteDept(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(async () => {
+  // 获取部门列表
+  deptList.value = await getSimpleDeptList()
+  // 获取用户列表
+  userList.value = await getSimpleUserList()
+  // 获取详情
+  await getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 258 - 0
src/pages-system/dept/form/index.vue

@@ -0,0 +1,258 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view class="p-24rpx">
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group custom-class="cell-group" border>
+          <wd-cell
+            title="上级部门"
+            title-width="180rpx"
+            prop="parentId"
+            is-link
+            :value="getParentName()"
+            @click="showDeptPicker = true"
+          />
+          <wd-input
+            v-model="formData.name"
+            label="部门名称"
+            label-width="180rpx"
+            prop="name"
+            clearable
+            placeholder="请输入部门名称"
+          />
+          <wd-cell title="显示顺序" title-width="180rpx" prop="sort" center>
+            <wd-input-number
+              v-model="formData.sort"
+              :min="0"
+            />
+          </wd-cell>
+          <wd-cell
+            title="负责人"
+            title-width="180rpx"
+            prop="leaderUserId"
+            is-link
+            :value="getLeaderName()"
+            @click="showUserPicker = true"
+          />
+          <wd-input
+            v-model="formData.phone"
+            label="联系电话"
+            label-width="180rpx"
+            prop="phone"
+            clearable
+            placeholder="请输入联系电话"
+          />
+          <wd-input
+            v-model="formData.email"
+            label="邮箱"
+            label-width="180rpx"
+            prop="email"
+            clearable
+            placeholder="请输入邮箱"
+          />
+          <wd-cell title="状态" title-width="180rpx" prop="status" center>
+            <wd-switch
+              v-model="formData.status"
+              :active-value="CommonStatusEnum.ENABLE"
+              :inactive-value="CommonStatusEnum.DISABLE"
+            />
+          </wd-cell>
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+
+    <!-- 上级部门选择器 -->
+    <wd-picker
+      :model-value="showDeptPicker"
+      :columns="deptPickerColumns"
+      title="选择上级部门"
+      @confirm="handleDeptConfirm"
+      @close="showDeptPicker = false"
+    />
+
+    <!-- 负责人选择器 -->
+    <wd-picker
+      :model-value="showUserPicker"
+      :columns="userPickerColumns"
+      title="选择负责人"
+      @confirm="handleUserConfirm"
+      @close="showUserPicker = false"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Dept } from '@/api/system/dept'
+import type { User } from '@/api/system/user'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createDept, getDept, getSimpleDeptList, updateDept } from '@/api/system/dept'
+import { getSimpleUserList } from '@/api/system/user'
+import { CommonStatusEnum } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number
+  parentId?: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑部门' : '新增部门')
+const formLoading = ref(false) // 提交中状态
+const formData = ref<Dept>({
+  id: undefined,
+  name: '',
+  parentId: props.parentId || 0,
+  sort: 0,
+  status: CommonStatusEnum.ENABLE,
+  leaderUserId: undefined,
+  phone: '',
+  email: '',
+})
+const formRules = {
+  parentId: [{ required: true, message: '上级部门不能为空' }],
+  name: [{ required: true, message: '部门名称不能为空' }],
+  sort: [{ required: true, message: '显示顺序不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+const deptList = ref<Dept[]>([]) // 部门列表
+const userList = ref<User[]>([]) // 用户列表
+const showDeptPicker = ref(false) // 部门选择器
+const showUserPicker = ref(false) // 负责人选择器
+
+/** 部门选择器列 */
+const deptPickerColumns = computed(() => {
+  const items = [{ label: '顶级部门', value: 0 }]
+  deptList.value.forEach((dept) => {
+    // 编辑时排除自己和子部门
+    if (props.id && dept.id === props.id) {
+      return
+    }
+    items.push({ label: dept.name, value: dept.id! })
+  })
+  return items
+})
+
+/** 用户选择器列 */
+const userPickerColumns = computed(() => {
+  const items = [{ label: '不设置', value: 0 }]
+  userList.value.forEach((user) => {
+    items.push({ label: user.nickname, value: user.id! })
+  })
+  return items
+})
+
+/** 获取上级部门名称 */
+function getParentName(): string {
+  if (!formData.value.parentId || formData.value.parentId === 0) {
+    return '顶级部门'
+  }
+  const parent = deptList.value.find(d => d.id === formData.value.parentId)
+  return parent?.name || '请选择'
+}
+
+/** 获取负责人名称 */
+function getLeaderName(): string {
+  if (!formData.value.leaderUserId) {
+    return '请选择'
+  }
+  const user = userList.value.find(u => u.id === formData.value.leaderUserId)
+  return user?.nickname || '请选择'
+}
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载部门详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getDept(props.id)
+}
+
+/** 部门选择确认 */
+function handleDeptConfirm({ value }: { value: number }) {
+  formData.value.parentId = value
+}
+
+/** 负责人选择确认 */
+function handleUserConfirm({ value }: { value: number }) {
+  formData.value.leaderUserId = value || undefined
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateDept(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createDept(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  // 获取部门列表
+  deptList.value = await getSimpleDeptList()
+  // 获取用户列表
+  userList.value = await getSimpleUserList()
+  // 获取详情
+  await getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 153 - 0
src/pages-system/dept/index.vue

@@ -0,0 +1,153 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="部门管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 面包屑导航 -->
+    <Breadcrumb ref="breadcrumbRef" v-model="currentParentId" />
+
+    <!-- 部门列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in currentList"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+      >
+        <!-- 主内容区域:点击进入详情 -->
+        <view class="p-24rpx" @click="handleDetail(item)">
+          <!-- 第一行:名称、状态标签 -->
+          <view class="flex items-center justify-between">
+            <view class="flex items-center">
+              <view class="mr-16rpx h-48rpx w-48rpx flex items-center justify-center rounded-8rpx bg-[#1890ff]">
+                <wd-icon name="folder" size="20px" color="#fff" />
+              </view>
+              <view class="text-32rpx text-[#333] font-semibold">
+                {{ item.name }}
+              </view>
+            </view>
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <!-- 第二行:负责人、子部门入口 -->
+          <view class="mt-12rpx flex items-center justify-between pl-64rpx">
+            <view class="text-24rpx text-[#999]">
+              负责人:{{ getLeaderName(item.leaderUserId) }}
+            </view>
+            <view
+              v-if="item.children && item.children.length > 0"
+              class="flex items-center"
+              @click.stop="handleEnterChildren(item)"
+            >
+              <text class="text-24rpx text-[#1890ff]">子部门 ({{ item.children.length }})</text>
+              <wd-icon name="arrow-right" size="12px" color="#1890ff" />
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-if="!loading && currentList.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无部门数据" />
+      </view>
+    </view>
+
+    <!-- 新增按钮 -->
+    <view
+      class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+      @click="handleAdd"
+    >
+      <wd-icon name="add" size="24px" color="#fff" />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Dept } from '@/api/system/dept'
+import type { User } from '@/api/system/user'
+import { computed, onMounted, ref } from 'vue'
+import { getDeptList } from '@/api/system/dept'
+import { getSimpleUserList } from '@/api/system/user'
+import { DICT_TYPE } from '@/utils/constants'
+import { findChildren, handleTree } from '@/utils/tree'
+import Breadcrumb from './components/breadcrumb.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const loading = ref(false)
+const list = ref<Dept[]>([]) // 完整部门列表(树形结构)
+const userList = ref<User[]>([]) // 用户列表
+
+const currentParentId = ref(0) // 当前层级的父节点编号
+const currentList = computed(() => {
+  if (currentParentId.value === 0) {
+    return list.value.filter(item => item.parentId === 0)
+  }
+  return findChildren(list.value, currentParentId.value)
+}) // 当前层级的部门列表
+const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
+
+/** 返回上一页或上一层级 */
+function handleBack() {
+  if (!breadcrumbRef.value?.back()) {
+    uni.navigateBack()
+  }
+}
+
+/** 获取负责人名称 */
+function getLeaderName(leaderUserId?: number): string {
+  if (!leaderUserId) {
+    return '未设置'
+  }
+  const user = userList.value.find(u => u.id === leaderUserId)
+  return user?.nickname || '未知'
+}
+
+/** 进入子部门层级 */
+function handleEnterChildren(item: Dept) {
+  breadcrumbRef.value?.enter({ id: item.id!, name: item.name })
+}
+
+/** 查询部门列表 */
+async function getList() {
+  loading.value = true
+  try {
+    const data = await getDeptList()
+    list.value = handleTree(data)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 新增部门 */
+function handleAdd() {
+  uni.navigateTo({
+    url: `/pages-system/dept/form/index?parentId=${currentParentId.value}`,
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: Dept) {
+  uni.navigateTo({
+    url: `/pages-system/dept/detail/index?id=${item.id}`,
+  })
+}
+
+/** 初始化 */
+onMounted(async () => {
+  // 获取用户列表
+  userList.value = await getSimpleUserList()
+  // 获取部门列表
+  await getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 98 - 0
src/pages-system/menu/components/breadcrumb.vue

@@ -0,0 +1,98 @@
+<template>
+  <view class="bg-white px-24rpx py-16rpx">
+    <scroll-view scroll-x class="whitespace-nowrap">
+      <view class="inline-flex items-center text-28rpx">
+        <template v-for="(item, index) in breadcrumbItems" :key="item.id">
+          <text v-if="index > 0" class="mx-8rpx text-[#999]">/</text>
+          <text
+            :class="index === breadcrumbItems.length - 1 ? 'text-[#333]' : 'text-[#1890ff]'"
+            @click="handleClick(index)"
+          >
+            {{ item.name }}
+          </text>
+        </template>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+
+export interface BreadcrumbNode {
+  id: number
+  name: string
+  [key: string]: any
+}
+
+const props = withDefaults(defineProps<{
+  modelValue?: number // 当前父节点编号
+  rootName?: string // 根目录名称
+}>(), {
+  modelValue: 0,
+  rootName: '根目录',
+})
+
+const emit = defineEmits<{
+  'update:modelValue': [value: number]
+  back: [] // 返回上一层级事件
+}>()
+
+const breadcrumbs = ref<BreadcrumbNode[]>([]) // 面包屑路径(不包含根目录)
+
+const breadcrumbItems = computed(() => [
+  { id: 0, name: props.rootName },
+  ...breadcrumbs.value,
+]) // 面包屑显示数据(包含根目录)
+
+const currentParentId = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val),
+}) // 当前父节点编号
+
+/** 面包屑点击 */
+function handleClick(index: number) {
+  if (index === breadcrumbItems.value.length - 1) return // 点击当前层级不处理
+  if (index === 0) {
+    breadcrumbs.value = []
+    currentParentId.value = 0
+  } else {
+    breadcrumbs.value = breadcrumbs.value.slice(0, index)
+    currentParentId.value = breadcrumbs.value[index - 1].id
+  }
+}
+
+/** 进入子层级 */
+function enter(node: BreadcrumbNode) {
+  breadcrumbs.value.push({ id: node.id, name: node.name })
+  currentParentId.value = node.id
+}
+
+/** 返回上一层级,返回 true 表示还有上层,false 表示已在根目录 */
+function back(): boolean {
+  if (breadcrumbs.value.length > 0) {
+    breadcrumbs.value.pop()
+    currentParentId.value = breadcrumbs.value.length > 0
+      ? breadcrumbs.value[breadcrumbs.value.length - 1].id
+      : 0
+    return true
+  }
+  return false
+}
+
+/** 监听外部 modelValue 变化,重置面包屑(用于外部重置场景) */
+watch(() => props.modelValue, (val) => {
+  if (val === 0 && breadcrumbs.value.length > 0) {
+    breadcrumbs.value = []
+  }
+})
+
+defineExpose({
+  enter,
+  back,
+  breadcrumbs,
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 155 - 0
src/pages-system/menu/detail/index.vue

@@ -0,0 +1,155 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="菜单详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="菜单名称" :value="formData?.name || '-'" />
+        <wd-cell title="菜单类型">
+          <dict-tag :type="DICT_TYPE.SYSTEM_MENU_TYPE" :value="formData?.type" />
+        </wd-cell>
+        <wd-cell title="上级菜单" :value="parentMenuName" />
+        <wd-cell title="显示排序" :value="String(formData?.sort ?? '-')" />
+        <wd-cell title="路由地址" :value="formData?.path || '-'" />
+        <wd-cell v-if="formData?.type === SystemMenuTypeEnum.MENU" title="组件路径" :value="formData?.component || '-'" />
+        <wd-cell v-if="formData?.type === SystemMenuTypeEnum.MENU" title="组件名称" :value="formData?.componentName || '-'" />
+        <wd-cell v-if="formData?.type !== SystemMenuTypeEnum.DIR" title="权限标识" :value="formData?.permission || '-'" />
+        <wd-cell title="菜单状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell v-if="formData?.type !== SystemMenuTypeEnum.BUTTON" title="显示状态">
+          <wd-tag v-if="formData?.visible" type="success" plain>
+            显示
+          </wd-tag>
+          <wd-tag v-else type="warning" plain>
+            隐藏
+          </wd-tag>
+        </wd-cell>
+        <wd-cell v-if="formData?.type === SystemMenuTypeEnum.MENU" title="缓存状态">
+          <wd-tag v-if="formData?.keepAlive" type="success" plain>
+            缓存
+          </wd-tag>
+          <wd-tag v-else type="default" plain>
+            不缓存
+          </wd-tag>
+        </wd-cell>
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button class="flex-1" type="warning" @click="handleEdit">
+          编辑
+        </wd-button>
+        <wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Menu } from '@/api/system/menu'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteMenu, getMenu, getSimpleMenuList } from '@/api/system/menu'
+import { DICT_TYPE, SystemMenuTypeEnum } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<Menu>() // 详情数据
+const deleting = ref(false) // 删除中
+const parentMenuName = ref('-') // 上级菜单名称
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载菜单详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getMenu(props.id)
+  // 获取上级菜单名称
+  if (formData.value?.parentId === 0) {
+    parentMenuName.value = '主类目'
+  } else if (formData.value?.parentId) {
+    // TODO @芋艿:后续这里可以优化,由后端返回 menuName;
+    const menuList = await getSimpleMenuList()
+    const parent = menuList.find(item => item.id === formData.value?.parentId)
+    parentMenuName.value = parent?.name || '-'
+  }
+}
+
+/** 编辑菜单 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/menu/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除菜单 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该菜单吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteMenu(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 143 - 0
src/pages-system/menu/form/components/menu-picker.vue

@@ -0,0 +1,143 @@
+<template>
+  <wd-col-picker
+    v-model="selectedValue"
+    label="上级菜单"
+    label-width="180rpx"
+    :columns="menuColumns"
+    :column-change="handleColumnChange"
+    :display-format="displayFormat"
+    @confirm="handleConfirm"
+  />
+</template>
+
+<script lang="ts" setup>
+import type { Menu } from '@/api/system/menu'
+import { onMounted, ref, watch } from 'vue'
+import { getSimpleMenuList } from '@/api/system/menu'
+import { SystemMenuTypeEnum } from '@/utils/constants'
+
+const props = defineProps<{
+  modelValue?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: number): void
+}>()
+
+const menuList = ref<Menu[]>([])
+const menuColumns = ref<any[]>([])
+const selectedValue = ref<number[]>([])
+
+/** 监听外部值变化,回显选中值 */
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val !== undefined && val !== 0 && menuList.value.length > 0) {
+      const path = findMenuPath(val)
+      selectedValue.value = path
+      buildColumnsForPath(path)
+    } else {
+      selectedValue.value = [0]
+    }
+  },
+)
+
+/** 加载菜单列表 */
+async function loadMenuList() {
+  const list = await getSimpleMenuList()
+  // 只保留目录和菜单
+  menuList.value = list.filter(item => item.type !== SystemMenuTypeEnum.BUTTON)
+  // 构建第一列数据(主类目 + 顶级菜单)
+  const topMenus = menuList.value.filter(item => item.parentId === 0)
+  menuColumns.value = [[
+    { value: 0, label: '主类目' },
+    ...topMenus.map(item => ({ value: item.id, label: item.name })),
+  ]]
+  // 如果有初始值,回显
+  if (props.modelValue !== undefined && props.modelValue !== 0) {
+    const path = findMenuPath(props.modelValue)
+    selectedValue.value = path
+    buildColumnsForPath(path)
+  } else {
+    selectedValue.value = [0]
+  }
+}
+
+/** 查找菜单路径 */
+function findMenuPath(targetId: number): number[] {
+  if (targetId === 0) {
+    return [0]
+  }
+  const path: number[] = []
+  const findPath = (parentId: number, id: number): boolean => {
+    const items = menuList.value.filter(m => m.parentId === parentId)
+    for (const item of items) {
+      if (item.id === id) {
+        path.push(item.id!)
+        return true
+      }
+      if (findPath(item.id!, id)) {
+        path.unshift(item.id!)
+        return true
+      }
+    }
+    return false
+  }
+  findPath(0, targetId)
+  return path.length > 0 ? path : [0]
+}
+
+/** 根据路径构建列数据 */
+function buildColumnsForPath(path: number[]) {
+  if (path.length === 0 || (path.length === 1 && path[0] === 0)) {
+    return
+  }
+  // 第一列已经有了,从第二列开始构建
+  const columns = [menuColumns.value[0]]
+  for (let i = 0; i < path.length; i++) {
+    const parentId = path[i]
+    if (parentId === 0) {
+      continue
+    }
+    const children = menuList.value.filter(item => item.parentId === parentId)
+    if (children.length > 0) {
+      columns.push(children.map(item => ({ value: item.id, label: item.name })))
+    }
+  }
+  menuColumns.value = columns
+}
+
+/** 列变化 */
+function handleColumnChange({ selectedItem, resolve, finish }: any) {
+  if (selectedItem.value === 0) {
+    // 选择主类目,结束
+    finish()
+    return
+  }
+  const children = menuList.value.filter(item => item.parentId === selectedItem.value)
+  if (children.length > 0) {
+    resolve(children.map(item => ({ value: item.id, label: item.name })))
+  } else {
+    finish()
+  }
+}
+
+/** 格式化显示 */
+function displayFormat(selectedItems: any[]) {
+  return selectedItems.map(item => item.label).join(' / ')
+}
+
+/** 确认选择 */
+function handleConfirm({ value }: { value: number[] }) {
+  if (value && value.length > 0) {
+    emit('update:modelValue', value[value.length - 1])
+  } else {
+    emit('update:modelValue', 0)
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  loadMenuList()
+})
+</script>

+ 257 - 0
src/pages-system/menu/form/index.vue

@@ -0,0 +1,257 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group custom-class="cell-group" border>
+          <MenuPicker v-model="formData.parentId" />
+          <wd-cell title="菜单类型" title-width="180rpx" prop="type">
+            <wd-radio-group v-model="formData.type" shape="button" @change="handleTypeChange">
+              <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_MENU_TYPE)" :key="dict.value" :value="dict.value">
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-input
+            v-model="formData.name"
+            label="菜单名称"
+            label-width="180rpx"
+            prop="name"
+            clearable
+            placeholder="请输入菜单名称"
+          />
+          <wd-input
+            v-if="formData.type !== SystemMenuTypeEnum.BUTTON"
+            v-model="formData.icon"
+            label="菜单图标"
+            label-width="180rpx"
+            clearable
+            placeholder="请输入菜单图标"
+          />
+          <wd-input
+            v-if="formData.type !== SystemMenuTypeEnum.BUTTON"
+            v-model="formData.path"
+            label="路由地址"
+            label-width="180rpx"
+            prop="path"
+            clearable
+            placeholder="请输入路由地址"
+          />
+          <wd-input
+            v-if="formData.type === SystemMenuTypeEnum.MENU"
+            v-model="formData.component"
+            label="组件路径"
+            label-width="180rpx"
+            clearable
+            placeholder="例如:system/user/index"
+          />
+          <wd-input
+            v-if="formData.type === SystemMenuTypeEnum.MENU"
+            v-model="formData.componentName"
+            label="组件名称"
+            label-width="180rpx"
+            clearable
+            placeholder="例如:SystemUser"
+          />
+          <wd-input
+            v-if="formData.type !== SystemMenuTypeEnum.DIR"
+            v-model="formData.permission"
+            label="权限标识"
+            label-width="180rpx"
+            clearable
+            placeholder="请输入权限标识"
+          />
+          <wd-cell title="显示排序" title-width="180rpx" prop="sort" center>
+            <wd-input-number
+              v-model="formData.sort"
+              :min="0"
+            />
+          </wd-cell>
+          <wd-cell title="菜单状态" title-width="180rpx" prop="status" center>
+            <wd-switch
+              v-model="formData.status"
+              :active-value="CommonStatusEnum.ENABLE"
+              :inactive-value="CommonStatusEnum.DISABLE"
+            />
+          </wd-cell>
+          <wd-cell v-if="formData.type !== SystemMenuTypeEnum.BUTTON" title="显示状态" title-width="180rpx" center>
+            <wd-switch
+              v-model="formData.visible"
+              :active-value="true"
+              :inactive-value="false"
+            />
+          </wd-cell>
+          <wd-cell v-if="formData.type !== SystemMenuTypeEnum.BUTTON" title="总是显示" title-width="180rpx" center>
+            <wd-switch
+              v-model="formData.alwaysShow"
+              :active-value="true"
+              :inactive-value="false"
+            />
+          </wd-cell>
+          <wd-cell v-if="formData.type === SystemMenuTypeEnum.MENU" title="缓存状态" title-width="180rpx" center>
+            <wd-switch
+              v-model="formData.keepAlive"
+              :active-value="true"
+              :inactive-value="false"
+            />
+          </wd-cell>
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Menu } from '@/api/system/menu'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createMenu, getMenu, updateMenu } from '@/api/system/menu'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { CommonStatusEnum, DICT_TYPE, SystemMenuTypeEnum } from '@/utils/constants'
+import MenuPicker from './components/menu-picker.vue'
+
+const props = defineProps<{
+  id?: number
+  parentId?: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑菜单' : '新增菜单')
+const formLoading = ref(false) // 提交中状态
+const formData = ref<Menu>({
+  id: undefined,
+  name: '',
+  permission: '',
+  type: SystemMenuTypeEnum.DIR,
+  sort: 0,
+  parentId: 0,
+  path: '',
+  icon: '',
+  component: '',
+  componentName: '',
+  status: CommonStatusEnum.ENABLE,
+  visible: true,
+  keepAlive: true,
+  alwaysShow: true,
+})
+const formRules = {
+  name: [{ required: true, message: '菜单名称不能为空' }],
+  type: [{ required: true, message: '菜单类型不能为空' }],
+  sort: [{ required: true, message: '显示排序不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 菜单类型变更: */
+function handleTypeChange() {
+  // 切换类型时,清空不需要的字段
+  if (formData.value.type === SystemMenuTypeEnum.BUTTON) {
+    formData.value.path = ''
+    formData.value.component = ''
+    formData.value.componentName = ''
+    formData.value.icon = ''
+  } else if (formData.value.type === SystemMenuTypeEnum.DIR) {
+    formData.value.component = ''
+    formData.value.componentName = ''
+    formData.value.permission = ''
+  }
+}
+
+/** 加载菜单详情 */
+async function getDetail() {
+  if (!props.id) {
+    // 新增时,设置默认的上级菜单
+    if (props.parentId) {
+      formData.value.parentId = props.parentId
+    }
+    return
+  }
+  formData.value = await getMenu(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+  // 路由地址校验
+  if (formData.value.type !== SystemMenuTypeEnum.BUTTON) {
+    const path = formData.value.path
+    const isExternal = /^(?:https?:|mailto:|tel:)/.test(path)
+    if (!isExternal) {
+      if (formData.value.parentId === 0 && path.charAt(0) !== '/') {
+        toast.error('路径必须以 / 开头')
+        return
+      } else if (formData.value.parentId !== 0 && path.charAt(0) === '/') {
+        toast.error('路径不能以 / 开头')
+        return
+      }
+    }
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateMenu(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createMenu(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 180 - 0
src/pages-system/menu/index.vue

@@ -0,0 +1,180 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="菜单管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 面包屑导航 -->
+    <Breadcrumb ref="breadcrumbRef" v-model="currentParentId" />
+
+    <!-- 菜单列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in currentList"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+      >
+        <!-- 主内容区域:点击进入详情 -->
+        <view class="p-24rpx" @click="handleDetail(item)">
+          <!-- 第一行:图标、名称、状态标签 -->
+          <view class="flex items-center justify-between">
+            <view class="flex items-center">
+              <view class="mr-16rpx h-48rpx w-48rpx flex items-center justify-center rounded-8rpx" :class="getTypeIconBg(item.type)">
+                <wd-icon :name="getTypeIcon(item.type)" size="20px" color="#fff" />
+              </view>
+              <view class="text-32rpx text-[#333] font-semibold">
+                {{ item.name }}
+              </view>
+            </view>
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <!-- 第二行:类型描述、子菜单入口 -->
+          <view class="mt-12rpx flex items-center justify-between pl-64rpx">
+            <view class="text-24rpx text-[#999]">
+              {{ getTypeDesc(item) }}
+            </view>
+            <view
+              v-if="item.children && item.children.length > 0"
+              class="flex items-center"
+              @click.stop="handleEnterChildren(item)"
+            >
+              <text class="text-24rpx text-[#1890ff]">子菜单 ({{ item.children.length }})</text>
+              <wd-icon name="arrow-right" size="12px" color="#1890ff" />
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-if="!loading && currentList.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无菜单数据" />
+      </view>
+    </view>
+
+    <!-- 新增按钮 -->
+    <view
+      class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+      @click="handleAdd"
+    >
+      <wd-icon name="add" size="24px" color="#fff" />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Menu } from '@/api/system/menu'
+import { computed, onMounted, ref } from 'vue'
+import { getMenuList } from '@/api/system/menu'
+import { DICT_TYPE, SystemMenuTypeEnum } from '@/utils/constants'
+import { findChildren, handleTree } from '@/utils/tree'
+import Breadcrumb from './components/breadcrumb.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const loading = ref(false)
+const list = ref<Menu[]>([]) // 完整菜单列表(树形结构)
+
+const currentParentId = ref(0) // 当前层级的父节点编号
+const currentList = computed(() => {
+  if (currentParentId.value === 0) {
+    return list.value.filter(item => item.parentId === 0)
+  }
+  return findChildren(list.value, currentParentId.value)
+}) // 当前层级的菜单列表
+const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
+
+/** 返回上一页或上一层级 */
+function handleBack() {
+  if (!breadcrumbRef.value?.back()) {
+    uni.navigateBack()
+  }
+}
+
+/** 获取菜单类型图标 */
+function getTypeIcon(type: number): string {
+  switch (type) {
+    case SystemMenuTypeEnum.DIR:
+      return 'folder'
+    case SystemMenuTypeEnum.MENU:
+      return 'read'
+    case SystemMenuTypeEnum.BUTTON:
+      return 'tips'
+    default:
+      return 'folder'
+  }
+}
+
+/** 获取菜单类型图标背景色 */
+function getTypeIconBg(type: number): string {
+  switch (type) {
+    case SystemMenuTypeEnum.DIR:
+      return 'bg-[#1890ff]'
+    case SystemMenuTypeEnum.MENU:
+      return 'bg-[#52c41a]'
+    case SystemMenuTypeEnum.BUTTON:
+      return 'bg-[#faad14]'
+    default:
+      return 'bg-[#1890ff]'
+  }
+}
+
+/** 获取菜单类型描述(根据类型展示不同信息) */
+function getTypeDesc(item: Menu): string {
+  switch (item.type) {
+    case SystemMenuTypeEnum.DIR:
+      return `路由:${item.path}`
+    case SystemMenuTypeEnum.MENU:
+      return `路由:${item.path}`
+    case SystemMenuTypeEnum.BUTTON:
+      return `权限:${item.permission}`
+    default:
+      return ''
+  }
+}
+
+/** 进入子菜单层级 */
+function handleEnterChildren(item: Menu) {
+  breadcrumbRef.value?.enter({ id: item.id!, name: item.name })
+}
+
+/** 查询菜单列表 */
+async function getList() {
+  loading.value = true
+  try {
+    const data = await getMenuList()
+    list.value = handleTree(data)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 新增菜单 */
+function handleAdd() {
+  uni.navigateTo({
+    url: `/pages-system/menu/form/index?parentId=${currentParentId.value}`,
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: Menu) {
+  uni.navigateTo({
+    url: `/pages-system/menu/detail/index?id=${item.id}`,
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 116 - 0
src/pages-system/post/components/search-form.vue

@@ -0,0 +1,116 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索岗位
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          岗位名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入岗位名称"
+          clearable
+        />
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          岗位编码
+        </view>
+        <wd-input
+          v-model="formData.code"
+          placeholder="请输入岗位编码"
+          clearable
+        />
+      </view>
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio :value="0">
+            启用
+          </wd-radio>
+          <wd-radio :value="1">
+            禁用
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, watch } from 'vue'
+
+/** 搜索表单数据 */
+export interface SearchFormData {
+  name?: string
+  code?: string
+  status: number // -1 表示全部
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<SearchFormData> // 初始搜索参数
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: SearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<SearchFormData>({
+  name: undefined,
+  code: undefined,
+  status: -1,
+})
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.name = props.searchParams.name
+    formData.code = props.searchParams.code
+    formData.status = props.searchParams.status ?? -1
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.code = undefined
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 123 - 0
src/pages-system/post/detail/index.vue

@@ -0,0 +1,123 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="岗位详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="岗位名称" :value="formData?.name || '-'" />
+        <wd-cell title="岗位编码" :value="formData?.code || '-'" />
+        <wd-cell title="显示顺序" :value="String(formData?.sort ?? '-')" />
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="备注" :value="formData?.remark || '-'" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button class="flex-1" type="warning" @click="handleEdit">
+          编辑
+        </wd-button>
+        <wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Post } from '@/api/system/post'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deletePost, getPost } from '@/api/system/post'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<Post>() // 详情数据
+const deleting = ref(false) // 删除中
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载岗位详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getPost(props.id)
+}
+
+/** 编辑岗位 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/post/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除岗位 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该岗位吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deletePost(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 160 - 0
src/pages-system/post/form/index.vue

@@ -0,0 +1,160 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view class="p-24rpx">
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group custom-class="cell-group" border>
+          <wd-input
+            v-model="formData.name"
+            label="岗位名称"
+            label-width="180rpx"
+            prop="name"
+            clearable
+            placeholder="请输入岗位名称"
+          />
+          <wd-input
+            v-model="formData.code"
+            label="岗位编码"
+            label-width="180rpx"
+            prop="code"
+            clearable
+            placeholder="请输入岗位编码"
+          />
+          <wd-cell title="显示顺序" title-width="180rpx" prop="sort" center>
+            <wd-input-number
+              v-model="formData.sort"
+              :min="0"
+            />
+          </wd-cell>
+          <wd-cell title="状态" title-width="180rpx" prop="status" center>
+            <wd-switch
+              v-model="formData.status"
+              :active-value="CommonStatusEnum.ENABLE"
+              :inactive-value="CommonStatusEnum.DISABLE"
+            />
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="180rpx"
+            placeholder="请输入备注"
+            :maxlength="200"
+            show-word-limit
+            clearable
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Post } from '@/api/system/post'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createPost, getPost, updatePost } from '@/api/system/post'
+import { CommonStatusEnum } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑岗位' : '新增岗位')
+const formLoading = ref(false) // 提交中状态
+const formData = ref<Post>({
+  id: undefined,
+  name: '',
+  code: '',
+  sort: 0,
+  status: CommonStatusEnum.ENABLE,
+  remark: '',
+})
+const formRules = {
+  name: [{ required: true, message: '岗位名称不能为空' }],
+  code: [{ required: true, message: '岗位编码不能为空' }],
+  sort: [{ required: true, message: '显示顺序不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载岗位详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getPost(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updatePost(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createPost(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 171 - 0
src/pages-system/post/index.vue

@@ -0,0 +1,171 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="岗位管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    >
+      <template #right>
+        <view class="flex items-center" @click="searchVisible = !searchVisible">
+          <wd-icon name="search" size="20px" />
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- 岗位列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-32rpx text-[#333] font-semibold">
+              {{ item.name }}
+            </view>
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">岗位编码:</text>
+            <text>{{ item.code }}</text>
+          </view>
+          <view v-if="item.remark" class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">备注:</text>
+            <text class="line-clamp-1">{{ item.remark }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无岗位数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <SearchForm
+      v-model="searchVisible"
+      :search-params="queryParams"
+      @search="handleQuery"
+      @reset="handleReset"
+    />
+
+    <!-- 新增按钮 -->
+    <view
+      class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+      @click="handleAdd"
+    >
+      <wd-icon name="add" size="24px" color="#fff" />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Post } from '@/api/system/post'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref } from 'vue'
+import { getPostPage } from '@/api/system/post'
+import { DICT_TYPE } from '@/utils/constants'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const total = ref(0) // 列表的总页数
+const list = ref<Post[]>([]) // 列表的数据
+const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
+const searchVisible = ref(false) // 搜索弹窗
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  code: undefined as string | undefined,
+  status: -1 as number, // -1 表示全部
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 查询岗位列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getPostPage({
+      ...queryParams,
+      status: queryParams.status === -1 ? undefined : queryParams.status,
+    })
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: { name?: string, code?: string, status?: number }) {
+  queryParams.name = data?.name
+  queryParams.code = data?.code
+  queryParams.status = data?.status ?? -1
+  queryParams.pageNo = 1
+  list.value = [] // 清空列表
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 新增岗位 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/post/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: Post) {
+  uni.navigateTo({
+    url: `/pages-system/post/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 116 - 0
src/pages-system/role/components/search-form.vue

@@ -0,0 +1,116 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索角色
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          角色名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入角色名称"
+          clearable
+        />
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          角色标识
+        </view>
+        <wd-input
+          v-model="formData.code"
+          placeholder="请输入角色标识"
+          clearable
+        />
+      </view>
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio :value="0">
+            启用
+          </wd-radio>
+          <wd-radio :value="1">
+            禁用
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, watch } from 'vue'
+
+/** 搜索表单数据 */
+export interface SearchFormData {
+  name?: string
+  code?: string
+  status: number // -1 表示全部
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<SearchFormData> // 初始搜索参数
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: SearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<SearchFormData>({
+  name: undefined,
+  code: undefined,
+  status: -1,
+})
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.name = props.searchParams.name
+    formData.code = props.searchParams.code
+    formData.status = props.searchParams.status ?? -1
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.code = undefined
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 124 - 0
src/pages-system/role/detail/index.vue

@@ -0,0 +1,124 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="角色详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="角色名称" :value="formData?.name || '-'" />
+        <wd-cell title="角色标识" :value="formData?.code || '-'" />
+        <wd-cell title="显示顺序" :value="String(formData?.sort ?? '-')" />
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="备注" :value="formData?.remark || '-'" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button class="flex-1" type="warning" @click="handleEdit">
+          编辑
+        </wd-button>
+        <wd-button class="flex-1" type="error" :loading="deleting" @click="handleDelete">
+          删除
+        </wd-button>
+      </view>
+      <!-- TODO @芋艿:1)数据权限;2)菜单权限 -->
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Role } from '@/api/system/role'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteRole, getRole } from '@/api/system/role'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<Role>() // 详情数据
+const deleting = ref(false) // 删除中
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载角色详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getRole(props.id)
+}
+
+/** 编辑角色 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/role/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除角色 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该角色吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteRole(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 161 - 0
src/pages-system/role/form/index.vue

@@ -0,0 +1,161 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view class="p-24rpx">
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group custom-class="cell-group" border>
+          <wd-input
+            v-model="formData.name"
+            label="角色名称"
+            label-width="180rpx"
+            prop="name"
+            clearable
+            placeholder="请输入角色名称"
+          />
+          <wd-input
+            v-model="formData.code"
+            label="角色标识"
+            label-width="180rpx"
+            prop="code"
+            clearable
+            placeholder="请输入角色标识"
+          />
+          <wd-cell title="显示顺序" title-width="180rpx" prop="sort" center>
+            <wd-input-number
+              v-model="formData.sort"
+              :min="0"
+            />
+          </wd-cell>
+          <wd-cell title="状态" title-width="180rpx" prop="status" center>
+            <wd-switch
+              v-model="formData.status"
+              :active-value="CommonStatusEnum.ENABLE"
+              :inactive-value="CommonStatusEnum.DISABLE"
+            />
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="180rpx"
+            placeholder="请输入备注"
+            :maxlength="200"
+            show-word-limit
+            clearable
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Role } from '@/api/system/role'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createRole, getRole, updateRole } from '@/api/system/role'
+import { CommonStatusEnum } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑角色' : '新增角色')
+const formLoading = ref(false) // 提交中状态
+const formData = ref<Role>({
+  id: undefined,
+  name: '',
+  code: '',
+  sort: 0,
+  status: CommonStatusEnum.ENABLE,
+  remark: '',
+  createTime: '',
+})
+const formRules = {
+  name: [{ required: true, message: '角色名称不能为空' }],
+  code: [{ required: true, message: '角色标识不能为空' }],
+  sort: [{ required: true, message: '显示顺序不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载角色详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getRole(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateRole(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createRole(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 174 - 0
src/pages-system/role/index.vue

@@ -0,0 +1,174 @@
+<template>
+  <!-- TODO @芋艿:【优化:全局样式】后续要全局样式么 -->
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="角色管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    >
+      <template #right>
+        <view class="flex items-center" @click="searchVisible = !searchVisible">
+          <wd-icon name="search" size="20px" />
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- 角色列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-32rpx text-[#333] font-semibold">
+              {{ item.name }}
+            </view>
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">角色标识:</text>
+            <text>{{ item.code }}</text>
+          </view>
+          <view v-if="item.remark" class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">备注:</text>
+            <text class="line-clamp-1">{{ item.remark }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无角色数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <SearchForm
+      v-model="searchVisible"
+      :search-params="queryParams"
+      @search="handleQuery"
+      @reset="handleReset"
+    />
+
+    <!-- 新增按钮 -->
+    <!-- TODO @芋艿:【优化:全局样式】后续要全局样式么 -->
+    <view
+      class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+      @click="handleAdd"
+    >
+      <wd-icon name="add" size="24px" color="#fff" />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SearchFormData } from './components/search-form.vue'
+import type { Role } from '@/api/system/role'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref } from 'vue'
+import { getRolePage } from '@/api/system/role'
+import { DICT_TYPE } from '@/utils/constants'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const total = ref(0) // 列表的总页数
+const list = ref<Role[]>([]) // 列表的数据
+const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
+const searchVisible = ref(false) // 搜索弹窗
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  code: undefined as string | undefined,
+  status: -1 as number, // -1 表示全部
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 查询角色列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getRolePage({
+      ...queryParams,
+      status: queryParams.status === -1 ? undefined : queryParams.status,
+    })
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: SearchFormData) {
+  queryParams.name = data?.name
+  queryParams.code = data?.code
+  queryParams.status = data?.status
+  queryParams.pageNo = 1
+  list.value = [] // 清空列表
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 新增角色 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/role/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: Role) {
+  uni.navigateTo({
+    url: `/pages-system/role/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 96 - 0
src/pages-system/user/components/search-form.vue

@@ -0,0 +1,96 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索用户
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          用户名称
+        </view>
+        <wd-input
+          v-model="formData.username"
+          placeholder="请输入用户名称"
+          clearable
+        />
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          用户昵称
+        </view>
+        <wd-input
+          v-model="formData.nickname"
+          placeholder="请输入用户昵称"
+          clearable
+        />
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, watch } from 'vue'
+
+/** 搜索表单数据 */
+export interface SearchFormData {
+  username?: string
+  nickname?: string
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<SearchFormData> // 初始搜索参数
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: SearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<SearchFormData>({
+  username: undefined,
+  nickname: undefined,
+})
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.username = props.searchParams.username
+    formData.nickname = props.searchParams.nickname
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.username = undefined
+  formData.nickname = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 106 - 0
src/pages-system/user/detail/components/password-form.vue

@@ -0,0 +1,106 @@
+<template>
+  <wd-popup v-model="visible" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;" @close="handleClose">
+    <view class="p-32rpx">
+      <view class="mb-24rpx flex items-center justify-between">
+        <text class="text-32rpx text-[#333] font-semibold">重置密码</text>
+        <wd-icon name="close" size="20px" @click="handleClose" />
+      </view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-input
+          v-model="formData.password"
+          label="新密码"
+          label-width="160rpx"
+          prop="password"
+          show-password
+          clearable
+          placeholder="请输入新密码"
+        />
+        <wd-input
+          v-model="formData.confirmPassword"
+          label="确认密码"
+          label-width="160rpx"
+          prop="confirmPassword"
+          show-password
+          clearable
+          placeholder="请再次输入新密码"
+        />
+      </wd-form>
+      <view class="mt-32rpx">
+        <wd-button type="primary" block :loading="loading" @click="handleConfirm">
+          确定
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { resetUserPassword } from '@/api/system/user'
+
+const props = defineProps<{
+  modelValue: boolean
+  userId: number
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'success': []
+}>()
+
+const toast = useToast()
+const visible = computed({
+  get: () => props.modelValue,
+  set: val => emit('update:modelValue', val),
+})
+const loading = ref(false)
+const formRef = ref()
+const formData = ref({
+  password: '',
+  confirmPassword: '',
+})
+const formRules = {
+  password: [{ required: true, message: '请输入新密码' }],
+  confirmPassword: [
+    { required: true, message: '请再次输入新密码' },
+    {
+      required: false,
+      validator: (value: string) => value === formData.value.password,
+      message: '两次输入的密码不一致',
+    },
+  ],
+}
+
+/** 监听弹窗打开,重置表单 */
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) {
+      formData.value = { password: '', confirmPassword: '' }
+    }
+  },
+)
+
+/** 关闭弹窗 */
+function handleClose() {
+  visible.value = false
+}
+
+/** 确认提交 */
+async function handleConfirm() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+  loading.value = true
+  try {
+    await resetUserPassword(props.userId, formData.value.password)
+    toast.success('密码重置成功')
+    handleClose()
+    emit('success')
+  } finally {
+    loading.value = false
+  }
+}
+</script>

+ 80 - 0
src/pages-system/user/detail/components/role-assign-form.vue

@@ -0,0 +1,80 @@
+<template>
+  <wd-popup v-model="visible" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;" @close="handleClose">
+    <view class="p-32rpx">
+      <view class="mb-24rpx flex items-center justify-between">
+        <text class="text-32rpx text-[#333] font-semibold">分配角色</text>
+        <wd-icon name="close" size="20px" @click="handleClose" />
+      </view>
+      <wd-checkbox-group v-model="selectedIds" cell shape="button">
+        <wd-checkbox v-for="item in roleList" :key="item.id" :model-value="item.id">
+          {{ item.name }}
+        </wd-checkbox>
+      </wd-checkbox-group>
+      <view class="mt-32rpx">
+        <wd-button type="primary" block :loading="loading" @click="handleConfirm">
+          确定
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import type { Role } from '@/api/system/role'
+import { computed, ref, watch } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getSimpleRoleList } from '@/api/system/role'
+import { assignUserRole, getUserRoleIds } from '@/api/system/user'
+
+const props = defineProps<{
+  modelValue: boolean
+  userId: number
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'success': []
+}>()
+
+const toast = useToast()
+const visible = computed({
+  get: () => props.modelValue,
+  set: val => emit('update:modelValue', val),
+})
+const loading = ref(false)
+const roleList = ref<Role[]>([])
+const selectedIds = ref<number[]>([])
+
+/** 监听弹窗打开,加载数据 */
+watch(
+  () => props.modelValue,
+  async (val) => {
+    if (val) {
+      // 加载角色列表
+      if (roleList.value.length === 0) {
+        roleList.value = await getSimpleRoleList()
+      }
+      // 加载用户已有角色
+      selectedIds.value = await getUserRoleIds(props.userId)
+    }
+  },
+)
+
+/** 关闭弹窗 */
+function handleClose() {
+  visible.value = false
+}
+
+/** 确认提交 */
+async function handleConfirm() {
+  loading.value = true
+  try {
+    await assignUserRole(props.userId, selectedIds.value)
+    toast.success('角色分配成功')
+    handleClose()
+    emit('success')
+  } finally {
+    loading.value = false
+  }
+}
+</script>

+ 207 - 0
src/pages-system/user/detail/index.vue

@@ -0,0 +1,207 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="用户详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view class="p-24rpx pb-200rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell title="头像">
+          <view v-if="formData?.avatar" class="h-80rpx w-80rpx overflow-hidden rounded-full">
+            <image :src="formData.avatar" class="h-full w-full" mode="aspectFill" />
+          </view>
+          <text v-else>-</text>
+        </wd-cell>
+        <wd-cell title="用户昵称" :value="formData?.nickname || '-'" />
+        <wd-cell title="用户账号" :value="formData?.username || '-'" />
+        <wd-cell title="手机号码" :value="formData?.mobile || '-'" />
+        <wd-cell title="邮箱" :value="formData?.email || '-'" />
+        <wd-cell title="部门" :value="formData?.deptName || '-'" />
+        <wd-cell title="性别">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="formData?.sex" />
+        </wd-cell>
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.sex" />
+        </wd-cell>
+        <wd-cell title="备注" :value="formData?.remark || '-'" />
+        <wd-cell title="最后登录 IP" :value="formData?.loginIp || '-'" />
+        <wd-cell title="最后登录时间" :value="formatDateTime(formData?.loginDate) || '-'" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:user:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:user:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+        <wd-button
+          v-if="hasMoreActions"
+          class="flex-1" type="info" @click="moreActionVisible = true"
+        >
+          更多
+        </wd-button>
+      </view>
+    </view>
+
+    <!-- 更多操作菜单 -->
+    <wd-action-sheet v-model="moreActionVisible" :actions="moreActions" @select="handleMoreAction" />
+    <!-- 重置密码弹窗 -->
+    <PasswordForm v-model="passwordFormVisible" :user-id="props.id" @success="getDetail" />
+    <!-- 分配角色弹窗 -->
+    <RoleAssignForm v-model="roleAssignFormVisible" :user-id="props.id" @success="getDetail" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { User } from '@/api/system/user'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteUser, getUser, updateUserStatus } from '@/api/system/user'
+import { useAccess } from '@/hooks/useAccess'
+import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import PasswordForm from './components/password-form.vue'
+import RoleAssignForm from './components/role-assign-form.vue'
+
+const props = defineProps<{
+  id: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<User>() // 详情数据
+const deleting = ref(false) // 删除中
+const moreActionVisible = ref(false) // 更多操作菜单
+const passwordFormVisible = ref(false) // 密码表单弹窗
+const roleAssignFormVisible = ref(false) // 角色分配弹窗
+const moreActions = computed(() => {
+  const actions = []
+  // 修改状态权限
+  if (hasAccessByCodes(['system:user:update'])) {
+    actions.push({ name: formData.value?.status === 1 ? '禁用用户' : '开启用户', value: 'update-status' })
+  }
+  // 重置密码权限
+  if (hasAccessByCodes(['system:user:update-password'])) {
+    actions.push({ name: '重置密码', value: 'resetPassword' })
+  }
+  // 分配角色权限
+  if (hasAccessByCodes(['system:permission:assign-user-role'])) {
+    actions.push({ name: '分配角色', value: 'assignRole' })
+  }
+  return actions
+})
+const hasMoreActions = computed(() => moreActions.value.length > 0)
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载用户详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getUser(props.id)
+}
+
+/** 编辑用户 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/user/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除用户 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该用户吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteUser(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 更多操作 */
+function handleMoreAction({ item }: { item: { value: string } }) {
+  if (item.value === 'resetPassword') {
+    passwordFormVisible.value = true
+  } else if (item.value === 'assignRole') {
+    roleAssignFormVisible.value = true
+  } else if (item.value === 'update-status') {
+    handleUpdateStatus()
+  }
+}
+
+/** 修改用户状态 */
+function handleUpdateStatus() {
+  const isDisable = formData.value.status === CommonStatusEnum.DISABLE
+  uni.showModal({
+    title: '提示',
+    content: isDisable ? '确定要禁用该用户吗?' : '确定要开启该用户吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      await updateUserStatus(props.id, formData.value.status === 1 ? 0 : 1)
+      toast.success(isDisable ? '禁用成功' : '开启成功')
+      await getDetail()
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 129 - 0
src/pages-system/user/form/components/dept-picker.vue

@@ -0,0 +1,129 @@
+<!-- TODO @芋艿:【优化】看看后续要不要抽成组件! -->
+<template>
+  <wd-col-picker
+    v-model="selectedValue"
+    label="归属部门"
+    label-width="180rpx"
+    :columns="deptColumns"
+    :column-change="handleColumnChange"
+    :display-format="displayFormat"
+    @confirm="handleConfirm"
+  />
+</template>
+
+<script lang="ts" setup>
+import type { Dept } from '@/api/system/dept'
+import { onMounted, ref, watch } from 'vue'
+import { getSimpleDeptList } from '@/api/system/dept'
+
+const props = defineProps<{
+  modelValue?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: number | undefined): void
+}>()
+
+const deptList = ref<Dept[]>([])
+const deptColumns = ref<any[]>([])
+const selectedValue = ref<number[]>([])
+
+/** 监听外部值变化,回显选中值 */
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val && deptList.value.length > 0) {
+      const path = findDeptPath(val)
+      selectedValue.value = path
+      // 构建列数据以支持回显
+      buildColumnsForPath(path)
+    }
+    else {
+      selectedValue.value = []
+    }
+  },
+)
+
+/** 加载部门列表 */
+async function loadDeptList() {
+  deptList.value = await getSimpleDeptList()
+  // 构建第一列数据(顶级部门)
+  const topDepts = deptList.value.filter(item => item.parentId === 0)
+  deptColumns.value = [topDepts.map(item => ({ value: item.id, label: item.name }))]
+  // 如果有初始值,回显
+  if (props.modelValue) {
+    const path = findDeptPath(props.modelValue)
+    selectedValue.value = path
+    buildColumnsForPath(path)
+  }
+}
+
+/** 查找部门路径 */
+function findDeptPath(targetId: number): number[] {
+  const path: number[] = []
+  const findPath = (parentId: number, id: number): boolean => {
+    const items = deptList.value.filter(d => d.parentId === parentId)
+    for (const item of items) {
+      if (item.id === id) {
+        path.push(item.id)
+        return true
+      }
+      if (findPath(item.id, id)) {
+        path.unshift(item.id)
+        return true
+      }
+    }
+    return false
+  }
+  findPath(0, targetId)
+  return path
+}
+
+/** 根据路径构建列数据 */
+function buildColumnsForPath(path: number[]) {
+  if (path.length === 0) {
+    return
+  }
+  // 第一列已经有了,从第二列开始构建
+  const columns = [deptColumns.value[0]]
+  for (let i = 0; i < path.length - 1; i++) {
+    const parentId = path[i]
+    const children = deptList.value.filter(item => item.parentId === parentId)
+    if (children.length > 0) {
+      columns.push(children.map(item => ({ value: item.id, label: item.name })))
+    }
+  }
+  deptColumns.value = columns
+}
+
+/** 列变化 */
+function handleColumnChange({ selectedItem, resolve, finish }: any) {
+  const children = deptList.value.filter(item => item.parentId === selectedItem.value)
+  if (children.length > 0) {
+    resolve(children.map(item => ({ value: item.id, label: item.name })))
+  }
+  else {
+    finish()
+  }
+}
+
+/** 格式化显示 */
+function displayFormat(selectedItems: any[]) {
+  return selectedItems.map(item => item.label).join('/')
+}
+
+/** 确认选择 */
+function handleConfirm({ value }: { value: number[] }) {
+  if (value && value.length > 0) {
+    emit('update:modelValue', value[value.length - 1])
+  }
+  else {
+    emit('update:modelValue', undefined)
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  loadDeptList()
+})
+</script>

+ 82 - 0
src/pages-system/user/form/components/post-picker.vue

@@ -0,0 +1,82 @@
+<!-- TODO @芋艿:【优化】看看后续要不要抽成组件! -->
+<template>
+  <!-- 岗位选择单元格 -->
+  <wd-cell title="岗位" title-width="180rpx" is-link @click="popupVisible = true">
+    <view class="text-left">
+      {{ displayText }}
+    </view>
+  </wd-cell>
+
+  <!-- 岗位选择弹窗 -->
+  <wd-popup v-model="popupVisible" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;">
+    <view class="p-32rpx">
+      <view class="mb-24rpx flex items-center justify-between">
+        <text class="text-32rpx text-[#333] font-semibold">选择岗位</text>
+        <wd-icon name="close" size="20px" @click="popupVisible = false" />
+      </view>
+      <wd-checkbox-group v-model="selectedIds" cell shape="button">
+        <wd-checkbox v-for="item in postList" :key="item.id" :model-value="item.id">
+          {{ item.name }}
+        </wd-checkbox>
+      </wd-checkbox-group>
+      <view class="mt-32rpx">
+        <wd-button type="primary" block @click="handleConfirm">
+          确定
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import type { Post } from '@/api/system/post'
+import { computed, onMounted, ref, watch } from 'vue'
+import { getSimplePostList } from '@/api/system/post'
+
+const props = defineProps<{
+  modelValue?: number[]
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: number[]): void
+}>()
+
+const popupVisible = ref(false)
+const postList = ref<Post[]>([])
+const selectedIds = ref<number[]>([])
+
+const displayText = computed(() => {
+  if (!selectedIds.value || selectedIds.value.length === 0) {
+    return ''
+  }
+  return postList.value
+    .filter(item => selectedIds.value.includes(item.id))
+    .map(item => item.name)
+    .join('、')
+}) // 显示文本
+
+/** 监听外部值变化 */
+watch(
+  () => props.modelValue,
+  (val) => {
+    selectedIds.value = val || []
+  },
+  { immediate: true },
+)
+
+/** 加载岗位列表 */
+async function loadPostList() {
+  postList.value = await getSimplePostList()
+}
+
+/** 确认选择 */
+function handleConfirm() {
+  emit('update:modelValue', selectedIds.value)
+  popupVisible.value = false
+}
+
+/** 初始化 */
+onMounted(() => {
+  loadPostList()
+})
+</script>

+ 201 - 0
src/pages-system/user/form/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view class="p-24rpx">
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group custom-class="cell-group" border>
+          <wd-input
+            v-model="formData.username"
+            label="用户名称"
+            label-width="180rpx"
+            prop="username"
+            clearable
+            placeholder="请输入用户名称"
+          />
+          <wd-input
+            v-if="!props.id"
+            v-model="formData.password"
+            label="用户密码"
+            label-width="180rpx"
+            prop="password"
+            show-password
+            clearable
+            placeholder="请输入用户密码"
+          />
+          <wd-input
+            v-model="formData.nickname"
+            label="用户昵称"
+            label-width="180rpx"
+            prop="nickname"
+            clearable
+            placeholder="请输入用户昵称"
+          />
+          <DeptPicker v-model="formData.deptId" />
+          <PostPicker v-model="formData.postIds" />
+          <wd-input
+            v-model="formData.email"
+            label="邮箱"
+            label-width="180rpx"
+            prop="email"
+            clearable
+            placeholder="请输入邮箱"
+          />
+          <wd-input
+            v-model="formData.mobile"
+            label="手机号码"
+            label-width="180rpx"
+            prop="mobile"
+            clearable
+            placeholder="请输入手机号码"
+          />
+          <wd-cell title="性别" title-width="180rpx" center prop="sex">
+            <wd-radio-group v-model="formData.sex" shape="button" size="medium">
+              <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" :key="dict.value" :value="dict.value">
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-cell title="状态" title-width="180rpx" center prop="status">
+            <wd-switch
+              v-model="formData.status"
+              :active-value="CommonStatusEnum.ENABLE"
+              :inactive-value="CommonStatusEnum.DISABLE"
+            />
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="180rpx"
+            placeholder="请输入备注"
+            :maxlength="200"
+            show-word-limit
+            clearable
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { User } from '@/api/system/user'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createUser, getUser, updateUser } from '@/api/system/user'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
+import { isEmail, isMobile } from '@/utils/validator'
+import DeptPicker from './components/dept-picker.vue'
+import PostPicker from './components/post-picker.vue'
+
+const props = defineProps<{
+  id?: number
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑用户' : '新增用户')
+const formLoading = ref(false) // 提交中状态
+const formData = ref<User>({
+  id: undefined,
+  username: '',
+  nickname: '',
+  password: '',
+  mobile: '',
+  email: '',
+  sex: undefined,
+  deptId: undefined,
+  postIds: [],
+  status: CommonStatusEnum.ENABLE,
+  remark: '',
+})
+const formRules = {
+  username: [{ required: true, message: '用户名称不能为空' }],
+  password: [{ required: true, message: '用户密码不能为空' }],
+  nickname: [{ required: true, message: '用户昵称不能为空' }],
+  email: [{ required: false, validator: (value: string) => !value || isEmail(value), message: '请输入正确的邮箱地址' }],
+  mobile: [{ required: false, validator: (value: string) => !value || isMobile(value), message: '请输入正确的手机号码' }],
+  sex: [{ required: true, message: '性别不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载用户详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getUser(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateUser(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createUser(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 183 - 0
src/pages-system/user/index.vue

@@ -0,0 +1,183 @@
+<template>
+  <!-- TODO @芋艿:【优化:全局样式】后续要全局样式么 -->
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="用户管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    >
+      <template #right>
+        <view class="flex items-center" @click="searchVisible = !searchVisible">
+          <wd-icon name="search" size="20px" />
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- 用户列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="relative p-24rpx">
+          <view class="absolute right-24rpx top-24rpx">
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <view class="flex items-center gap-16rpx">
+            <view
+              v-if="item.avatar"
+              class="h-80rpx w-80rpx overflow-hidden rounded-full"
+            >
+              <image :src="item.avatar" class="h-full w-full" mode="aspectFill" />
+            </view>
+            <view
+              v-else
+              class="h-80rpx w-80rpx flex items-center justify-center rounded-full bg-[#1890ff] text-32rpx text-white"
+            >
+              {{ item.nickname?.charAt(0) || item.username?.charAt(0) }}
+            </view>
+            <view>
+              <view class="text-32rpx text-[#333] font-semibold">
+                {{ item.nickname || item.username }}
+              </view>
+              <view class="text-24rpx text-[#999]">
+                {{ item.deptName || '未分配部门' }}
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无用户数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <SearchForm
+      v-model="searchVisible"
+      :search-params="queryParams"
+      @search="handleQuery"
+      @reset="handleReset"
+    />
+
+    <!-- 新增按钮 -->
+    <!-- TODO @芋艿:【优化:全局样式】后续要全局样式么 -->
+    <view
+      v-if="hasAccessByCodes(['system:user:create'])"
+      class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+      @click="handleAdd"
+    >
+      <wd-icon name="add" size="24px" color="#fff" />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SearchFormData } from './components/search-form.vue'
+import type { User } from '@/api/system/user'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref } from 'vue'
+import { getUserPage } from '@/api/system/user'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0) // 列表的总页数
+const list = ref<User[]>([]) // 列表的数据
+const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
+const searchVisible = ref(false) // 搜索弹窗
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  username: undefined as string | undefined,
+  nickname: undefined as string | undefined,
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 查询用户列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getUserPage(queryParams)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: SearchFormData) {
+  queryParams.username = data?.username
+  queryParams.nickname = data?.nickname
+  queryParams.pageNo = 1
+  list.value = [] // 清空列表
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  getList()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 新增用户 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/user/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: User) {
+  uni.navigateTo({
+    url: `/pages-system/user/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 135 - 0
src/pages/auth/code-login.vue

@@ -0,0 +1,135 @@
+<template>
+  <view class="auth-container">
+    <!-- 顶部 -->
+    <Header />
+
+    <!-- 表单区域 -->
+    <view class="form-container">
+      <TenantPicker ref="tenantPickerRef" />
+      <view class="input-item">
+        <wd-icon name="phone" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.mobile"
+          placeholder="请输入手机号"
+          clearable
+          clear-trigger="focus"
+          no-border
+          type="number"
+          :maxlength="11"
+        />
+      </view>
+      <CodeInput
+        v-model="formData.code"
+        :mobile="formData.mobile"
+        :scene="21"
+        :before-send="validateBeforeSend"
+      />
+
+      <!-- 登录按钮 -->
+      <view class="mb-2 mt-2 flex justify-between">
+        <text class="text-28rpx text-[#1890ff]" @click="goToLogin">
+          账号登录
+        </text>
+        <text class="text-28rpx text-[#1890ff]" @click="goToForgetPassword">
+          忘记密码?
+        </text>
+      </view>
+      <wd-button block :loading="loading" type="primary" @click="handleLogin">
+        登录
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import { useToast } from "wot-design-uni";
+import { FORGET_PASSWORD_PAGE, LOGIN_PAGE } from "@/router/config";
+import { useTokenStore } from "@/store/token";
+import { ensureDecodeURIComponent, redirectAfterLogin } from "@/utils";
+import { isMobile } from "@/utils/validator";
+import CodeInput from "./components/code-input.vue";
+import Header from "./components/header.vue";
+import TenantPicker from "./components/tenant-picker.vue";
+
+defineOptions({
+  name: "SmsLoginPage",
+});
+
+definePage({
+  style: {
+    navigationStyle: "custom",
+  },
+  excludeLoginPath: true,
+});
+
+const toast = useToast();
+const loading = ref(false); // 加载状态
+const redirectUrl = ref<string>(); // 重定向地址
+const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
+
+const formData = reactive({
+  mobile: "",
+  code: "",
+}); // 表单数据
+
+/** 页面加载时处理重定向 */
+onLoad((options) => {
+  if (options?.redirect) {
+    redirectUrl.value = ensureDecodeURIComponent(options.redirect);
+  }
+});
+
+/** 发送验证码前的校验 */
+function validateBeforeSend(): boolean {
+  return tenantPickerRef.value?.validate() ?? false;
+}
+
+/** 登录处理 */
+async function handleLogin() {
+  // 校验租户
+  if (!tenantPickerRef.value?.validate()) {
+    return;
+  }
+  if (!formData.mobile) {
+    toast.warning("请输入手机号");
+    return;
+  }
+  if (!isMobile(formData.mobile)) {
+    toast.warning("请输入正确的手机号");
+    return;
+  }
+  if (!formData.code) {
+    toast.warning("请输入验证码");
+    return;
+  }
+
+  loading.value = true;
+  try {
+    // 调用短信登录接口
+    const tokenStore = useTokenStore();
+    await tokenStore.login({
+      type: "sms",
+      ...formData,
+    });
+    // 处理跳转
+    redirectAfterLogin(redirectUrl.value);
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 跳转到账号密码登录 */
+function goToLogin() {
+  uni.navigateTo({ url: LOGIN_PAGE });
+}
+
+/** 跳转到忘记密码 */
+function goToForgetPassword() {
+  uni.navigateTo({ url: FORGET_PASSWORD_PAGE });
+}
+</script>
+
+<style lang="scss" scoped>
+@import "./styles/auth.scss";
+</style>

+ 90 - 0
src/pages/auth/components/code-input.vue

@@ -0,0 +1,90 @@
+<template>
+  <view class="input-item">
+    <wd-icon name="lock-on" size="20px" color="#1890ff" />
+    <wd-input
+      :model-value="modelValue"
+      placeholder="请输入验证码"
+      clearable
+      clear-trigger="focus"
+      no-border
+      type="number"
+      :maxlength="6"
+      @update:model-value="$emit('update:modelValue', $event)"
+    />
+    <view
+      class="whitespace-nowrap border-l-1rpx border-l-[#e5e5e5] border-l-solid px-20rpx text-28rpx text-[#1890ff]"
+      @click="handleSendCode"
+    >
+      <text :class="{ 'text-gray-400': countdown > 0 }">
+        {{ countdown > 0 ? `${countdown} 秒后重发` : "获取验证码" }}
+      </text>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref } from "vue";
+import { useToast } from "wot-design-uni";
+import { sendSmsCode } from "@/api/login";
+import { isMobile } from "@/utils/validator";
+
+defineOptions({
+  name: "CodeInput",
+});
+
+const props = defineProps<{
+  modelValue: string; // 验证码值 (v-model)
+  mobile: string; // 手机号
+  scene: number; // 短信场景:21-登录 23-重置密码
+  beforeSend?: () => boolean; // 发送前的校验函数,返回 false 则不发送
+}>();
+
+defineEmits<{
+  "update:modelValue": [value: string];
+}>();
+
+const toast = useToast();
+const countdown = ref(0); // 验证码倒计时,单位秒
+let countdownTimer: ReturnType<typeof setInterval> | null = null; // 倒计时定时器
+
+/** 页面卸载时清除倒计时定时器 */
+onUnmounted(() => {
+  if (countdownTimer) {
+    clearInterval(countdownTimer);
+    countdownTimer = null;
+  }
+});
+
+/** 发送验证码 */
+async function handleSendCode() {
+  // 执行前置校验
+  if (props.beforeSend && !props.beforeSend()) {
+    return;
+  }
+  if (countdown.value > 0) {
+    return;
+  }
+  if (!props.mobile) {
+    toast.warning("请输入手机号");
+    return;
+  }
+  if (!isMobile(props.mobile)) {
+    toast.warning("请输入正确的手机号");
+    return;
+  }
+
+  // 发送验证码
+  await sendSmsCode({ mobile: props.mobile, scene: props.scene });
+  toast.success("验证码已发送");
+
+  // 开始倒计时
+  countdown.value = 60;
+  countdownTimer = setInterval(() => {
+    countdown.value--;
+    if (countdown.value <= 0) {
+      clearInterval(countdownTimer!);
+      countdownTimer = null;
+    }
+  }, 1000);
+}
+</script>

+ 12 - 0
src/pages/auth/components/header.vue

@@ -0,0 +1,12 @@
+<template>
+  <view class="header flex flex-col items-center pb-60rpx pt-120rpx">
+    <image class="mb-24rpx h-160rpx w-160rpx" src="/static/logo.svg" mode="aspectFit" />
+    <view class="text-44rpx text-[#1890ff] font-bold">
+      {{ title }}
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+const title = import.meta.env.VITE_APP_TITLE // 应用标题
+</script>

+ 134 - 0
src/pages/auth/components/tenant-picker.vue

@@ -0,0 +1,134 @@
+<template>
+  <view v-if="tenantEnabled" class="input-item">
+    <wd-icon name="home" size="20px" color="#1890ff" />
+    <wd-picker
+      :model-value="tenantId"
+      :columns="tenantList"
+      label-key="name"
+      value-key="id"
+      label=""
+      placeholder="请选择租户"
+      @confirm="handleConfirm"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from "vue";
+import { useToast } from "wot-design-uni";
+import {
+  getTenantByWebsite,
+  getTenantSimpleList,
+  type TenantVO,
+} from "@/api/login";
+import { useUserStore } from "@/store/user";
+
+const toast = useToast();
+const userStore = useUserStore();
+
+const tenantEnabled = computed(
+  () => import.meta.env.VITE_APP_TENANT_ENABLE === "true",
+); // 租户开关:通过环境变量控制
+const tenantList = ref<TenantVO[]>([]); // 租户列表数据
+
+const tenantId = computed(
+  () =>
+    userStore.tenantId ||
+    Number(import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT_ID) ||
+    undefined,
+); // 当前选中的租户
+
+/** 获取租户列表,并根据域名/appId 自动选中租户 */
+async function fetchTenantList() {
+  if (!tenantEnabled.value) {
+    return;
+  }
+  try {
+    // 1. 并行获取租户列表和域名对应的租户
+    const websiteTenantPromise = fetchTenantByWebsite();
+    const list = await getTenantSimpleList();
+    tenantList.value = list || [];
+
+    // 2. 确定选中的租户:域名/appId > store 中的租户 > 列表第一个
+    let selectedTenantId: number | null = null;
+    // 2.1 优先使用域名/appId 对应的租户
+    const websiteTenant = await websiteTenantPromise;
+    if (websiteTenant?.id) {
+      selectedTenantId = websiteTenant.id;
+    }
+    // 2.2 如果没有从域名获取到,使用 store 中的租户
+    if (!selectedTenantId && userStore.tenantId) {
+      selectedTenantId = userStore.tenantId;
+    }
+    // 2.3 如果还是没有,使用列表第一个
+    if (!selectedTenantId && tenantList.value.length > 0) {
+      selectedTenantId = tenantList.value[0].id;
+    }
+
+    // 3. 设置选中的租户
+    if (selectedTenantId && selectedTenantId !== userStore.tenantId) {
+      userStore.setTenantId(selectedTenantId);
+    }
+  } catch (error) {
+    console.error("获取租户列表失败:", error);
+  }
+}
+
+/** 根据域名或 appId 获取租户 */
+async function fetchTenantByWebsite(): Promise<TenantVO | null> {
+  try {
+    let website: string | null = null;
+
+    // #ifdef H5
+    // H5 环境:使用域名
+    if (window?.location?.hostname) {
+      website = window.location.hostname;
+    }
+    // #endif
+
+    // #ifdef MP
+    // 小程序环境:使用 appId
+    const appId = uni.getAccountInfoSync?.()?.miniProgram?.appId;
+    if (appId) {
+      website = appId;
+    }
+    // #endif
+
+    if (website) {
+      return await getTenantByWebsite(website);
+    }
+  } catch (error) {
+    // 域名未配置租户时会报错,忽略即可
+    console.debug("根据域名获取租户失败:", error);
+  }
+  return null;
+}
+
+/** 租户选择确认 */
+function handleConfirm({ value }: { value: number }) {
+  userStore.setTenantId(value);
+}
+
+/** 校验租户是否已选择 */
+function validate(): boolean {
+  if (!tenantEnabled.value) {
+    return true;
+  }
+  if (!tenantId.value) {
+    toast.warning("请选择租户");
+    return false;
+  }
+  return true;
+}
+
+/** 页面加载时获取租户列表 */
+onMounted(() => {
+  fetchTenantList();
+});
+
+defineExpose({ validate });
+</script>
+
+<style lang="scss" scoped>
+@import "../styles/auth.scss";
+</style>

+ 160 - 0
src/pages/auth/forget-password.vue

@@ -0,0 +1,160 @@
+<template>
+  <view class="auth-container">
+    <!-- 顶部 -->
+    <Header />
+
+    <!-- 表单区域 -->
+    <view class="form-container">
+      <TenantPicker ref="tenantPickerRef" />
+      <view class="input-item">
+        <wd-icon name="phone" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.mobile"
+          placeholder="请输入手机号"
+          clearable
+          clear-trigger="focus"
+          no-border
+          type="number"
+          :maxlength="11"
+        />
+      </view>
+      <CodeInput
+        v-model="formData.code"
+        :mobile="formData.mobile"
+        :scene="23"
+        :before-send="validateBeforeSend"
+      />
+      <view class="input-item">
+        <wd-icon name="lock-on" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.password"
+          placeholder="请输入新密码"
+          clearable
+          clear-trigger="focus"
+          show-password
+          no-border
+        />
+      </view>
+      <view class="input-item">
+        <wd-icon name="lock-on" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.confirmPassword"
+          placeholder="请确认新密码"
+          clearable
+          clear-trigger="focus"
+          show-password
+          no-border
+        />
+      </view>
+
+      <!-- 重置密码按钮 -->
+      <wd-button
+        block
+        :loading="loading"
+        type="primary"
+        @click="handleResetPassword"
+      >
+        重置密码
+      </wd-button>
+      <wd-button class="mt-2" block type="info" @click="goToLogin">
+        返回登录
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import { useToast } from "wot-design-uni";
+import { smsResetPassword } from "@/api/login";
+import { LOGIN_PAGE } from "@/router/config";
+import { isMobile } from "@/utils/validator";
+import CodeInput from "./components/code-input.vue";
+import Header from "./components/header.vue";
+import TenantPicker from "./components/tenant-picker.vue";
+
+defineOptions({
+  name: "ForgetPasswordPage",
+});
+
+definePage({
+  style: {
+    navigationStyle: "custom",
+  },
+  excludeLoginPath: true,
+});
+
+const toast = useToast();
+const loading = ref(false); // 加载状态
+const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
+
+const formData = reactive({
+  mobile: "",
+  code: "",
+  password: "",
+  confirmPassword: "",
+}); // 表单数据
+
+/** 发送验证码前的校验 */
+function validateBeforeSend(): boolean {
+  return tenantPickerRef.value?.validate() ?? false;
+}
+
+/** 重置密码处理 */
+async function handleResetPassword() {
+  // 校验租户
+  if (!tenantPickerRef.value?.validate()) {
+    return;
+  }
+  if (!formData.mobile) {
+    toast.warning("请输入手机号");
+    return;
+  }
+  if (!isMobile(formData.mobile)) {
+    toast.warning("请输入正确的手机号");
+    return;
+  }
+  if (!formData.code) {
+    toast.warning("请输入验证码");
+    return;
+  }
+  if (!formData.password) {
+    toast.warning("请输入新密码");
+    return;
+  }
+  if (!formData.confirmPassword) {
+    toast.warning("请确认新密码");
+    return;
+  }
+  if (formData.password !== formData.confirmPassword) {
+    toast.warning("两次输入的密码不一致");
+    return;
+  }
+
+  loading.value = true;
+  try {
+    // 调用重置密码接口
+    await smsResetPassword({
+      mobile: formData.mobile,
+      code: formData.code,
+      password: formData.password,
+    });
+    toast.success("密码重置成功");
+    // 跳转到登录页
+    setTimeout(() => {
+      goToLogin();
+    }, 500);
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 跳转到登录页面 */
+function goToLogin() {
+  uni.navigateTo({ url: LOGIN_PAGE });
+}
+</script>
+
+<style lang="scss" scoped>
+@import "./styles/auth.scss";
+</style>

+ 185 - 0
src/pages/auth/login.vue

@@ -0,0 +1,185 @@
+<template>
+  <view class="auth-container">
+    <!-- 顶部 -->
+    <Header />
+
+    <!-- 表单区域 -->
+    <view class="form-container">
+      <TenantPicker ref="tenantPickerRef" />
+      <view class="input-item">
+        <wd-icon name="user" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.username"
+          placeholder="请输入用户名"
+          clearable
+          clear-trigger="focus"
+          no-border
+        />
+      </view>
+      <view class="input-item">
+        <wd-icon name="lock-on" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.password"
+          placeholder="请输入密码"
+          clearable
+          clear-trigger="focus"
+          show-password
+          no-border
+        />
+      </view>
+
+      <!-- 登录按钮 -->
+      <view class="mb-2 mt-2 flex justify-between">
+        <text class="text-28rpx text-[#1890ff]" @click="goToSmsLogin">
+          验证码登录
+        </text>
+        <text class="text-28rpx text-[#1890ff]" @click="goToForgetPassword">
+          忘记密码?
+        </text>
+      </view>
+      <wd-button block :loading="loading" type="primary" @click="handleLogin">
+        登录
+      </wd-button>
+
+      <!-- 第三方登录 -->
+      <view class="mt-100rpx">
+        <view class="divider mb-40rpx flex items-center justify-center">
+          <view class="h-1rpx flex-1 bg-[#e5e5e5]" />
+          <text class="px-24rpx text-26rpx text-[#999]">其他登录方式</text>
+          <view class="h-1rpx flex-1 bg-[#e5e5e5]" />
+        </view>
+        <!-- TODO @芋艿:图标换下! -->
+        <view class="icons flex justify-center gap-60rpx">
+          <view class="icon-item" @click="handleWechatLogin">
+            <wd-icon name="chat" size="24px" color="#07c160" />
+          </view>
+          <view class="icon-item" @click="handleDingTalkLogin">
+            <wd-icon name="computer" size="24px" color="#3370ff" />
+          </view>
+        </view>
+      </view>
+      <!-- 创建账号 -->
+      <view class="mt-40rpx flex items-center justify-center">
+        <text class="text-28rpx text-[#666]">还没有账号?</text>
+        <text class="text-28rpx text-[#1890ff]" @click="goToRegister">
+          创建账号
+        </text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import { useToast } from "wot-design-uni";
+import {
+  CODE_LOGIN_PAGE,
+  FORGET_PASSWORD_PAGE,
+  REGISTER_PAGE,
+} from "@/router/config";
+import { useTokenStore } from "@/store/token";
+import { ensureDecodeURIComponent, redirectAfterLogin } from "@/utils";
+import Header from "./components/header.vue";
+import TenantPicker from "./components/tenant-picker.vue";
+
+defineOptions({
+  name: "LoginPage",
+  style: {
+    navigationStyle: "custom",
+  },
+});
+
+definePage({
+  style: {
+    navigationStyle: "custom",
+  },
+});
+
+const toast = useToast();
+const loading = ref(false); // 加载状态
+const redirectUrl = ref<string>(); // 重定向地址
+const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
+
+const formData = reactive({
+  username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || "",
+  password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || "",
+}); // 表单数据
+
+/** 页面加载时处理重定向 */
+onLoad((options) => {
+  if (options?.redirect) {
+    redirectUrl.value = ensureDecodeURIComponent(options.redirect);
+  }
+});
+
+/** 登录处理 */
+async function handleLogin() {
+  if (!tenantPickerRef.value?.validate()) {
+    return;
+  }
+  if (!formData.username) {
+    toast.warning("请输入用户名");
+    return;
+  }
+  if (!formData.password) {
+    toast.warning("请输入密码");
+    return;
+  }
+
+  loading.value = true;
+  try {
+    // 调用登录接口
+    const tokenStore = useTokenStore();
+    await tokenStore.login({
+      type: "username",
+      ...formData,
+    });
+    // 处理跳转
+    redirectAfterLogin(redirectUrl.value);
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 跳转到注册页面 */
+function goToRegister() {
+  uni.navigateTo({ url: REGISTER_PAGE });
+}
+
+/** 跳转到验证码登录 */
+function goToSmsLogin() {
+  uni.navigateTo({ url: CODE_LOGIN_PAGE });
+}
+
+/** 跳转到忘记密码 */
+function goToForgetPassword() {
+  uni.navigateTo({ url: FORGET_PASSWORD_PAGE });
+}
+
+/** 微信登录 */
+// TODO @芋艿:后续开发
+function handleWechatLogin() {
+  toast.info("微信登录功能开发中");
+}
+
+/** 钉钉登录 */
+// TODO @芋艿:后续开发
+function handleDingTalkLogin() {
+  toast.info("钉钉登录功能开发中");
+}
+</script>
+
+<style lang="scss" scoped>
+@import "./styles/auth.scss";
+
+// 第三方登录图标
+.icon-item {
+  width: 40rpx;
+  height: 40rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: #f5f7fa;
+}
+</style>

+ 179 - 0
src/pages/auth/register.vue

@@ -0,0 +1,179 @@
+<template>
+  <view class="auth-container">
+    <!-- 顶部 -->
+    <Header />
+
+    <!-- 表单区域 -->
+    <view class="form-container">
+      <TenantPicker ref="tenantPickerRef" />
+      <view class="input-item">
+        <wd-icon name="user" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.username"
+          placeholder="请输入用户名"
+          clearable
+          clear-trigger="focus"
+          no-border
+        />
+      </view>
+      <view class="input-item">
+        <wd-icon name="person" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.nickname"
+          placeholder="请输入昵称"
+          clearable
+          clear-trigger="focus"
+          no-border
+        />
+      </view>
+      <view class="input-item">
+        <wd-icon name="lock-on" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.password"
+          placeholder="请输入密码"
+          clearable
+          clear-trigger="focus"
+          show-password
+          no-border
+        />
+      </view>
+      <view class="input-item">
+        <wd-icon name="lock-on" size="20px" color="#1890ff" />
+        <wd-input
+          v-model="formData.confirmPassword"
+          placeholder="请确认密码"
+          clearable
+          clear-trigger="focus"
+          show-password
+          no-border
+        />
+      </view>
+
+      <!-- 用户协议 -->
+      <view class="mb-24rpx flex items-center">
+        <wd-checkbox v-model="agreePolicy" shape="square" />
+        <text class="text-24rpx text-[#666]">我已阅读并同意</text>
+        <text class="text-24rpx text-[#1890ff]" @click="goToUserAgreement">
+          《用户协议》
+        </text>
+        <text class="text-24rpx text-[#666]">与</text>
+        <text class="text-24rpx text-[#1890ff]" @click="goToPrivacyPolicy">
+          《隐私政策》
+        </text>
+      </view>
+
+      <!-- 注册按钮 -->
+      <wd-button
+        block
+        :loading="loading"
+        type="primary"
+        @click="handleRegister"
+      >
+        注册
+      </wd-button>
+
+      <!-- 已有账号 -->
+      <view class="mt-40rpx flex items-center justify-center">
+        <text class="text-28rpx text-[#666]">已有账号?</text>
+        <text class="text-28rpx text-[#1890ff]" @click="goToLogin">去登录</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import { useToast } from "wot-design-uni";
+import { LOGIN_PAGE } from "@/router/config";
+import { useTokenStore } from "@/store/token";
+import { redirectAfterLogin } from "@/utils";
+import Header from "./components/header.vue";
+import TenantPicker from "./components/tenant-picker.vue";
+
+defineOptions({
+  name: "RegisterPage",
+});
+
+definePage({
+  style: {
+    navigationStyle: "custom",
+  },
+});
+
+const toast = useToast();
+const loading = ref(false); // 加载状态
+const agreePolicy = ref(false); // 用户协议勾选
+const tenantPickerRef = ref<InstanceType<typeof TenantPicker>>(); // 租户选择器引用
+
+const formData = reactive({
+  username: "",
+  nickname: "",
+  password: "",
+  confirmPassword: "",
+}); // 表单数据
+
+/** 注册处理 */
+async function handleRegister() {
+  if (!tenantPickerRef.value?.validate()) {
+    return;
+  }
+  if (!agreePolicy.value) {
+    toast.warning("请阅读并同意《用户协议》与《隐私政策》");
+    return;
+  }
+  if (!formData.username) {
+    toast.warning("请输入用户名");
+    return;
+  }
+  if (!formData.nickname) {
+    toast.warning("请输入昵称");
+    return;
+  }
+  if (!formData.password) {
+    toast.warning("请输入密码");
+    return;
+  }
+  if (!formData.confirmPassword) {
+    toast.warning("请确认密码");
+    return;
+  }
+  if (formData.password !== formData.confirmPassword) {
+    toast.warning("两次输入的密码不一致");
+    return;
+  }
+
+  loading.value = true;
+  try {
+    // 调用注册接口
+    const tokenStore = useTokenStore();
+    await tokenStore.login({
+      type: "register",
+      ...formData,
+    });
+    toast.success("注册成功");
+    // 处理跳转
+    redirectAfterLogin();
+  } finally {
+    loading.value = false;
+  }
+}
+
+/** 跳转到登录页面 */
+function goToLogin() {
+  uni.navigateTo({ url: LOGIN_PAGE });
+}
+
+/** 跳转到用户协议 */
+function goToUserAgreement() {
+  uni.navigateTo({ url: "/pages/user/settings/agreement/index" });
+}
+
+/** 跳转到隐私政策 */
+function goToPrivacyPolicy() {
+  uni.navigateTo({ url: "/pages/user/settings/privacy/index" });
+}
+</script>
+
+<style lang="scss" scoped>
+@import "./styles/auth.scss";
+</style>

+ 63 - 0
src/pages/auth/styles/auth.scss

@@ -0,0 +1,63 @@
+/** 认证页面公共样式 */
+
+// 页面容器
+.auth-container {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  background: linear-gradient(180deg, #e8f4ff 0%, #fff 50%);
+  box-sizing: border-box;
+}
+
+// 表单容器
+.form-container {
+  flex: 1;
+  border-radius: 24rpx 24rpx 0 0;
+  background: #fff;
+  padding: 40rpx;
+}
+
+// 输入项
+.input-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  background: #f5f7fa;
+  border-radius: 12rpx;
+  margin-bottom: 24rpx;
+
+  :deep(.wd-input) {
+    flex: 1;
+    margin-left: 16rpx;
+    background: transparent;
+  }
+
+  :deep(.wd-picker) {
+    flex: 1;
+    margin-left: 16rpx;
+  }
+
+  :deep(.wd-picker__field) {
+    background: transparent;
+    padding: 0;
+  }
+
+  // 移除 picker 的边框,保持与 input 一致
+  :deep(.wd-picker__cell) {
+    background: transparent !important;
+    padding: 0 !important;
+  }
+
+  :deep(.wd-cell) {
+    background: transparent !important;
+    padding: 0 !important;
+
+    &::after {
+      display: none !important;
+    }
+  }
+
+  :deep(.wd-cell__wrapper) {
+    padding: 0 !important;
+  }
+}

+ 148 - 0
src/pages/bpm/components/copy-list.vue

@@ -0,0 +1,148 @@
+<template>
+  <view class="bpm-list">
+    <view
+      v-for="item in list"
+      :key="item.id"
+      class="bpm-card"
+      @click="handleDetail(item)"
+    >
+      <view class="bpm-card-content">
+        <view class="bpm-card-header">
+          <view class="bpm-card-title">
+            {{ item.processInstanceName }}
+          </view>
+          <wd-tag type="primary" plain>
+            查看详情
+          </wd-tag>
+        </view>
+        <view v-if="item.summary?.length" class="bpm-summary">
+          <view v-for="(s, idx) in item.summary" :key="idx" class="bpm-summary-item">
+            <text class="text-[#999]">{{ s.key }}:</text>
+            <text>{{ s.value }}</text>
+          </view>
+        </view>
+        <view class="bpm-card-info">
+          <view class="bpm-user">
+            <view class="bpm-avatar">
+              {{ item.startUser.nickname?.[0] || '?' }}
+            </view>
+            <text class="bpm-nickname">{{ item.startUser.nickname }}</text>
+          </view>
+          <text class="bpm-time">{{ formatDateTime(item.createTime) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="loadMoreState !== 'loading' && list.length === 0" class="bpm-empty">
+      <wd-status-tip image="content" tip="暂无抄送任务" />
+    </view>
+    <wd-loadmore
+      v-if="list.length > 0"
+      :state="loadMoreState"
+      @reload="loadMore"
+    />
+
+    <!-- 搜索弹窗 -->
+    <CopySearchForm
+      v-model="searchPopupVisible"
+      :search-params="queryParams"
+      @search="handleSearch"
+      @reset="handleReset"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { CopySearchFormData } from './copy-search-form.vue'
+import type { ProcessInstanceCopy } from '@/api/bpm/processInstance'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref, watch } from 'vue'
+import { getProcessInstanceCopyPage } from '@/api/bpm/processInstance'
+import { formatDateRange, formatDateTime } from '@/utils/date'
+import CopySearchForm from './copy-search-form.vue'
+import './index.scss'
+
+const props = defineProps<{
+  searchVisible?: boolean
+}>()
+
+const emit = defineEmits<{
+  'update:searchVisible': [value: boolean]
+}>()
+
+const total = ref(0)
+const list = ref<ProcessInstanceCopy[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const searchPopupVisible = ref(false)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  processInstanceName: undefined as string | undefined,
+  createTime: undefined as number[] | undefined,
+})
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const params = {
+      ...queryParams,
+      createTime: formatDateRange(queryParams.createTime as any),
+    }
+    const data = await getProcessInstanceCopyPage(params)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 搜索 */
+function handleSearch(data?: CopySearchFormData) {
+  queryParams.processInstanceName = data?.processInstanceName
+  queryParams.createTime = data?.createTime
+  queryParams.pageNo = 1
+  list.value = []
+  getList()
+}
+
+/** 重置 */
+function handleReset() {
+  handleSearch()
+}
+
+/** 查看详情 */
+function handleDetail(item: ProcessInstanceCopy) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstanceId}` })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+watch(() => props.searchVisible, (val) => {
+  searchPopupVisible.value = val ?? false
+})
+
+watch(searchPopupVisible, (val) => {
+  emit('update:searchVisible', val)
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 164 - 0
src/pages/bpm/components/copy-search-form.vue

@@ -0,0 +1,164 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索流程
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程名称
+        </view>
+        <wd-input
+          v-model="formData.processInstanceName"
+          placeholder="请输入流程名称"
+          clearable
+        />
+      </view>
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          抄送时间
+        </view>
+        <view class="flex items-center gap-16rpx">
+          <view class="flex-1" @click="showStartPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          <text class="text-28rpx text-[#999]">至</text>
+          <view class="flex-1" @click="showEndPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showStartPicker"
+          v-model="tempCreateTime[0]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showStartPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleStartCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleStartConfirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showEndPicker"
+          v-model="tempCreateTime[1]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showEndPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleEndCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleEndConfirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue'
+import { formatDate } from '@/utils/date'
+
+/** 搜索表单数据 */
+export interface CopySearchFormData {
+  processInstanceName?: string
+  createTime?: [number | undefined, number | undefined]
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<CopySearchFormData>
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: CopySearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<CopySearchFormData>({
+  processInstanceName: undefined,
+  createTime: [undefined, undefined],
+})
+
+// 时间选择器状态
+const showStartPicker = ref(false)
+const showEndPicker = ref(false)
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 开始时间确认 */
+function handleStartConfirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  showStartPicker.value = false
+}
+
+/** 开始时间取消 */
+function handleStartCancel() {
+  showStartPicker.value = false
+}
+
+/** 结束时间确认 */
+function handleEndConfirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  showEndPicker.value = false
+}
+
+/** 结束时间取消 */
+function handleEndCancel() {
+  showEndPicker.value = false
+}
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.processInstanceName = props.searchParams.processInstanceName
+    formData.createTime = props.searchParams.createTime ?? [undefined, undefined]
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.processInstanceName = undefined
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 147 - 0
src/pages/bpm/components/done-list.vue

@@ -0,0 +1,147 @@
+<template>
+  <view class="bpm-list">
+    <view
+      v-for="item in list"
+      :key="item.id"
+      class="bpm-card"
+      @click="handleDetail(item)"
+    >
+      <view class="bpm-card-content">
+        <view class="bpm-card-header">
+          <view class="bpm-card-title">
+            {{ item.processInstance?.name }}
+          </view>
+          <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="item.status" />
+        </view>
+        <view v-if="item.processInstance?.summary?.length" class="bpm-summary">
+          <view v-for="(s, idx) in item.processInstance.summary" :key="idx" class="bpm-summary-item">
+            <text class="text-[#999]">{{ s.key }}:</text>
+            <text>{{ s.value }}</text>
+          </view>
+        </view>
+        <view class="bpm-card-info">
+          <view class="bpm-user">
+            <view class="bpm-avatar">
+              {{ item.processInstance?.startUser?.nickname?.[0] || '?' }}
+            </view>
+            <text class="bpm-nickname">{{ item.processInstance?.startUser?.nickname }}</text>
+          </view>
+          <text class="bpm-time">{{ formatDateTime(item.createTime) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="loadMoreState !== 'loading' && list.length === 0" class="bpm-empty">
+      <wd-status-tip image="content" tip="暂无已办任务" />
+    </view>
+    <wd-loadmore
+      v-if="list.length > 0"
+      :state="loadMoreState"
+      @reload="loadMore"
+    />
+
+    <!-- 搜索弹窗 -->
+    <DoneSearchForm
+      v-model="searchPopupVisible"
+      :search-params="queryParams"
+      @search="handleSearch"
+      @reset="handleReset"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DoneSearchFormData } from './done-search-form.vue'
+import type { Task } from '@/api/bpm/task'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref, watch } from 'vue'
+import { getTaskDonePage } from '@/api/bpm/task'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateRange, formatDateTime } from '@/utils/date'
+import DoneSearchForm from './done-search-form.vue'
+import './index.scss'
+
+const props = defineProps<{
+  searchVisible?: boolean
+}>()
+
+const emit = defineEmits<{
+  'update:searchVisible': [value: boolean]
+}>()
+
+const total = ref(0)
+const list = ref<Task[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const searchPopupVisible = ref(false)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  createTime: undefined as number[] | undefined,
+})
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const params = {
+      ...queryParams,
+      createTime: formatDateRange(queryParams.createTime as any),
+    }
+    const data = await getTaskDonePage(params)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 搜索 */
+function handleSearch(data?: DoneSearchFormData) {
+  queryParams.name = data?.name
+  queryParams.createTime = data?.createTime
+  queryParams.pageNo = 1
+  list.value = []
+  getList()
+}
+
+/** 重置 */
+function handleReset() {
+  handleSearch()
+}
+
+/** 查看详情 */
+function handleDetail(item: Task) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstance.id}` })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+watch(() => props.searchVisible, (val) => {
+  searchPopupVisible.value = val ?? false
+})
+
+watch(searchPopupVisible, (val) => {
+  emit('update:searchVisible', val)
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 246 - 0
src/pages/bpm/components/done-search-form.vue

@@ -0,0 +1,246 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索任务
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          任务名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入任务名称"
+          clearable
+        />
+      </view>
+      <view v-if="processDefinitionList.length > 0" class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          所属流程
+        </view>
+        <wd-picker
+          v-model="formData.processDefinitionKey"
+          :columns="processDefinitionList"
+          label-key="name"
+          value-key="key"
+          label=""
+        />
+      </view>
+      <view v-if="categoryList.length > 0" class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程分类
+        </view>
+        <wd-picker
+          v-model="formData.category"
+          :columns="categoryList"
+          label-key="name"
+          value-key="code"
+          label=""
+        />
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          审批状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_TASK_STATUS)" :key="dict.value" :value="dict.value">
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <!-- DONE @AI:时时间范围,参考下 /Users/yunai/Java/yudao-ui-admin-uniapp-next/src/pages/message/components/search-form.vue -->
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          发起时间
+        </view>
+        <view class="flex items-center gap-16rpx">
+          <view class="flex-1" @click="showStartPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          <text class="text-28rpx text-[#999]">至</text>
+          <view class="flex-1" @click="showEndPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showStartPicker"
+          v-model="tempCreateTime[0]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showStartPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleStartCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleStartConfirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showEndPicker"
+          v-model="tempCreateTime[1]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showEndPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleEndCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleEndConfirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import type { Category } from '@/api/bpm/category'
+import type { ProcessDefinition } from '@/api/bpm/definition'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { getCategorySimpleList } from '@/api/bpm/category'
+import { getProcessDefinitionList } from '@/api/bpm/definition'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate } from '@/utils/date'
+
+/** 搜索表单数据 */
+export interface DoneSearchFormData {
+  name?: string
+  processDefinitionKey?: string
+  category?: string
+  status: number // -1 表示全部
+  createTime?: [number | undefined, number | undefined]
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<DoneSearchFormData>
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: DoneSearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const categoryList = ref<Category[]>([])
+const processDefinitionList = ref<ProcessDefinition[]>([])
+const formData = reactive<DoneSearchFormData>({
+  name: undefined,
+  processDefinitionKey: undefined,
+  category: undefined,
+  status: -1,
+  createTime: [undefined, undefined],
+})
+
+// 时间选择器状态
+const showStartPicker = ref(false)
+const showEndPicker = ref(false)
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 开始时间确认 */
+function handleStartConfirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  showStartPicker.value = false
+}
+
+/** 开始时间取消 */
+function handleStartCancel() {
+  showStartPicker.value = false
+}
+
+/** 结束时间确认 */
+function handleEndConfirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  showEndPicker.value = false
+}
+
+/** 结束时间取消 */
+function handleEndCancel() {
+  showEndPicker.value = false
+}
+
+/** 获取流程分类列表 */
+async function getCategoryList() {
+  try {
+    categoryList.value = await getCategorySimpleList()
+  } catch (error) {
+    console.error('获取流程分类失败:', error)
+  }
+}
+
+/** 获取流程定义列表 */
+async function getProcessDefinitions() {
+  try {
+    processDefinitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
+  } catch (error) {
+    console.error('获取流程定义失败:', error)
+  }
+}
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.name = props.searchParams.name
+    formData.processDefinitionKey = props.searchParams.processDefinitionKey
+    formData.category = props.searchParams.category
+    formData.status = props.searchParams.status ?? -1
+    formData.createTime = props.searchParams.createTime ?? [undefined, undefined]
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.processDefinitionKey = undefined
+  formData.category = undefined
+  formData.status = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+
+/** 初始化 */
+onMounted(() => {
+  getCategoryList()
+  getProcessDefinitions()
+})
+</script>

+ 139 - 0
src/pages/bpm/components/index.scss

@@ -0,0 +1,139 @@
+/**
+ * BPM 列表组件共享样式
+ */
+
+// 列表容器
+.bpm-list {
+  padding: 24rpx;
+}
+
+// 搜索框容器
+.bpm-search {
+  margin-bottom: 24rpx;
+}
+
+// 列表卡片
+.bpm-card {
+  margin-bottom: 24rpx;
+  overflow: hidden;
+  border-radius: 12rpx;
+  background-color: #fff;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
+}
+
+// 卡片内容区
+.bpm-card-content {
+  padding: 24rpx;
+}
+
+// 卡片标题行
+.bpm-card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+
+// 卡片标题
+.bpm-card-title {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+// 卡片信息行
+.bpm-card-info {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 28rpx;
+  color: #666;
+}
+
+// 摘要区域
+.bpm-summary {
+  margin-bottom: 16rpx;
+  padding: 16rpx;
+  border-radius: 8rpx;
+  background-color: #f9f9f9;
+  font-size: 26rpx;
+}
+
+.bpm-summary-item {
+  margin-bottom: 8rpx;
+  line-height: 1.5;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+// 用户信息
+.bpm-user {
+  display: flex;
+  align-items: center;
+}
+
+// 用户头像
+.bpm-avatar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 48rpx;
+  height: 48rpx;
+  margin-right: 12rpx;
+  border-radius: 50%;
+  background-color: #1890ff;
+  font-size: 24rpx;
+  color: #fff;
+}
+
+// 用户昵称
+.bpm-nickname {
+  max-width: 200rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+// 时间文本
+.bpm-time {
+  color: #999;
+}
+
+// 时间文本 - 警告色
+.bpm-time--warning {
+  color: #ff9800;
+}
+
+// 操作按钮区
+.bpm-actions {
+  display: flex;
+  border-top: 1rpx solid #f0f0f0;
+}
+
+// 操作按钮
+.bpm-action-btn {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  padding: 24rpx 0;
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1890ff;
+
+  &:not(:last-child) {
+    border-right: 1rpx solid #f0f0f0;
+  }
+}
+
+// 空状态
+.bpm-empty {
+  padding: 100rpx 0;
+  text-align: center;
+}

+ 172 - 0
src/pages/bpm/components/my-list.vue

@@ -0,0 +1,172 @@
+<template>
+  <view class="bpm-list">
+    <view
+      v-for="item in list"
+      :key="item.id"
+      class="bpm-card"
+      @click="handleDetail(item)"
+    >
+      <view class="bpm-card-content">
+        <view class="bpm-card-header">
+          <view class="bpm-card-title">
+            {{ item.name }}
+          </view>
+          <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="item.status" />
+        </view>
+        <view v-if="item.summary?.length" class="bpm-summary">
+          <view v-for="(s, idx) in item.summary" :key="idx" class="bpm-summary-item">
+            <text class="text-[#999]">{{ s.key }}:</text>
+            <text>{{ s.value }}</text>
+          </view>
+        </view>
+        <view class="bpm-card-info">
+          <view class="bpm-user">
+            <view class="bpm-avatar">
+              {{ userNickname?.[0] }}
+            </view>
+            <text class="bpm-nickname">{{ userNickname }}</text>
+          </view>
+          <text class="bpm-time">{{ formatDateTime(item.startTime) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-if="loadMoreState !== 'loading' && list.length === 0" class="bpm-empty">
+      <wd-status-tip image="content" tip="暂无发起的流程" />
+    </view>
+    <!-- 加载更多 -->
+    <wd-loadmore
+      v-if="list.length > 0"
+      :state="loadMoreState"
+      @reload="loadMore"
+    />
+
+    <!-- 搜索弹窗 -->
+    <MySearchForm
+      v-model="searchPopupVisible"
+      :search-params="queryParams"
+      @search="handleSearch"
+      @reset="handleReset"
+    />
+
+    <!-- 新增按钮 -->
+    <view
+      class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+      @click="handleCreate"
+    >
+      <wd-icon name="add" size="24px" color="#fff" />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { MySearchFormData } from './my-search-form.vue'
+import type { ProcessInstance } from '@/api/bpm/processInstance'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { getProcessInstanceMyPage } from '@/api/bpm/processInstance'
+import { useUserStore } from '@/store'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateRange, formatDateTime } from '@/utils/date'
+import MySearchForm from './my-search-form.vue'
+import './index.scss'
+
+const props = defineProps<{
+  searchVisible?: boolean
+}>()
+
+const emit = defineEmits<{
+  'update:searchVisible': [value: boolean]
+}>()
+
+const userStore = useUserStore()
+const userNickname = computed(() => userStore.userInfo?.nickname || '')
+
+const total = ref(0)
+const list = ref<ProcessInstance[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const searchPopupVisible = ref(false)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  createTime: undefined as number[] | undefined,
+  status: -1,
+  categoryId: undefined as string | undefined,
+})
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const params = {
+      ...queryParams,
+      status: queryParams.status === -1 ? undefined : queryParams.status,
+      createTime: formatDateRange(queryParams.createTime as any),
+    }
+    const data = await getProcessInstanceMyPage(params)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 搜索 */
+function handleSearch(data?: MySearchFormData) {
+  queryParams.name = data?.name
+  queryParams.createTime = data?.createTime as any
+  queryParams.status = data?.status ?? -1
+  queryParams.categoryId = data?.categoryId
+  queryParams.pageNo = 1
+  list.value = []
+  getList()
+}
+
+/** 重置 */
+function handleReset() {
+  handleSearch()
+}
+
+/** 查看详情 */
+function handleDetail(item: ProcessInstance) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.id}` })
+}
+
+/** 发起流程 */
+function handleCreate() {
+  uni.navigateTo({ url: '/pages-bpm/processInstance/create/index' })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+watch(() => props.searchVisible, (val) => {
+  searchPopupVisible.value = val ?? false
+})
+
+watch(searchPopupVisible, (val) => {
+  emit('update:searchVisible', val)
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 246 - 0
src/pages/bpm/components/my-search-form.vue

@@ -0,0 +1,246 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索流程
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入流程名称"
+          clearable
+        />
+      </view>
+      <view v-if="processDefinitionList.length > 0" class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          所属流程
+        </view>
+        <wd-picker
+          v-model="formData.processDefinitionId"
+          :columns="processDefinitionList"
+          label-key="name"
+          value-key="id"
+          label=""
+        />
+      </view>
+      <!-- DONE @AI:时时间范围,参考下 /Users/yunai/Java/yudao-ui-admin-uniapp-next/src/pages/message/components/search-form.vue -->
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          发起时间
+        </view>
+        <view class="flex items-center gap-16rpx">
+          <view class="flex-1" @click="showStartPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          <text class="text-28rpx text-[#999]">至</text>
+          <view class="flex-1" @click="showEndPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showStartPicker"
+          v-model="tempCreateTime[0]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showStartPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleStartCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleStartConfirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showEndPicker"
+          v-model="tempCreateTime[1]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showEndPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleEndCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleEndConfirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" :key="dict.value" :value="dict.value">
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view v-if="categoryList.length > 0" class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程分类
+        </view>
+        <wd-picker
+          v-model="formData.categoryId"
+          :columns="categoryList"
+          label-key="name"
+          value-key="code"
+          label=""
+        />
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import type { Category } from '@/api/bpm/category'
+import type { ProcessDefinition } from '@/api/bpm/definition'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { getCategorySimpleList } from '@/api/bpm/category'
+import { getProcessDefinitionList } from '@/api/bpm/definition'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate } from '@/utils/date'
+
+/** 搜索表单数据 */
+export interface MySearchFormData {
+  name?: string
+  processDefinitionId?: string
+  createTime?: [number | undefined, number | undefined]
+  status: number // -1 表示全部
+  categoryId?: string
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<MySearchFormData>
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: MySearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const categoryList = ref<Category[]>([])
+const processDefinitionList = ref<ProcessDefinition[]>([])
+const formData = reactive<MySearchFormData>({
+  name: undefined,
+  processDefinitionId: undefined,
+  createTime: [undefined, undefined],
+  status: -1,
+  categoryId: undefined,
+})
+
+// 时间选择器状态
+const showStartPicker = ref(false)
+const showEndPicker = ref(false)
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 开始时间确认 */
+function handleStartConfirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  showStartPicker.value = false
+}
+
+/** 开始时间取消 */
+function handleStartCancel() {
+  showStartPicker.value = false
+}
+
+/** 结束时间确认 */
+function handleEndConfirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  showEndPicker.value = false
+}
+
+/** 结束时间取消 */
+function handleEndCancel() {
+  showEndPicker.value = false
+}
+
+/** 获取流程分类列表 */
+async function getCategoryList() {
+  try {
+    categoryList.value = await getCategorySimpleList()
+  } catch (error) {
+    console.error('获取流程分类失败:', error)
+  }
+}
+
+/** 获取流程定义列表 */
+async function getProcessDefinitions() {
+  try {
+    processDefinitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
+  } catch (error) {
+    console.error('获取流程定义失败:', error)
+  }
+}
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.name = props.searchParams.name
+    formData.processDefinitionId = props.searchParams.processDefinitionId
+    formData.createTime = props.searchParams.createTime ?? [undefined, undefined]
+    formData.status = props.searchParams.status ?? -1
+    formData.categoryId = props.searchParams.categoryId
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.processDefinitionId = undefined
+  formData.createTime = [undefined, undefined]
+  formData.status = -1
+  formData.categoryId = undefined
+  visible.value = false
+  emit('reset')
+}
+
+/** 初始化 */
+onMounted(() => {
+  getCategoryList()
+  getProcessDefinitions()
+})
+</script>

+ 167 - 0
src/pages/bpm/components/todo-list.vue

@@ -0,0 +1,167 @@
+<template>
+  <view class="bpm-list">
+    <view
+      v-for="item in list"
+      :key="item.id"
+      class="bpm-card"
+      @click="handleDetail(item)"
+    >
+      <view class="bpm-card-content">
+        <view class="bpm-card-header">
+          <view class="bpm-card-title">
+            {{ item.processInstance?.name }}
+          </view>
+          <wd-tag type="primary" plain>
+            待审批
+          </wd-tag>
+        </view>
+        <view v-if="item.processInstance?.summary?.length" class="bpm-summary">
+          <view v-for="(s, idx) in item.processInstance.summary" :key="idx" class="bpm-summary-item">
+            <text class="text-[#999]">{{ s.key }}:</text>
+            <text>{{ s.value }}</text>
+          </view>
+        </view>
+        <view class="bpm-card-info">
+          <view class="bpm-user">
+            <view class="bpm-avatar">
+              {{ item.processInstance?.startUser?.nickname?.[0] || '?' }}
+            </view>
+            <text class="bpm-nickname">{{ item.processInstance?.startUser?.nickname }}</text>
+          </view>
+          <text class="bpm-time--warning">{{ formatPast(item.createTime) }}</text>
+        </view>
+      </view>
+      <view class="bpm-actions">
+        <view class="bpm-action-btn" @click.stop="handleReject(item)">
+          <text>拒绝</text>
+        </view>
+        <view class="bpm-action-btn" @click.stop="handleApprove(item)">
+          <text>同意</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 加载更多 -->
+    <view v-if="loadMoreState !== 'loading' && list.length === 0" class="bpm-empty">
+      <wd-status-tip image="content" tip="暂无待办任务" />
+    </view>
+    <wd-loadmore
+      v-if="list.length > 0"
+      :state="loadMoreState"
+      @reload="loadMore"
+    />
+
+    <!-- 搜索弹窗 -->
+    <TodoSearchForm
+      v-model="searchPopupVisible"
+      :search-params="queryParams"
+      @search="handleSearch"
+      @reset="handleReset"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { TodoSearchFormData } from './todo-search-form.vue'
+import type { Task } from '@/api/bpm/task'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref, watch } from 'vue'
+import { getTaskTodoPage } from '@/api/bpm/task'
+import { formatDateRange, formatPast } from '@/utils/date'
+import TodoSearchForm from './todo-search-form.vue'
+import './index.scss'
+
+const props = defineProps<{
+  searchVisible?: boolean
+}>()
+
+const emit = defineEmits<{
+  'update:searchVisible': [value: boolean]
+}>()
+
+const total = ref(0)
+const list = ref<Task[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const searchPopupVisible = ref(false)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  createTime: undefined as number[] | undefined,
+})
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const params = {
+      ...queryParams,
+      createTime: formatDateRange(queryParams.createTime as any),
+    }
+    const data = await getTaskTodoPage(params)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 搜索 */
+function handleSearch(data?: TodoSearchFormData) {
+  queryParams.name = data?.name
+  queryParams.createTime = data?.createTime
+  queryParams.pageNo = 1
+  list.value = []
+  getList()
+}
+
+/** 重置 */
+function handleReset() {
+  handleSearch()
+}
+
+/** 查看详情 */
+function handleDetail(item: Task) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstance.id}` })
+}
+
+/** 同意 */
+function handleApprove(item: Task) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?id=${item.id}&pass=true` })
+}
+
+/** 拒绝 */
+function handleReject(item: Task) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?id=${item.id}&pass=false` })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+watch(() => props.searchVisible, (val) => {
+  searchPopupVisible.value = val ?? false
+})
+
+watch(searchPopupVisible, (val) => {
+  emit('update:searchVisible', val)
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 246 - 0
src/pages/bpm/components/todo-search-form.vue

@@ -0,0 +1,246 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索任务
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          任务名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入任务名称"
+          clearable
+        />
+      </view>
+      <view v-if="processDefinitionList.length > 0" class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          所属流程
+        </view>
+        <wd-picker
+          v-model="formData.processDefinitionKey"
+          :columns="processDefinitionList"
+          label-key="name"
+          value-key="key"
+          label=""
+        />
+      </view>
+      <view v-if="categoryList.length > 0" class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程分类
+        </view>
+        <wd-picker
+          v-model="formData.category"
+          :columns="categoryList"
+          label-key="name"
+          value-key="code"
+          label=""
+        />
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          流程状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" :key="dict.value" :value="dict.value">
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <!-- DONE @AI:时时间范围,参考下 /Users/yunai/Java/yudao-ui-admin-uniapp-next/src/pages/message/components/search-form.vue -->
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          发起时间
+        </view>
+        <view class="flex items-center gap-16rpx">
+          <view class="flex-1" @click="showStartPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          <text class="text-28rpx text-[#999]">至</text>
+          <view class="flex-1" @click="showEndPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showStartPicker"
+          v-model="tempCreateTime[0]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showStartPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleStartCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleStartConfirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view
+          v-if="showEndPicker"
+          v-model="tempCreateTime[1]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showEndPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleEndCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleEndConfirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import type { Category } from '@/api/bpm/category'
+import type { ProcessDefinition } from '@/api/bpm/definition'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { getCategorySimpleList } from '@/api/bpm/category'
+import { getProcessDefinitionList } from '@/api/bpm/definition'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate } from '@/utils/date'
+
+/** 搜索表单数据 */
+export interface TodoSearchFormData {
+  name?: string
+  processDefinitionKey?: string
+  category?: string
+  status: number // -1 表示全部
+  createTime?: [number | undefined, number | undefined]
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<TodoSearchFormData>
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: TodoSearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const categoryList = ref<Category[]>([])
+const processDefinitionList = ref<ProcessDefinition[]>([])
+const formData = reactive<TodoSearchFormData>({
+  name: undefined,
+  processDefinitionKey: undefined,
+  category: undefined,
+  status: -1,
+  createTime: [undefined, undefined],
+})
+
+// 时间选择器状态
+const showStartPicker = ref(false)
+const showEndPicker = ref(false)
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 开始时间确认 */
+function handleStartConfirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  showStartPicker.value = false
+}
+
+/** 开始时间取消 */
+function handleStartCancel() {
+  showStartPicker.value = false
+}
+
+/** 结束时间确认 */
+function handleEndConfirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  showEndPicker.value = false
+}
+
+/** 结束时间取消 */
+function handleEndCancel() {
+  showEndPicker.value = false
+}
+
+/** 获取流程分类列表 */
+async function getCategoryList() {
+  try {
+    categoryList.value = await getCategorySimpleList()
+  } catch (error) {
+    console.error('获取流程分类失败:', error)
+  }
+}
+
+/** 获取流程定义列表 */
+async function getProcessDefinitions() {
+  try {
+    processDefinitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
+  } catch (error) {
+    console.error('获取流程定义失败:', error)
+  }
+}
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.name = props.searchParams.name
+    formData.processDefinitionKey = props.searchParams.processDefinitionKey
+    formData.category = props.searchParams.category
+    formData.status = props.searchParams.status ?? -1
+    formData.createTime = props.searchParams.createTime ?? [undefined, undefined]
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.processDefinitionKey = undefined
+  formData.category = undefined
+  formData.status = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+
+/** 初始化 */
+onMounted(() => {
+  getCategoryList()
+  getProcessDefinitions()
+})
+</script>

+ 85 - 0
src/pages/bpm/index.vue

@@ -0,0 +1,85 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="审批"
+      placeholder safe-area-inset-top fixed
+    >
+      <template #right>
+        <view class="flex items-center" @click="searchVisible = !searchVisible">
+          <wd-icon name="search" size="20px" />
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- Tabs 区域 -->
+    <view class="bg-white">
+      <wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
+        <wd-tab title="待办任务" />
+        <wd-tab title="已办任务" />
+        <wd-tab title="我的流程" />
+        <wd-tab title="抄送我的" />
+      </wd-tabs>
+    </view>
+    <!-- 列表内容 -->
+    <TodoList
+      v-show="tabType === 'todo'"
+      :search-visible="searchVisible && tabType === 'todo'"
+      @update:search-visible="searchVisible = $event"
+    />
+    <DoneList
+      v-show="tabType === 'done'"
+      :search-visible="searchVisible && tabType === 'done'"
+      @update:search-visible="searchVisible = $event"
+    />
+    <MyList
+      v-show="tabType === 'my'"
+      :search-visible="searchVisible && tabType === 'my'"
+      @update:search-visible="searchVisible = $event"
+    />
+    <CopyList
+      v-show="tabType === 'copy'"
+      :search-visible="searchVisible && tabType === 'copy'"
+      @update:search-visible="searchVisible = $event"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { getAndClearTabParams } from '@/utils/url'
+import CopyList from './components/copy-list.vue'
+import DoneList from './components/done-list.vue'
+import MyList from './components/my-list.vue'
+import TodoList from './components/todo-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['todo', 'done', 'my', 'copy']
+const tabIndex = ref(0)
+const tabType = computed<string>(() => tabTypes[tabIndex.value])
+const searchVisible = ref(false)
+
+/** Tab 切换 */
+function handleTabChange({ index }: { index: number }) {
+  tabIndex.value = index
+  searchVisible.value = false
+}
+
+/** 初始化:根据 tab 参数设置默认 tab */
+onShow(() => {
+  // 从 globalData 获取参数(switchTab 跳转时使用)
+  const tabParams = getAndClearTabParams()
+  if (tabParams?.tab) {
+    const index = tabTypes.indexOf(tabParams.tab)
+    if (index !== -1) {
+      tabIndex.value = index
+    }
+  }
+})
+</script>

+ 84 - 0
src/pages/contact/components/breadcrumb.vue

@@ -0,0 +1,84 @@
+<template>
+  <view v-if="breadcrumbList.length > 0" class="bg-white px-24rpx py-16rpx">
+    <scroll-view scroll-x class="whitespace-nowrap">
+      <view class="inline-flex items-center">
+        <view
+          class="flex items-center text-28rpx"
+          :class="breadcrumbList.length > 0 ? 'text-[#1890ff]' : 'text-[#333]'"
+          @click="handleClick(-1)"
+        >
+          <text>全部</text>
+        </view>
+        <template v-for="(item, index) in breadcrumbList" :key="item.id">
+          <wd-icon name="arrow-right" size="12px" color="#999" custom-class="mx-8rpx" />
+          <view
+            class="flex items-center text-28rpx"
+            :class="index < breadcrumbList.length - 1 ? 'text-[#1890ff]' : 'text-[#333]'"
+            @click="handleClick(index)"
+          >
+            <text>{{ item.name }}</text>
+          </view>
+        </template>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+
+interface BreadcrumbItem {
+  id: number
+  name: string
+}
+
+const props = defineProps<{
+  modelValue: number
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: number]
+}>()
+
+const breadcrumbList = ref<BreadcrumbItem[]>([])
+
+/** 监听外部值变化 */
+watch(() => props.modelValue, (val) => {
+  if (val === 0) {
+    breadcrumbList.value = []
+  }
+})
+
+/** 点击面包屑 */
+function handleClick(index: number) {
+  if (index === -1) {
+    // 点击"全部"
+    breadcrumbList.value = []
+    emit('update:modelValue', 0)
+  } else if (index < breadcrumbList.value.length - 1) {
+    // 点击中间层级
+    const item = breadcrumbList.value[index]
+    breadcrumbList.value = breadcrumbList.value.slice(0, index + 1)
+    emit('update:modelValue', item.id)
+  }
+}
+
+/** 进入子层级 */
+function enter(item: BreadcrumbItem) {
+  breadcrumbList.value.push(item)
+  emit('update:modelValue', item.id)
+}
+
+/** 返回上一层级 */
+function back(): boolean {
+  if (breadcrumbList.value.length === 0) {
+    return false
+  }
+  breadcrumbList.value.pop()
+  const lastItem = breadcrumbList.value[breadcrumbList.value.length - 1]
+  emit('update:modelValue', lastItem?.id ?? 0)
+  return true
+}
+
+defineExpose({ enter, back })
+</script>

+ 169 - 0
src/pages/contact/index.vue

@@ -0,0 +1,169 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="通讯录"
+      placeholder safe-area-inset-top fixed
+    />
+
+    <!-- 面包屑导航 -->
+    <Breadcrumb ref="breadcrumbRef" v-model="currentDeptId" />
+
+    <!-- 通讯录列表 -->
+    <view class="p-24rpx">
+      <!-- 部门列表 -->
+      <view
+        v-for="item in currentDeptList"
+        :key="`dept-${item.id}`"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleEnterDept(item)"
+      >
+        <view class="flex items-center p-24rpx">
+          <view class="mr-16rpx h-80rpx w-80rpx flex items-center justify-center rounded-8rpx bg-[#1890ff]">
+            <wd-icon name="folder" size="32px" color="#fff" />
+          </view>
+          <view class="flex-1">
+            <view class="text-28rpx text-[#333] font-medium">
+              {{ item.name }}
+            </view>
+            <view v-if="item.children && item.children.length > 0" class="mt-8rpx text-24rpx text-[#999]">
+              {{ item.children.length }} 个子部门
+            </view>
+          </view>
+          <wd-icon name="arrow-right" size="16px" color="#999" />
+        </view>
+      </view>
+
+      <!-- 用户列表 -->
+      <view v-if="currentDeptList.length > 0 && currentUserList.length > 0" class="my-24rpx flex items-center">
+        <view class="h-1rpx flex-1 bg-[#ddd]" />
+        <text class="mx-16rpx text-24rpx text-[#999]">部门成员</text>
+        <view class="h-1rpx flex-1 bg-[#ddd]" />
+      </view>
+      <view
+        v-for="item in currentUserList"
+        :key="`user-${item.id}`"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleUserClick(item)"
+      >
+        <view class="flex items-center p-24rpx">
+          <view
+            v-if="item.avatar"
+            class="mr-16rpx h-80rpx w-80rpx overflow-hidden rounded-full"
+          >
+            <image :src="item.avatar" class="h-full w-full" mode="aspectFill" />
+          </view>
+          <view
+            v-else
+            class="mr-16rpx h-80rpx w-80rpx flex items-center justify-center rounded-full bg-[#1890ff] text-32rpx text-white"
+          >
+            {{ item.nickname?.charAt(0) || item.username?.charAt(0) }}
+          </view>
+          <view class="flex-1">
+            <view class="text-28rpx text-[#333] font-medium">
+              {{ item.nickname }}
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-if="!loading && currentDeptList.length === 0 && currentUserList.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无数据" />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Dept } from '@/api/system/dept'
+import type { User } from '@/api/system/user'
+import { computed, onMounted, ref } from 'vue'
+import { getSimpleDeptList } from '@/api/system/dept'
+import { getSimpleUserList, getUser } from '@/api/system/user'
+import { findChildren, handleTree } from '@/utils/tree'
+import Breadcrumb from './components/breadcrumb.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const loading = ref(false)
+const deptList = ref<Dept[]>([]) // 完整部门列表(树形结构)
+const userList = ref<User[]>([]) // 用户列表
+
+const currentDeptId = ref(0) // 当前层级的部门编号
+const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
+
+/** 当前层级的部门列表 */
+const currentDeptList = computed(() => {
+  if (currentDeptId.value === 0) {
+    return deptList.value.filter(item => item.parentId === 0)
+  }
+  return findChildren(deptList.value, currentDeptId.value)
+})
+
+/** 当前层级的用户列表 */
+const currentUserList = computed(() => {
+  if (currentDeptId.value === 0) {
+    // 根层级不显示用户,只显示部门
+    return []
+  }
+  return userList.value.filter(item => item.deptId === currentDeptId.value)
+})
+
+/** 进入部门层级 */
+function handleEnterDept(item: Dept) {
+  breadcrumbRef.value?.enter({ id: item.id!, name: item.name })
+}
+
+/** 点击用户:弹出联系方式 */
+async function handleUserClick(item: User) {
+  const userInfo = await getUser(item.id!)
+  const actions: string[] = []
+  if (userInfo.mobile) {
+    actions.push(`手机:${userInfo.mobile}`)
+  }
+  if (userInfo.email) {
+    actions.push(`邮箱:${userInfo.email}`)
+  }
+  if (actions.length === 0) {
+    uni.showToast({ title: '暂无联系方式', icon: 'none' })
+    return
+  }
+  uni.showActionSheet({
+    title: userInfo.nickname,
+    itemList: actions,
+    success: (res) => {
+      const selected = actions[res.tapIndex]
+      if (selected.startsWith('手机')) {
+        uni.makePhoneCall({ phoneNumber: userInfo.mobile! })
+      } else if (selected.startsWith('邮箱')) {
+        uni.setClipboardData({ data: userInfo.email!, success: () => {
+          uni.showToast({ title: '邮箱已复制', icon: 'success' })
+        } })
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(async () => {
+  loading.value = true
+  try {
+    // 获取部门列表
+    const deptData = await getSimpleDeptList()
+    deptList.value = handleTree(deptData)
+    // 获取用户列表
+    userList.value = await getSimpleUserList()
+  } finally {
+    loading.value = false
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 30 - 0
src/pages/error/404.vue

@@ -0,0 +1,30 @@
+<script lang="ts" setup>
+import { HOME_PAGE } from '@/utils'
+
+definePage({
+  style: {
+    // 'custom' 表示开启自定义导航栏,默认 'default'
+    navigationStyle: 'custom',
+  },
+})
+
+function goBack() {
+  // 当pages.config.ts中配置了tabbar页面时,使用switchTab切换到首页
+  // 否则使用navigateTo返回首页
+  uni.switchTab({ url: HOME_PAGE })
+}
+</script>
+
+<template>
+  <view class="h-screen flex flex-col items-center justify-center">
+    <view> 404 </view>
+    <view> 页面不存在 </view>
+    <button class="mt-6 w-40 text-center" @click="goBack">
+      返回首页
+    </button>
+  </view>
+</template>
+
+<style lang="scss" scoped>
+//
+</style>

+ 33 - 0
src/pages/index/components/banner.vue

@@ -0,0 +1,33 @@
+<template>
+  <view class="mx-20rpx mt-20rpx overflow-hidden rounded-16rpx bg-white">
+    <wd-swiper
+      :list="banners"
+      autoplay
+      :interval="5000"
+      indicator-position="bottom-right"
+      image-mode="aspectFill"
+      height="300rpx"
+      custom-class="rounded-16rpx overflow-hidden"
+      @click="handleClick"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+defineOptions({
+  name: 'HomeBanner',
+})
+
+/** Banner 轮播图数据 */
+const banners: string[] = [
+  '/static/images/banner/banner01.jpg',
+  '/static/images/banner/banner02.jpg',
+  '/static/images/banner/banner03.jpg',
+]
+
+/** 处理点击 */
+function handleClick(index: number) {
+  // TODO @芋艿:看看后续要不要支持跳转
+  console.log('点击了第', index + 1, '张 Banner')
+}
+</script>

+ 87 - 0
src/pages/index/components/menu-grid.vue

@@ -0,0 +1,87 @@
+<template>
+  <view>
+    <wd-grid :column="4" clickable :border="false">
+      <wd-grid-item
+        v-for="menu in menus"
+        :key="menu.key"
+        :text="menu.name"
+        @itemclick="handleClick(menu)"
+      >
+        <template #icon>
+          <view
+            class="h-80rpx w-80rpx flex items-center justify-center rounded-16rpx"
+            :style="getIconStyle(menu)"
+          >
+            <wd-icon :name="menu.icon" size="50rpx" :color="menu.iconColor" />
+          </view>
+        </template>
+      </wd-grid-item>
+    </wd-grid>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { MenuItem } from '../index'
+import { isTabBarPage } from '@/tabbar/config'
+import { parseUrl, setTabParams } from '@/utils/url'
+
+defineOptions({
+  name: 'MenuGrid',
+})
+
+defineProps<{
+  menus: MenuItem[] // 菜单列表
+}>()
+
+/** 处理菜单点击 */
+function handleClick(menu: MenuItem) {
+  console.log( '点击菜单:', menu )
+  if (!menu.url) {
+    uni.showToast({ title: '功能开发中', icon: 'none' })
+    return
+  }
+
+  // 解析 URL,提取路径和参数
+  const { path, query } = parseUrl(menu.url)
+
+  // 判断是否是 tabBar 页面
+  if (isTabBarPage(path)) {
+    // tabBar 页面:通过 globalData 传参,使用 switchTab 跳转
+    if (Object.keys(query).length > 0) {
+      setTabParams(query)
+    }
+    uni.switchTab({
+      url: path,
+      fail: () => {
+        uni.showToast({ title: '页面不存在', icon: 'none' })
+      },
+    })
+  } else {
+    // 普通页面:使用 navigateTo 跳转
+    uni.navigateTo({
+      url: menu.url,
+      fail: () => {
+        uni.showToast({ title: '页面不存在', icon: 'none' })
+      },
+    })
+  }
+}
+
+/** 获取图标样式 */
+function getIconStyle(menu: MenuItem) {
+  return {
+    backgroundColor: menu.iconColor ? `${menu.iconColor}20` : '#f5f5f5',
+    color: menu.iconColor || '#666',
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.wd-grid-item__text) {
+  margin-top: 8rpx;
+  font-size: 24rpx;
+  color: #333;
+  overflow: visible;
+  white-space: nowrap;
+}
+</style>

+ 79 - 0
src/pages/index/components/menu-section.vue

@@ -0,0 +1,79 @@
+<template>
+  <scroll-view class="min-h-0 flex-1" scroll-y scroll-with-animation>
+    <!-- 常用分组 -->
+    <view class="mx-20rpx mt-20rpx overflow-hidden rounded-16rpx bg-white">
+      <view class="flex items-center justify-between px-24rpx py-20rpx">
+        <text class="text-28rpx text-#333 font-500">常用</text>
+        <view class="p-10rpx" @click="handleGotoFavoriteSettings">
+          <wd-icon name="setting" size="32rpx" color="#999" />
+        </view>
+      </view>
+      <MenuGrid v-if="favoriteMenuItems.length > 0" :menus="favoriteMenuItems" />
+      <view
+        v-else
+        class="mx-24rpx mb-24rpx flex items-center border-1rpx border-#ddd rounded-12rpx border-dashed px-24rpx py-12rpx"
+        @click="handleGotoFavoriteSettings"
+      >
+        <wd-icon name="add" size="32rpx" color="#999" />
+        <text class="pl-10rpx text-28rpx text-#999">添加我常用的</text>
+      </view>
+    </view>
+
+    <!-- 菜单分组 -->
+    <view v-for="group in menuGroups" :key="group.key" class="mx-20rpx mt-20rpx overflow-hidden rounded-16rpx bg-white">
+      <view class="px-24rpx pb-0 pt-20rpx">
+        <text class="text-28rpx text-#333 font-500">{{ group.name }}</text>
+      </view>
+      <MenuGrid :menus="group.menus" />
+    </view>
+
+    <!-- 底部安全区域 -->
+    <view class="h-40rpx" />
+  </scroll-view>
+</template>
+
+<script lang="ts" setup>
+import type { MenuGroup, MenuItem } from '../index'
+import { useUserStore } from '@/store/user'
+import { getMenuGroups, getMenuItemByKey } from '../index'
+import MenuGrid from './menu-grid.vue'
+
+defineOptions({
+  name: 'MenuSection',
+})
+
+const userStore = useUserStore()
+
+/** 菜单分组列表 */
+const menuGroups = ref<MenuGroup[]>([])
+
+/** 常用服务菜单(从 store 中计算得出) */
+const favoriteMenuItems = computed<MenuItem[]>(() => {
+  const keys = userStore.favoriteMenus
+  if (!keys || keys.length === 0) {
+    return []
+  }
+  return keys.map(key => getMenuItemByKey(key)).filter(Boolean) as MenuItem[]
+})
+
+/** 初始化数据 */
+function initData() {
+  menuGroups.value = getMenuGroups()
+}
+
+/** 跳转到常用服务设置页面 */
+function handleGotoFavoriteSettings() {
+  uni.navigateTo({
+    url: '/pages/index/settings/index',
+  })
+}
+
+/**
+ * 初始化
+ *
+ * 不使用 onMounted 的原因是:登录后,页面可能已经挂载,但数据需要重新初始化
+ */
+onShow(() => {
+  initData()
+})
+</script>

+ 76 - 0
src/pages/index/components/user-header.vue

@@ -0,0 +1,76 @@
+<template>
+  <view class="mx-20rpx mt-20rpx overflow-hidden rounded-16rpx bg-white">
+    <view class="flex items-center p-24rpx">
+      <view class="avatar-wrapper mr-20rpx h-100rpx w-100rpx overflow-hidden rounded-full">
+        <image
+          :src="userInfo.avatar"
+          mode="aspectFill"
+          class="h-full w-full"
+        />
+      </view>
+      <view class="flex-1">
+        <view class="text-32rpx text-#333 font-500">
+          {{ greeting }},{{ userInfo.nickname || userInfo.username }}
+        </view>
+        <view class="mt-8rpx text-26rpx text-#999">
+          {{ description }}
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { storeToRefs } from 'pinia'
+import { useUserStore } from '@/store'
+
+defineOptions({
+  name: 'UserHeader',
+})
+
+const userStore = useUserStore()
+const { userInfo } = storeToRefs(userStore)
+
+/** 根据时间获取问候语 */
+const greeting = computed(() => {
+  const hour = new Date().getHours()
+  if (hour < 6) {
+    return '凌晨好'
+  } else if (hour < 9) {
+    return '早上好'
+  } else if (hour < 12) {
+    return '上午好'
+  } else if (hour < 14) {
+    return '中午好'
+  } else if (hour < 17) {
+    return '下午好'
+  } else if (hour < 19) {
+    return '傍晚好'
+  } else {
+    return '晚上好'
+  }
+})
+
+/** 描述语 */
+const description = computed(() => {
+  const hour = new Date().getHours()
+  if (hour < 9) {
+    return '开始新的一天,加油!'
+  } else if (hour < 12) {
+    return '工作顺利,效率满满!'
+  } else if (hour < 14) {
+    return '午休时间,记得休息~'
+  } else if (hour < 18) {
+    return '继续努力,收获满满!'
+  } else {
+    return '辛苦了,注意休息!'
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.avatar-wrapper {
+  border: 3rpx solid #f0f0f0;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+</style>

+ 173 - 0
src/pages/index/index.ts

@@ -0,0 +1,173 @@
+import { useAccess } from '@/hooks/useAccess'
+
+/**
+ * 工作台菜单数据
+ * 定义菜单分组和菜单项的数据结构
+ */
+
+/** 菜单项类型 */
+export interface MenuItem {
+  key: string // 菜单唯一标识
+  name: string // 菜单名称
+  icon: string // 菜单图标(支持 wot-design-uni 图标名或图片路径)
+  url?: string // 跳转路径
+  iconColor?: string // 图标颜色(可选)
+  enabled?: boolean // 是否启用(可选,默认 true)
+  permission?: string // 权限标识(可选)
+}
+
+/** 菜单分组类型 */
+export interface MenuGroup {
+  key: string // 分组唯一标识
+  name: string // 分组名称
+  menus: MenuItem[] // 分组下的菜单列表
+}
+
+/** 菜单分组原始数据 */
+const menuGroupsData: MenuGroup[] = [
+  {
+    key: 'system',
+    name: '系统管理',
+    menus: [
+      {
+        key: 'user',
+        name: '用户管理',
+        icon: 'user',
+        url: '/pages-system/user/index',
+        iconColor: '#1890ff',
+        permission: 'system:user:list',
+      },
+      {
+        key: 'role',
+        name: '角色管理',
+        icon: 'usergroup',
+        url: '/pages-system/role/index',
+        iconColor: '#52c41a',
+        permission: 'system:role:query',
+      },
+      {
+        key: 'menu',
+        name: '菜单管理',
+        icon: 'menu-fold',
+        url: '/pages-system/menu/index',
+        iconColor: '#fa8c16',
+        permission: 'system:menu:query',
+      },
+      {
+        key: 'dept',
+        name: '部门管理',
+        icon: 'attach',
+        url: '/pages-system/dept/index',
+        iconColor: '#13c2c2',
+        permission: 'system:dept:query',
+      },
+      {
+        key: 'post',
+        name: '岗位管理',
+        icon: 'flag',
+        url: '/pages-system/post/index',
+        iconColor: '#eb2f96',
+        permission: 'system:post:query',
+      },
+    ],
+  },
+  {
+    key: 'infra',
+    name: '基础设施',
+    menus: [
+      {
+        key: 'accessLog',
+        name: '访问日志',
+        icon: 'laptop',
+        url: '/pages-infra/apiAccessLog/index',
+        iconColor: '#2f54eb',
+        permission: 'infra:api-access-log:query',
+      },
+      {
+        key: 'errorLog',
+        name: '错误日志',
+        icon: 'error-circle',
+        url: '/pages-infra/apiErrorLog/index',
+        iconColor: '#f5222d',
+        permission: 'infra:api-error-log:query',
+      },
+      {
+        key: 'websocket',
+        name: 'WebSocket',
+        icon: 'chat',
+        url: '/pages-infra/webSocket/index',
+        iconColor: '#36cfc9',
+      },
+    ],
+  },
+  {
+    key: 'bpm',
+    name: '工作流程',
+    menus: [
+      {
+        key: 'bpmMy',
+        name: '我的流程',
+        icon: 'list',
+        url: '/pages/bpm/index?tab=my',
+        iconColor: '#597ef7',
+        permission: 'bpm:process-instance:query',
+      },
+      {
+        key: 'bpmTodo',
+        name: '待办任务',
+        icon: 'clock',
+        url: '/pages/bpm/index?tab=todo',
+        iconColor: '#ff7a45',
+        permission: 'bpm:task:query',
+      },
+      {
+        key: 'bpmDone',
+        name: '已办任务',
+        icon: 'check-circle',
+        url: '/pages/bpm/index?tab=done',
+        iconColor: '#73d13d',
+        permission: 'bpm:task:query',
+      },
+      {
+        key: 'bpmCopy',
+        name: '抄送我的',
+        icon: 'mail',
+        url: '/pages/bpm/index?tab=copy',
+        iconColor: '#5cdbd3',
+        permission: 'bpm:process-instance-cc:query',
+      },
+    ],
+  },
+]
+
+/**
+ * 获取所有菜单分组数据(带权限过滤):过滤掉没有权限的菜单项,如果整个分组都没有权限则不展示该分组
+ */
+export function getMenuGroups(): MenuGroup[] {
+  const { hasAccessByCodes } = useAccess()
+  return menuGroupsData
+    .map(group => ({
+      ...group,
+      // 过滤掉没有权限的菜单项
+      menus: group.menus.filter((menu) => {
+        // 没有配置权限的菜单项默认展示
+        if (!menu.permission) {
+          return true
+        }
+        return hasAccessByCodes([menu.permission])
+      }),
+    }))
+    // 过滤掉没有菜单项的分组
+    .filter(group => group.menus.length > 0)
+}
+
+/** 获取所有菜单项(扁平化) */
+export function getAllMenuItems(): MenuItem[] {
+  const groups = getMenuGroups()
+  return groups.flatMap(group => group.menus)
+}
+
+/** 根据 key 获取菜单项 */
+export function getMenuItemByKey(key: string): MenuItem | undefined {
+  return getAllMenuItems().find(item => item.key === key)
+}

+ 20 - 42
src/pages/index/index.vue

@@ -1,53 +1,31 @@
+<template>
+  <view class="min-h-screen flex flex-col bg-#f5f5f5" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
+    <!-- 顶部导航栏 -->
+    <wd-navbar title="工作台" />
+    <!-- 用户信息头部 -->
+    <UserHeader />
+    <!-- Banner 轮播图 -->
+    <HomeBanner />
+    <!-- 菜单区域 -->
+    <MenuSection />
+  </view>
+</template>
+
 <script lang="ts" setup>
+import { safeAreaInsets } from '@/utils/systemInfo'
+import HomeBanner from './components/banner.vue'
+import MenuSection from './components/menu-section.vue'
+import UserHeader from './components/user-header.vue'
+
 defineOptions({
   name: 'Home',
 })
+
 definePage({
-  // 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
   type: 'home',
   style: {
-    // 'custom' 表示开启自定义导航栏,默认 'default'
     navigationStyle: 'custom',
-    navigationBarTitleText: '首页',
+    navigationBarTitleText: '工作台',
   },
 })
-
-const description = ref(
-  'unibest 是一个集成了多种工具和技术的 uniapp 开发模板,由 uniapp + Vue3 + Ts + Vite5 + UnoCss + VSCode 构建,模板具有代码提示、自动格式化、统一配置、代码片段等功能,并内置了许多常用的基本组件和基本功能,让你编写 uniapp 拥有 best 体验。',
-)
-console.log('index/index 首页打印了')
-
-onLoad(() => {
-  console.log('测试 uni API 自动引入: onLoad')
-})
 </script>
-
-<template>
-  <view class="bg-white px-4 pt-safe">
-    <view class="mt-10">
-      <image src="/static/logo.svg" alt="" class="mx-auto block h-28 w-28" />
-    </view>
-    <view class="mt-4 text-center text-4xl text-[#d14328]">
-      unibest
-    </view>
-    <view class="mb-8 mt-2 text-center text-2xl">
-      最好用的 uniapp 开发模板
-    </view>
-
-    <view class="m-auto mb-2 max-w-100 text-justify indent text-4">
-      {{ description }}
-    </view>
-    <view class="mt-4 text-center">
-      作者:
-      <text class="text-green-500">
-        菲鸽
-      </text>
-    </view>
-    <view class="mt-4 text-center">
-      官网地址:
-      <text class="text-green-500">
-        https://unibest.tech
-      </text>
-    </view>
-  </view>
-</template>

+ 200 - 0
src/pages/index/settings/index.vue

@@ -0,0 +1,200 @@
+<template>
+  <view class="min-h-screen bg-#f5f5f5">
+    <!-- 搜索框 -->
+    <view class="mx-20rpx overflow-hidden rounded-16rpx bg-white">
+      <wd-search v-model="searchKeyword" placeholder="搜索" hide-cancel />
+    </view>
+
+    <!-- 常用区域 -->
+    <view class="mx-20rpx mt-20rpx overflow-hidden rounded-16rpx bg-white">
+      <view class="section-header">
+        <text class="text-28rpx text-#333 font-500">常用</text>
+      </view>
+      <!-- 常用分组 -->
+      <view v-if="favoriteMenus.length > 0" class="menu-list">
+        <view v-for="menu in favoriteMenus" :key="menu.key" class="menu-item">
+          <view class="menu-item__left">
+            <view class="menu-item__icon" :style="{ backgroundColor: menu.iconColor ? `${menu.iconColor}20` : '#f5f5f5' }">
+              <wd-icon :name="menu.icon" size="40rpx" :color="menu.iconColor" />
+            </view>
+            <text class="menu-item__name">{{ menu.name }}</text>
+          </view>
+          <view class="menu-item__right">
+            <wd-button size="small" type="warning" plain custom-class="mr-16rpx" @click="handleRemoveFavorite(menu)">
+              从常用移除
+            </wd-button>
+            <wd-icon name="menu" size="40rpx" color="#ccc" />
+          </view>
+        </view>
+      </view>
+      <view v-else class="flex flex-col items-center justify-center py-60rpx">
+        <wd-button type="primary" plain @click="scrollToGroups">
+          <wd-icon name="add" size="28rpx" />
+          添加我常用的
+        </wd-button>
+      </view>
+    </view>
+
+    <!-- 菜单分组 -->
+    <view id="menuGroups">
+      <view v-for="group in filteredMenuGroups" :key="group.key" class="mx-20rpx mt-20rpx overflow-hidden rounded-16rpx bg-white">
+        <view class="section-header">
+          <text class="text-28rpx text-#333 font-500">{{ group.name }}</text>
+        </view>
+        <view class="menu-list">
+          <view v-for="menu in group.menus" :key="menu.key" class="menu-item">
+            <view class="menu-item__left">
+              <view class="menu-item__icon" :style="{ backgroundColor: menu.iconColor ? `${menu.iconColor}20` : '#f5f5f5' }">
+                <wd-icon :name="menu.icon" size="40rpx" :color="menu.iconColor" />
+              </view>
+              <text class="menu-item__name">{{ menu.name }}</text>
+            </view>
+            <view class="menu-item__right">
+              <wd-button v-if="isInFavorites(menu)" size="small" type="warning" plain @click="handleRemoveFavorite(menu)">
+                从常用移除
+              </wd-button>
+              <wd-button v-else size="small" type="primary" plain @click="handleAddFavorite(menu)">
+                添加至常用
+              </wd-button>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部安全区域 -->
+    <view class="h-40rpx" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { MenuGroup, MenuItem } from '../index'
+import { useUserStore } from '@/store/user'
+import { getMenuGroups, getMenuItemByKey } from '../index'
+
+defineOptions({
+  name: 'FavoriteSettings',
+})
+
+definePage({
+  style: {
+    navigationBarTitleText: '编辑工作台',
+  },
+})
+
+const userStore = useUserStore()
+
+const searchKeyword = ref('') // 搜索关键词
+const menuGroups = ref<MenuGroup[]>([]) // 菜单分组列表
+const favoriteMenus = computed<MenuItem[]>(() => {
+  const keys = userStore.favoriteMenus
+  if (!keys || keys.length === 0) {
+    return []
+  }
+  return keys.map(key => getMenuItemByKey(key)).filter(Boolean) as MenuItem[]
+}) // 常用服务菜单(从 store 中计算得出)
+
+/** 过滤后的菜单分组 */
+const filteredMenuGroups = computed(() => {
+  if (!searchKeyword.value) {
+    return menuGroups.value
+  }
+  const keyword = searchKeyword.value.toLowerCase()
+  return menuGroups.value
+    .map(group => ({
+      ...group,
+      menus: group.menus.filter(menu => menu.name.toLowerCase().includes(keyword)),
+    }))
+    .filter(group => group.menus.length > 0)
+})
+
+/** 初始化数据 */
+function initData() {
+  menuGroups.value = getMenuGroups()
+}
+
+/** 处理添加常用服务 */
+function handleAddFavorite(menu: MenuItem) {
+  const keys = [...userStore.favoriteMenus]
+  if (!keys.includes(menu.key)) {
+    keys.push(menu.key)
+    userStore.setFavoriteMenus(keys)
+  }
+  uni.showToast({ title: '已添加', icon: 'success' })
+}
+
+/** 处理移除常用服务 */
+function handleRemoveFavorite(menu: MenuItem) {
+  const keys = [...userStore.favoriteMenus]
+  const index = keys.indexOf(menu.key)
+  if (index > -1) {
+    keys.splice(index, 1)
+    userStore.setFavoriteMenus(keys)
+  }
+  uni.showToast({ title: '已移除', icon: 'success' })
+}
+
+/** 检查菜单是否已添加到常用服务 */
+function isInFavorites(menu: MenuItem): boolean {
+  return favoriteMenus.value.some(m => m.key === menu.key)
+}
+
+/** 滚动到菜单分组区域 */
+function scrollToGroups() {
+  uni.pageScrollTo({
+    selector: '#menuGroups',
+    duration: 300,
+  })
+}
+
+onLoad(() => {
+  initData()
+})
+</script>
+
+<style lang="scss" scoped>
+.section-header {
+  padding: 24rpx 30rpx 16rpx;
+}
+
+.menu-list {
+  padding: 0 30rpx 20rpx;
+}
+
+.menu-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+
+  &:last-child {
+    border-bottom: none;
+  }
+
+  &__left {
+    display: flex;
+    align-items: center;
+  }
+
+  &__icon {
+    width: 80rpx;
+    height: 80rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 16rpx;
+    margin-right: 24rpx;
+  }
+
+  &__name {
+    font-size: 30rpx;
+    color: #333;
+  }
+
+  &__right {
+    display: flex;
+    align-items: center;
+  }
+}
+</style>

+ 0 - 13
src/pages/me/me.vue

@@ -1,13 +0,0 @@
-<script lang="ts" setup>
-definePage({
-  style: {
-    navigationBarTitleText: '我的',
-  },
-})
-</script>
-
-<template>
-  <view class="mt-10 text-center text-green-500">
-    我的页面
-  </view>
-</template>

+ 82 - 0
src/pages/message/components/detail-popup.vue

@@ -0,0 +1,82 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="bottom"
+    custom-style="border-radius: 24rpx 24rpx 0 0; max-height: 80vh;"
+    safe-area-inset-bottom
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <!-- 标题 -->
+      <view class="mb-32rpx flex items-center justify-between">
+        <view class="text-36rpx text-[#333] font-semibold">
+          消息详情
+        </view>
+        <view class="p-8rpx" @click="visible = false">
+          <wd-icon name="close" size="20px" color="#999" />
+        </view>
+      </view>
+
+      <!-- 详情内容 -->
+      <view v-if="formData" class="space-y-24rpx">
+        <view class="flex items-start">
+          <text class="w-160rpx shrink-0 text-28rpx text-[#999]">发送人</text>
+          <text class="text-28rpx text-[#333]">{{ formData.templateNickname || '-' }}</text>
+        </view>
+        <view class="flex items-start">
+          <text class="w-160rpx shrink-0 text-28rpx text-[#999]">发送时间</text>
+          <text class="text-28rpx text-[#333]">{{ formatDateTime(formData.createTime) || '-' }}</text>
+        </view>
+        <view class="flex items-start">
+          <text class="w-160rpx shrink-0 text-28rpx text-[#999]">消息类型</text>
+          <text class="text-28rpx text-[#333]">
+            {{ getDictLabel(DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE, formData.templateType) }}
+          </text>
+        </view>
+        <view class="flex items-start">
+          <text class="w-160rpx shrink-0 text-28rpx text-[#999]">是否已读</text>
+          <wd-tag v-if="formData.readStatus" type="success" plain>
+            已读
+          </wd-tag>
+          <wd-tag v-else type="warning" plain>
+            未读
+          </wd-tag>
+        </view>
+        <view v-if="formData.readStatus" class="flex items-start">
+          <text class="w-160rpx shrink-0 text-28rpx text-[#999]">阅读时间</text>
+          <text class="text-28rpx text-[#333]">{{ formatDateTime(formData.readTime) || '-' }}</text>
+        </view>
+        <view class="flex flex-col">
+          <text class="mb-12rpx w-160rpx shrink-0 text-28rpx text-[#999]">消息内容</text>
+          <view class="rounded-12rpx bg-[#f5f5f5] p-24rpx">
+            <text class="text-28rpx text-[#333]">{{ formData.templateContent || '-' }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import type { NotifyMessage } from '@/api/system/notify'
+import { ref } from 'vue'
+import { getDictLabel } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const visible = ref(false)
+const formData = ref<NotifyMessage>()
+
+/** 打开弹窗 */
+function open(data: NotifyMessage) {
+  formData.value = data
+  visible.value = true
+}
+
+/** 关闭弹窗 */
+function close() {
+  visible.value = false
+}
+
+defineExpose({ open, close })
+</script>

+ 172 - 0
src/pages/message/components/search-form.vue

@@ -0,0 +1,172 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="top"
+    custom-style="border-radius: 0 0 24rpx 24rpx;"
+    safe-area-inset-top
+    @close="visible = false"
+  >
+    <view class="p-32rpx">
+      <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+        搜索消息
+      </view>
+      <view class="mb-24rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          已读状态
+        </view>
+        <wd-radio-group v-model="formData.readStatus" shape="button" size="medium">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio :value="1">
+            已读
+          </wd-radio>
+          <wd-radio :value="0">
+            未读
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="mb-32rpx">
+        <view class="mb-12rpx text-28rpx text-[#666]">
+          发送时间
+        </view>
+        <view class="flex items-center gap-16rpx">
+          <view class="flex-1" @click="showStartPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          <text class="text-28rpx text-[#999]">至</text>
+          <view class="flex-1" @click="showEndPicker = true">
+            <view
+              class="h-72rpx flex items-center justify-center rounded-8rpx bg-[#f5f5f5] px-24rpx text-28rpx"
+            >
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <!-- 开始时间选择器 -->
+        <wd-datetime-picker-view
+          v-if="showStartPicker"
+          v-model="tempCreateTime[0]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showStartPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleStartCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleStartConfirm">
+            确定
+          </wd-button>
+        </view>
+        <!-- 结束时间选择器 -->
+        <wd-datetime-picker-view
+          v-if="showEndPicker"
+          v-model="tempCreateTime[1]"
+          type="date"
+          :columns-height="200"
+        />
+        <view v-if="showEndPicker" class="mt-16rpx flex justify-end gap-16rpx">
+          <wd-button size="small" plain @click="handleEndCancel">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleEndConfirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="w-full flex justify-center gap-24rpx">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue'
+import { formatDate } from '@/utils/date'
+
+/** 搜索表单数据 */
+export interface SearchFormData {
+  readStatus: number // -1 表示全部, 0 未读, 1 已读
+  createTime?: [number | undefined, number | undefined]
+}
+
+const props = defineProps<{
+  modelValue: boolean
+  searchParams?: Partial<SearchFormData>
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'search': [data: SearchFormData]
+  'reset': []
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val: boolean) => emit('update:modelValue', val),
+})
+
+const formData = reactive<SearchFormData>({
+  readStatus: -1,
+  createTime: [undefined, undefined],
+})
+
+// 时间选择器状态
+const showStartPicker = ref(false)
+const showEndPicker = ref(false)
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 开始时间确认 */
+function handleStartConfirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  showStartPicker.value = false
+}
+
+/** 开始时间取消 */
+function handleStartCancel() {
+  showStartPicker.value = false
+}
+
+/** 结束时间确认 */
+function handleEndConfirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  showEndPicker.value = false
+}
+
+/** 结束时间取消 */
+function handleEndCancel() {
+  showEndPicker.value = false
+}
+
+/** 监听弹窗打开,同步外部参数 */
+watch(() => props.modelValue, (val) => {
+  if (val && props.searchParams) {
+    formData.readStatus = props.searchParams.readStatus ?? -1
+    formData.createTime = props.searchParams.createTime ?? [undefined, undefined]
+  }
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.readStatus = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 237 - 0
src/pages/message/index.vue

@@ -0,0 +1,237 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="我的消息"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    >
+      <template #right>
+        <view class="flex items-center gap-24rpx">
+          <view @click="handleReadAll">
+            <wd-icon name="check-circle" size="20px" />
+          </view>
+          <view @click="searchVisible = !searchVisible">
+            <wd-icon name="search" size="20px" />
+          </view>
+        </view>
+      </template>
+    </wd-navbar>
+
+    <!-- 消息列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <!-- 消息头部 -->
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="flex items-center">
+              <view
+                v-if="!item.readStatus"
+                class="mr-12rpx h-16rpx w-16rpx rounded-full bg-red-500"
+              />
+              <view class="text-32rpx text-[#333] font-semibold">
+                {{ item.templateNickname }}
+              </view>
+            </view>
+            <wd-tag v-if="item.readStatus" type="success" plain>
+              已读
+            </wd-tag>
+            <wd-tag v-else type="warning" plain>
+              未读
+            </wd-tag>
+          </view>
+          <!-- 消息内容 -->
+          <view class="mb-12rpx rounded-8rpx bg-[#f7f8f9] p-20rpx">
+            <view class="line-clamp-1 mb-8rpx text-30rpx text-[#323333] font-bold">
+              {{ getDictLabel(DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE, item.templateType) }}
+            </view>
+            <view class="line-clamp-2 text-28rpx text-[#777]">
+              {{ item.templateContent }}
+            </view>
+          </view>
+          <!-- 消息时间 -->
+          <view class="flex items-center justify-between text-26rpx text-[#999]">
+            <text>{{ formatDateTime(item.createTime) }}</text>
+            <view
+              v-if="!item.readStatus"
+              class="text-[#1890ff]"
+              @click.stop="handleReadOne(item)"
+            >
+              标记已读
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无消息" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <SearchForm
+      v-model="searchVisible"
+      :search-params="queryParams"
+      @search="handleQuery"
+      @reset="handleReset"
+    />
+
+    <!-- 详情弹窗 -->
+    <DetailPopup ref="detailPopupRef" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SearchFormData } from './components/search-form.vue'
+import type { NotifyMessage } from '@/api/system/notify'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, reactive, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import {
+  getMyNotifyMessagePage,
+  updateAllNotifyMessageRead,
+  updateNotifyMessageRead,
+} from '@/api/system/notify'
+import { getDictLabel } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateRange, formatDateTime } from '@/utils/date'
+import DetailPopup from './components/detail-popup.vue'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const total = ref(0) // 列表的总页数
+const list = ref<NotifyMessage[]>([]) // 列表的数据
+const loadMoreState = ref<LoadMoreState>('loading') // 加载更多状态
+const searchVisible = ref(false) // 搜索弹窗
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  readStatus: -1 as number, // -1 表示全部
+  createTime: [undefined, undefined] as [number | undefined, number | undefined],
+})
+const detailPopupRef = ref<InstanceType<typeof DetailPopup>>() // 详情弹窗
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 查询消息列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    // 构建参数
+    const params = { ...queryParams } as any
+    if (queryParams.readStatus !== -1) {
+      params.readStatus = queryParams.readStatus === 1
+    } else {
+      delete params.readStatus
+    }
+    params.createTime = formatDateRange(queryParams.createTime)
+
+    // 执行查询
+    const data = await getMyNotifyMessagePage(params)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.pageNo = queryParams.pageNo > 1 ? queryParams.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: SearchFormData) {
+  queryParams.readStatus = data?.readStatus ?? -1
+  queryParams.createTime = data?.createTime ?? [undefined, undefined]
+  queryParams.pageNo = 1
+  list.value = [] // 清空列表
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.pageNo++
+  getList()
+}
+
+/** 查看详情 */
+function handleDetail(item: NotifyMessage) {
+  // 如果未读,先标记已读
+  if (!item.readStatus) {
+    handleReadOne(item, false)
+  }
+  // 打开详情弹窗
+  detailPopupRef.value?.open(item)
+}
+
+/** 标记单条已读 */
+async function handleReadOne(item: NotifyMessage, showToast = true) {
+  await updateNotifyMessageRead(item.id)
+  // 更新本地状态
+  item.readStatus = true
+  item.readTime = new Date().toISOString()
+  if (showToast) {
+    toast.success('已标记为已读')
+  }
+}
+
+/** 标记全部已读 */
+function handleReadAll() {
+  uni.showModal({
+    title: '提示',
+    content: '确定要将所有消息标记为已读吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      await updateAllNotifyMessageRead()
+      toast.success('全部已读成功')
+      // 刷新列表
+      queryParams.pageNo = 1
+      list.value = []
+      await getList()
+    },
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 90 - 0
src/pages/user/contact/index.vue

@@ -0,0 +1,90 @@
+<template>
+  <view class="min-h-screen bg-gray-100">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="联系客服"
+      left-arrow
+      placeholder
+      safe-area-inset-top
+      fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 客服卡片 -->
+    <view class="mx-30rpx mt-20rpx rounded-16rpx bg-white px-60rpx py-80rpx">
+      <view class="flex flex-col items-center">
+        <!-- 二维码图片 -->
+        <view class="mb-30rpx h-280rpx w-280rpx overflow-hidden rounded-16rpx">
+          <wd-img
+            :src="qrCodeUrl"
+            width="280rpx"
+            height="280rpx"
+            mode="aspectFit"
+          />
+        </view>
+        <text class="mb-40rpx text-32rpx font-bold text-gray-800">
+          添加客服二维码
+        </text>
+        <text class="mb-16rpx text-28rpx text-gray-500">
+          服务时间:早上 9:00 - 22:00
+        </text>
+
+        <!-- 客服电话 -->
+        <view class="flex items-center text-28rpx text-gray-500">
+          <text>客服电话:{{ servicePhone }}</text>
+          <text
+            class="ml-10rpx text-blue-500 underline"
+            @click="handleCallPhone"
+          >
+            拨打
+          </text>
+        </view>
+
+        <!-- 保存按钮 -->
+        <view class="mt-60rpx w-full">
+          <wd-button type="primary" block @click="handleSaveQRCode">
+            保存二维码图片
+          </wd-button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { saveImageToAlbum } from '@/utils/download'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const qrCodeUrl = ref('/static/images/qrcode.png') // 客服二维码图片地址
+const servicePhone = ref('18818818818') // 客服电话号码
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 拨打电话 */
+function handleCallPhone() {
+  uni.makePhoneCall({
+    phoneNumber: servicePhone.value,
+    fail: (err) => {
+      uni.showToast({
+        icon: 'none',
+        title: '拨打失败',
+      })
+    },
+  })
+}
+
+/** 保存二维码图片 */
+async function handleSaveQRCode() {
+  await saveImageToAlbum(qrCodeUrl.value, 'weixin_qrcode.png')
+}
+</script>

+ 66 - 0
src/pages/user/faq/data.ts

@@ -0,0 +1,66 @@
+/**
+ * FAQ 常见问题数据
+ */
+export interface FaqItem {
+  /** 问题标题 */
+  title: string
+  /** 问题答案 */
+  content: string
+}
+
+export interface FaqCategory {
+  /** 分类图标 */
+  icon: string
+  /** 分类标题 */
+  title: string
+  /** 问题列表 */
+  childList: FaqItem[]
+}
+
+/** FAQ 数据列表 */
+export const faqList: FaqCategory[] = [
+  {
+    icon: 'github-filled',
+    title: '芋道问题',
+    childList: [
+      {
+        title: '芋道开源吗?',
+        content: '开源,基于 MIT 协议,可免费商用。',
+      },
+      {
+        title: '芋道可以商用吗?',
+        content: '可以,芋道采用 MIT 开源协议,允许商业使用。',
+      },
+      {
+        title: '芋道官网地址多少?',
+        content: 'https://www.iocoder.cn',
+      },
+      {
+        title: '芋道文档地址多少?',
+        content: 'https://doc.iocoder.cn',
+      },
+    ],
+  },
+  {
+    icon: 'warning',
+    title: '其他问题',
+    childList: [
+      {
+        title: '如何退出登录?',
+        content: '请点击 [我的] - [退出登录] 即可退出登录。',
+      },
+      {
+        title: '如何修改用户头像?',
+        content: '请点击 [我的] - [个人资料] - [选择头像] 即可更换用户头像。',
+      },
+      {
+        title: '如何修改登录密码?',
+        content: '请点击 [我的] - [账号安全] - [修改密码] 即可修改登录密码。',
+      },
+      {
+        title: '如何切换用户?',
+        content: '请先退出当前账号,然后使用其他账号重新登录即可。',
+      },
+    ],
+  },
+]

+ 98 - 0
src/pages/user/faq/index.vue

@@ -0,0 +1,98 @@
+<template>
+  <view class="min-h-screen flex flex-col bg-white">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="常见问题"
+      left-arrow
+      placeholder
+      safe-area-inset-top
+      fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 搜索框 -->
+    <wd-search
+      v-model="searchValue"
+      placeholder="请输入你想咨询的问题"
+      placeholder-left
+      hide-cancel
+      light
+    />
+
+    <!-- FAQ Tabs -->
+    <wd-tabs v-model="activeTab" auto-line-width>
+      <wd-tab
+        v-for="(category, index) in faqList"
+        :key="index"
+        :title="category.title"
+        :name="index"
+      >
+        <view class="min-h-[calc(100vh-300rpx)] bg-white">
+          <wd-collapse v-model="activeNames" custom-class="faq-collapse">
+            <wd-collapse-item
+              v-for="(item, idx) in filteredList(category.childList)"
+              :key="idx"
+              :name="`${index}-${idx}`"
+            >
+              <template #title>
+                <view class="flex items-center">
+                  <wd-icon name="edit-outline" size="18px" color="#1890ff" class="mr-16rpx" />
+                  <text>{{ item.title }}</text>
+                </view>
+              </template>
+              <view class="text-28rpx text-gray-500 leading-relaxed">
+                {{ item.content }}
+              </view>
+            </wd-collapse-item>
+          </wd-collapse>
+        </view>
+      </wd-tab>
+    </wd-tabs>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { FaqItem } from './data'
+import { ref } from 'vue'
+import { faqList } from './data'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const activeTab = ref<number>(0) // 当前选中的 Tab
+const searchValue = ref('') // 搜索关键词
+const activeNames = ref<string[]>([]) // 展开的问题
+
+/** 过滤问题列表 */
+function filteredList(list: FaqItem[]) {
+  if (!searchValue.value) {
+    return list
+  }
+  return list.filter(item =>
+    item.title.includes(searchValue.value) || item.content.includes(searchValue.value),
+  )
+}
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.faq-collapse) {
+  background: #fff;
+
+  .wd-collapse-item__header {
+    padding: 24rpx;
+  }
+
+  .wd-collapse-item__wrapper {
+    background: #f9fafb;
+  }
+}
+</style>

+ 205 - 0
src/pages/user/feedback/index.vue

@@ -0,0 +1,205 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="意见反馈"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view class="p-24rpx">
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group custom-class="cell-group" border>
+          <wd-textarea
+            v-model="formData.content"
+            label="反馈内容"
+            label-width="180rpx"
+            prop="content"
+            placeholder="请输入您的宝贵意见和建议"
+            :maxlength="500"
+            show-word-limit
+            clearable
+            :rows="5"
+          />
+          <wd-cell title="反馈图片" title-width="180rpx">
+          </wd-cell>
+          <!-- TODO @芋艿:图片上传的接入 -->
+          <view class="px-24rpx pb-24rpx">
+            <wd-upload
+              v-model:file-list="fileList"
+              :upload-method="customUpload"
+              accept="image"
+              multiple
+              :limit="9"
+            />
+          </view>
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部提交按钮 -->
+    <view class="safe-area-inset-bottom fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        提交反馈
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { UploadFile, UploadMethod } from 'wot-design-uni/components/wd-upload/types'
+import { ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getEnvBaseUrl } from '@/utils/index'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formLoading = ref(false)
+const formRef = ref()
+const fileList = ref<UploadFile[]>([])
+
+const formData = ref({
+  content: '',
+}) // 表单数据
+const formRules = {
+  content: [
+    { required: true, message: '请输入反馈内容' },
+    {
+      required: true,
+      validator: (value: string) => value.length >= 10,
+      message: '反馈内容至少10个字符',
+    },
+  ],
+} // 表单校验规则
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 自定义上传方法 */
+const customUpload: UploadMethod = (file, formData, options) => {
+  const uploadTask = uni.uploadFile({
+    url: `${getEnvBaseUrl()}/infra/file/upload`,
+    header: {
+      ...options.header,
+    },
+    name: options.name,
+    fileType: options.fileType,
+    formData,
+    filePath: file.url,
+    success(res) {
+      if (res.statusCode === options.statusCode) {
+        options.onSuccess(res, file, formData)
+      }
+      else {
+        options.onError({ ...res, errMsg: res.errMsg || '' }, file, formData)
+      }
+    },
+    fail(err) {
+      options.onError(err, file, formData)
+    },
+  })
+  uploadTask.onProgressUpdate((res) => {
+    options.onProgress(res, file)
+  })
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    // 构建提交数据
+    const submitData = {
+      content: formData.value.content,
+      // 提取已上传成功的图片 URL
+      images: fileList.value
+        .filter(file => file.status === 'success')
+        .map((file) => {
+          // 尝试从响应中解析 URL
+          if (file.response) {
+            try {
+              const res = typeof file.response === 'string' ? JSON.parse(file.response) : file.response
+              return res.data || file.url
+            }
+            catch {
+              return file.url
+            }
+          }
+          return file.url
+        }),
+    }
+
+    // TODO: 替换为真实 API 调用
+    await mockSubmitFeedback(submitData)
+
+    toast.success('提交成功,感谢您的反馈!')
+    setTimeout(() => {
+      handleBack()
+    }, 1500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// ===================== Mock API =====================
+// TODO: 后端 API 实现后,删除此 mock 函数,替换为真实 API 调用
+
+interface FeedbackData {
+  content: string
+  images: string[]
+}
+
+/**
+ * Mock 提交反馈接口
+ *
+ * @param data 反馈数据
+ */
+function mockSubmitFeedback(data: FeedbackData): Promise<{ code: number, message: string }> {
+  return new Promise((resolve, reject) => {
+    console.log('[Mock] 提交反馈数据:', data)
+
+    // 模拟网络延迟
+    setTimeout(() => {
+      // 模拟成功
+      if (data.content && data.content.length >= 10) {
+        resolve({
+          code: 0,
+          message: '提交成功',
+        })
+      } else {
+        reject(new Error('反馈内容不能少于 10 个字符'))
+      }
+    }, 1000)
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+
+.safe-area-inset-bottom {
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+}
+</style>

+ 176 - 0
src/pages/user/index.vue

@@ -0,0 +1,176 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部背景区域 -->
+    <view class="header-bg h-120rpx w-full flex items-center justify-center" />
+
+    <!-- 用户信息卡片 -->
+    <view class="relative mx-24rpx -mt-60rpx">
+      <view
+        class="user-card flex items-center rounded-12rpx bg-white p-32rpx"
+        @click="handleGoProfile"
+      >
+        <view class="avatar-wrapper mr-24rpx h-120rpx w-120rpx overflow-hidden rounded-full">
+          <image
+            :src="userInfo.avatar"
+            mode="aspectFill"
+            class="h-full w-full"
+          />
+        </view>
+        <view class="flex-1">
+          <view class="mb-8rpx text-40rpx text-[#323333] font-semibold">
+            {{ userInfo.nickname || userInfo.username }}
+          </view>
+          <view class="text-30rpx text-[#777]">
+            {{ userProfile ? (userProfile.dept?.name || '暂无部门') : '' }}
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 菜单区域 -->
+    <view class="mx-24rpx mt-32rpx">
+      <wd-cell-group custom-class="menu-group" border>
+        <wd-cell title="个人资料" is-link @click="handleGoProfile">
+          <template #icon>
+            <wd-icon name="user" size="20px" color="#1890ff" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+        <wd-cell title="账号安全" is-link @click="handleGoSecurity">
+          <template #icon>
+            <wd-icon name="lock-on" size="20px" color="#52c41a" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+      </wd-cell-group>
+      <wd-cell-group custom-class="menu-group mt-24rpx" border>
+        <wd-cell title="常见问题" is-link @click="handleGoFaq">
+          <template #icon>
+            <wd-icon name="warning" size="20px" color="#faad14" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+        <wd-cell title="意见反馈" is-link @click="handleGoFeedback">
+          <template #icon>
+            <wd-icon name="edit" size="20px" color="#722ed1" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+        <wd-cell title="联系客服" is-link @click="handleGoContact">
+          <template #icon>
+            <wd-icon name="phone" size="20px" color="#13c2c2" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+        <wd-cell title="应用设置" is-link @click="handleGoSettings">
+          <template #icon>
+            <wd-icon name="setting" size="20px" color="#1890ff" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+      </wd-cell-group>
+      <view class="mt-48rpx">
+        <wd-button block type="error" @click="handleLogout">
+          退出登录
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { UserProfileVO } from '@/api/system/user/profile'
+import { storeToRefs } from 'pinia'
+import { onMounted, ref } from 'vue'
+import { getUserProfile } from '@/api/system/user/profile'
+import { LOGIN_PAGE } from '@/router/config'
+import { useUserStore } from '@/store'
+import { useTokenStore } from '@/store/token'
+
+definePage({
+  style: {
+    navigationStyle: 'custom',
+  },
+})
+
+const userStore = useUserStore()
+const tokenStore = useTokenStore()
+const { userInfo } = storeToRefs(userStore)
+const userProfile = ref<UserProfileVO | null>(null) // 用户详细信息
+
+/** 页面加载时获取用户信息 */
+onMounted(async () => {
+  userProfile.value = await getUserProfile()
+  await userStore.fetchUserInfo()
+})
+
+/** 跳转到个人资料 */
+function handleGoProfile() {
+  uni.navigateTo({ url: '/pages/user/profile/index' })
+}
+
+/** 跳转到账号安全 */
+function handleGoSecurity() {
+  uni.navigateTo({ url: '/pages/user/security/index' })
+}
+
+/** 跳转到常见问题 */
+function handleGoFaq() {
+  uni.navigateTo({ url: '/pages/user/faq/index' })
+}
+
+/** 跳转到意见反馈 */
+function handleGoFeedback() {
+  uni.navigateTo({ url: '/pages/user/feedback/index' })
+}
+
+/** 跳转联系客服 */
+function handleGoContact() {
+  uni.navigateTo({ url: '/pages/user/contact/index' })
+}
+
+/** 跳转到应用设置 */
+function handleGoSettings() {
+  uni.navigateTo({ url: '/pages/user/settings/index' })
+}
+
+/** 退出登录 */
+function handleLogout() {
+  uni.showModal({
+    title: '提示',
+    content: '确定要退出登录吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      await tokenStore.logout()
+      uni.showToast({
+        title: '退出登录成功',
+        icon: 'success',
+      })
+      setTimeout(() => {
+        uni.reLaunch({ url: LOGIN_PAGE })
+      }, 500)
+    },
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+// 顶部渐变背景
+.header-bg {
+  background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
+}
+
+// 用户卡片阴影
+.user-card {
+  box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
+}
+
+// 头像边框
+.avatar-wrapper {
+  border: 4rpx solid #f5f5f5;
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+
+// 菜单组样式
+:deep(.menu-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+</style>

+ 149 - 0
src/pages/user/profile/components/form.vue

@@ -0,0 +1,149 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="bottom"
+    custom-style="border-radius: 24rpx 24rpx 0 0;"
+    safe-area-inset-bottom
+    @close="handleClose"
+  >
+    <view class="p-32rpx">
+      <view class="mb-32rpx text-center text-32rpx text-[#333] font-semibold">
+        {{ title }}
+      </view>
+      <!-- 昵称输入 -->
+      <template v-if="field === 'nickname'">
+        <wd-input
+          v-model="formValue"
+          placeholder="请输入昵称"
+          clearable
+          :focus="visible"
+        />
+      </template>
+      <!-- 性别选择 -->
+      <template v-else-if="field === 'sex'">
+        <wd-radio-group v-model="formValue" cell>
+          <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" :key="dict.value" :value="dict.value">
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </template>
+      <!-- 手机输入 -->
+      <template v-else-if="field === 'mobile'">
+        <wd-input
+          v-model="formValue"
+          placeholder="请输入手机号"
+          type="number"
+          clearable
+          :focus="visible"
+          :maxlength="11"
+        />
+      </template>
+      <!-- 邮箱输入 -->
+      <template v-else-if="field === 'email'">
+        <wd-input
+          v-model="formValue"
+          placeholder="请输入邮箱"
+          clearable
+          :focus="visible"
+        />
+      </template>
+      <!-- 按钮 -->
+      <view class="mt-30rpx">
+        <wd-button block type="primary" :loading="submitting" @click="handleConfirm">
+          确定
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { updateUserProfile } from '@/api/system/user/profile'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { DICT_TYPE } from '@/utils/constants'
+import { isBlank, isEmail, isMobile } from '@/utils/validator'
+
+const props = defineProps<{
+  modelValue: boolean
+  field: 'nickname' | 'sex' | 'mobile' | 'email'
+  value: string | number
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'success': []
+}>()
+
+const toast = useToast()
+const visible = computed({
+  get: () => props.modelValue,
+  set: val => emit('update:modelValue', val),
+})
+const formValue = ref<string | number>('') // 表单值
+const submitting = ref(false) // 提交中状态
+
+const title = computed(() => {
+  switch (props.field) {
+    case 'nickname':
+      return '修改昵称'
+    case 'sex':
+      return '修改性别'
+    case 'mobile':
+      return '修改手机'
+    case 'email':
+      return '修改邮箱'
+    default:
+      return '修改'
+  }
+})
+
+/** 监听弹窗打开,初始化值 */
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) {
+      formValue.value = props.value
+    }
+  },
+)
+
+/** 处理关闭 */
+function handleClose() {
+  visible.value = false
+}
+
+/** 处理确认 */
+async function handleConfirm() {
+  // 参数校验
+  if (props.field === 'sex' && !formValue.value) {
+    toast.warning('请选择性别')
+    return
+  }
+  if (props.field !== 'sex' && isBlank(formValue.value as string)) {
+    toast.warning(`请输入${title.value.replace('修改', '')}`)
+    return
+  }
+  if (props.field === 'mobile' && !isMobile(formValue.value as string)) {
+    toast.warning('请输入正确的手机号')
+    return
+  }
+  if (props.field === 'email' && !isEmail(formValue.value as string)) {
+    toast.warning('请输入正确的邮箱')
+    return
+  }
+
+  // 调用更新接口
+  submitting.value = true
+  try {
+    await updateUserProfile({ [props.field]: formValue.value })
+    toast.success('修改成功')
+    handleClose()
+    emit('success')
+  }
+  finally {
+    submitting.value = false
+  }
+}
+</script>

+ 138 - 0
src/pages/user/profile/index.vue

@@ -0,0 +1,138 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="个人资料"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 信息区域 -->
+    <wd-cell-group custom-class="cell-group" border>
+      <wd-cell title="头像" is-link center @click="handleEditAvatar">
+        <view class="ml-auto h-50rpx w-50rpx overflow-hidden rounded-full">
+          <image :src="userProfile?.avatar" mode="aspectFill" class="h-full w-full" />
+        </view>
+      </wd-cell>
+      <wd-cell title="昵称" :value="userProfile?.nickname || '-'" is-link @click="handleEdit('nickname')" />
+      <wd-cell title="性别" :value="getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, userProfile?.sex) || '-'" is-link @click="handleEdit('sex')" />
+      <wd-cell title="手机" :value="userProfile?.mobile || '-'" is-link @click="handleEdit('mobile')" />
+      <wd-cell title="邮箱" :value="userProfile?.email || '-'" is-link @click="handleEdit('email')" />
+    </wd-cell-group>
+    <wd-cell-group custom-class="cell-group mt-24rpx" border>
+      <wd-cell title="部门" :value="userProfile?.dept?.name || '-'" />
+      <wd-cell title="岗位" :value="userProfile?.posts?.map(p => p.name).join('、') || '-'" />
+      <wd-cell title="角色" :value="userProfile?.roles?.map(r => r.name).join('、') || '-'" />
+    </wd-cell-group>
+
+    <!-- 头像裁剪 -->
+    <wd-img-cropper
+      v-model="showCropper"
+      :img-src="cropperSrc"
+      @confirm="handleCropperConfirm"
+    />
+    <!-- 编辑弹窗 -->
+    <Form
+      v-model="formVisible"
+      :field="formType"
+      :value="formValue"
+      @success="loadUserProfile"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { UserProfileVO } from '@/api/system/user/profile'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getUserProfile, updateUserProfile } from '@/api/system/user/profile'
+import { getDictLabel } from '@/hooks/useDict'
+import { useUserStore } from '@/store/user'
+import { DICT_TYPE } from '@/utils/constants'
+import { uploadFileFromPath } from '@/utils/uploadFile'
+import Form from './components/form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const userStore = useUserStore()
+const loading = ref(true)
+const userProfile = ref<UserProfileVO | null>(null)
+
+// 头像裁剪相关
+const showCropper = ref(false)
+const cropperSrc = ref('')
+
+// 编辑弹窗相关
+const formVisible = ref(false)
+const formType = ref<'nickname' | 'sex' | 'mobile' | 'email'>('nickname')
+const formValue = ref<string | number>('')
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 加载用户信息 */
+async function loadUserProfile() {
+  loading.value = true
+  try {
+    userProfile.value = await getUserProfile()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 编辑头像 */
+function handleEditAvatar() {
+  uni.chooseImage({
+    count: 1,
+    success: (res) => {
+      cropperSrc.value = res.tempFilePaths[0]
+      showCropper.value = true
+    },
+  })
+}
+
+/** 头像裁剪确认 */
+async function handleCropperConfirm(event: { tempFilePath: string }) {
+  // 1.1 上传文件,获取 URL
+  const avatarUrl = await uploadFileFromPath(event.tempFilePath, 'avatar')
+  // 1.2 更新用户头像
+  await updateUserProfile({ avatar: avatarUrl })
+
+  // 2.1 直接更新本地状态,避免重新加载
+  if (userProfile.value) {
+    userProfile.value.avatar = avatarUrl
+  }
+  // 2.2 同步更新 userStore 中的头像
+  userStore.setUserAvatar(avatarUrl)
+  toast.success('头像修改成功')
+}
+
+/** 编辑字段 */
+function handleEdit(field: 'nickname' | 'sex' | 'mobile' | 'email') {
+  formType.value = field
+  formValue.value = userProfile.value?.[field] ?? (field === 'sex' ? 1 : '')
+  formVisible.value = true
+}
+
+/** 初始化 */
+onMounted(() => {
+  loadUserProfile()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  margin: 24rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+</style>

+ 122 - 0
src/pages/user/security/components/password-form.vue

@@ -0,0 +1,122 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="bottom"
+    custom-style="border-radius: 24rpx 24rpx 0 0;"
+    safe-area-inset-bottom
+    @close="handleClose"
+  >
+    <view class="p-32rpx">
+      <view class="mb-32rpx text-center text-32rpx text-[#333] font-semibold">
+        修改密码
+      </view>
+      <wd-input
+        v-model="formData.oldPassword"
+        label="旧密码"
+        placeholder="请输入旧密码"
+        show-password
+        clearable
+      />
+      <wd-input
+        v-model="formData.newPassword"
+        label="新密码"
+        placeholder="请输入新密码"
+        show-password
+        clearable
+      />
+      <wd-input
+        v-model="formData.confirmPassword"
+        label="确认密码"
+        placeholder="请再次输入新密码"
+        show-password
+        clearable
+      />
+      <view class="mt-30rpx">
+        <wd-button block type="primary" :loading="submitting" @click="handleConfirm">
+          确定
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { updateUserPassword } from '@/api/system/user/profile'
+import { isBlank } from '@/utils/validator'
+
+const props = defineProps<{
+  modelValue: boolean
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  'success': []
+}>()
+
+const toast = useToast()
+const visible = computed({
+  get: () => props.modelValue,
+  set: val => emit('update:modelValue', val),
+})
+
+const formData = reactive({
+  oldPassword: '',
+  newPassword: '',
+  confirmPassword: '',
+})
+const submitting = ref(false)
+
+/** 监听弹窗打开,重置表单 */
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) {
+      formData.oldPassword = ''
+      formData.newPassword = ''
+      formData.confirmPassword = ''
+    }
+  },
+)
+
+/** 处理关闭 */
+function handleClose() {
+  visible.value = false
+}
+
+/** 处理确认 */
+async function handleConfirm() {
+  // 参数校验
+  if (isBlank(formData.oldPassword)) {
+    toast.warning('请输入旧密码')
+    return
+  }
+  if (isBlank(formData.newPassword)) {
+    toast.warning('请输入新密码')
+    return
+  }
+  if (isBlank(formData.confirmPassword)) {
+    toast.warning('请确认新密码')
+    return
+  }
+  if (formData.newPassword !== formData.confirmPassword) {
+    toast.warning('两次输入的密码不一致')
+    return
+  }
+
+  // 调用更新接口
+  submitting.value = true
+  try {
+    await updateUserPassword({
+      oldPassword: formData.oldPassword,
+      newPassword: formData.newPassword,
+    })
+    toast.success('密码修改成功')
+    handleClose()
+    emit('success')
+  } finally {
+    submitting.value = false
+  }
+}
+</script>

+ 83 - 0
src/pages/user/security/index.vue

@@ -0,0 +1,83 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="账号安全"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 安全设置区域 -->
+    <wd-cell-group custom-class="cell-group" border>
+      <wd-cell title="修改密码" is-link @click="handleChangePassword">
+        <template #icon>
+          <wd-icon name="lock-on" size="20px" color="#1890ff" class="mr-16rpx" />
+        </template>
+      </wd-cell>
+    </wd-cell-group>
+
+    <!-- 第三方绑定区域 -->
+    <wd-cell-group custom-class="cell-group mt-24rpx" border>
+      <wd-cell title="微信小程序" is-link @click="handleBindWechatMiniProgram">
+        <template #icon>
+          <wd-icon name="chat" size="20px" color="#07c160" class="mr-16rpx" />
+        </template>
+        <view class="text-[#999]">未绑定</view>
+      </wd-cell>
+      <wd-cell title="微信公众号" is-link @click="handleBindWechatOfficialAccount">
+        <template #icon>
+          <wd-icon name="chat" size="20px" color="#07c160" class="mr-16rpx" />
+        </template>
+        <view class="text-[#999]">未绑定</view>
+      </wd-cell>
+    </wd-cell-group>
+
+    <!-- 修改密码弹窗 -->
+    <PasswordForm v-model="showPasswordPopup" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import PasswordForm from './components/password-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const showPasswordPopup = ref(false) // 密码弹窗相关
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 打开修改密码弹窗 */
+function handleChangePassword() {
+  showPasswordPopup.value = true
+}
+
+/** 绑定微信小程序 */
+function handleBindWechatMiniProgram() {
+  toast.info('正在开发中')
+}
+
+/** 绑定微信公众号 */
+function handleBindWechatOfficialAccount() {
+  toast.info('正在开发中')
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  margin: 24rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+</style>

+ 122 - 0
src/pages/user/settings/agreement/index.vue

@@ -0,0 +1,122 @@
+<template>
+  <view class="min-h-screen bg-white">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="用户协议"
+      left-arrow
+      placeholder
+      safe-area-inset-top
+      fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 协议内容 -->
+    <view class="p-32rpx">
+      <view class="mb-40rpx text-center">
+        <text class="text-36rpx font-bold text-gray-800">用户服务协议</text>
+      </view>
+
+      <view class="text-28rpx leading-relaxed text-gray-600">
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">一、总则</text>
+          <text class="block">
+            1.1 欢迎使用芋道移动端应用(以下简称"本应用")。在使用本应用前,请您仔细阅读本协议。
+          </text>
+          <text class="mt-16rpx block">
+            1.2 您在使用本应用时,即表示您已充分阅读、理解并接受本协议的全部内容,并与本应用达成协议。
+          </text>
+          <text class="mt-16rpx block">
+            1.3 本应用有权根据需要不时地修订本协议,修订后的协议一经公布即生效。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">二、服务内容</text>
+          <text class="block">
+            2.1 本应用为用户提供企业管理、数据分析、业务处理等相关服务。
+          </text>
+          <text class="mt-16rpx block">
+            2.2 本应用保留随时变更、中断或终止部分或全部服务的权利。
+          </text>
+          <text class="mt-16rpx block">
+            2.3 用户理解,本应用仅提供相关的网络服务,除此之外与相关网络服务有关的设备及所需的费用均应由用户自行承担。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">三、用户账号</text>
+          <text class="block">
+            3.1 用户在使用本应用服务前需要注册账号。用户应当对其账号和密码的安全负责。
+          </text>
+          <text class="mt-16rpx block">
+            3.2 用户不得将账号转让、出借或以任何方式提供给他人使用。
+          </text>
+          <text class="mt-16rpx block">
+            3.3 如发现账号被盗用或存在安全漏洞,用户应立即通知本应用。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">四、用户行为规范</text>
+          <text class="block">
+            4.1 用户在使用本应用服务时,必须遵守国家法律法规,不得利用本应用从事违法违规活动。
+          </text>
+          <text class="mt-16rpx block">
+            4.2 用户不得干扰本应用的正常运行,不得侵入本应用及国家计算机信息系统。
+          </text>
+          <text class="mt-16rpx block">
+            4.3 用户不得传播违法、有害、骚扰、侵害他人隐私等信息。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">五、知识产权</text>
+          <text class="block">
+            5.1 本应用的所有内容,包括但不限于文字、图片、音频、视频、软件、程序、版面设计等均受知识产权法律法规保护。
+          </text>
+          <text class="mt-16rpx block">
+            5.2 未经本应用书面许可,任何人不得擅自使用、复制、修改、传播本应用的任何内容。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">六、免责声明</text>
+          <text class="block">
+            6.1 本应用不对因网络状况、通讯线路等任何技术原因导致的服务中断或其他缺陷承担责任。
+          </text>
+          <text class="mt-16rpx block">
+            6.2 用户因使用本应用而产生的任何损失,本应用不承担任何责任。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">七、其他</text>
+          <text class="block">
+            7.1 本协议的订立、执行和解释及争议的解决均应适用中华人民共和国法律。
+          </text>
+          <text class="mt-16rpx block">
+            7.2 如本协议中的任何条款无论因何种原因完全或部分无效或不具有执行力,本协议的其余条款仍应有效并且有约束力。
+          </text>
+        </view>
+
+        <view class="mt-48rpx text-center text-24rpx text-gray-400">
+          <text>最后更新日期:2025 年 1 月 1日</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+</script>

+ 148 - 0
src/pages/user/settings/index.vue

@@ -0,0 +1,148 @@
+<template>
+  <view class="min-h-screen bg-[#f5f5f5]">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="应用设置"
+      left-arrow
+      placeholder
+      safe-area-inset-top
+      fixed
+      @click-left="handleBack"
+    />
+
+    <!-- Logo 区域 -->
+    <view class="flex flex-col items-center py-60rpx">
+      <image
+        class="mb-24rpx h-150rpx w-150rpx rounded-full"
+        src="/static/logo.svg"
+        mode="aspectFit"
+      />
+      <text class="text-40rpx text-gray-800 font-medium">芋道移动端</text>
+    </view>
+
+    <!-- 设置列表 -->
+    <view class="mx-24rpx">
+      <wd-cell-group custom-class="cell-group" border>
+        <wd-cell
+          title="当前版本"
+          :value="`v${version}`"
+          is-link
+          @click="handleShowVersion"
+        >
+          <template #icon>
+            <wd-icon name="warning" size="20px" color="#1890ff" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+        <wd-cell
+          title="本地缓存"
+          :value="storageSize"
+          is-link
+          @click="handleClearCache"
+        >
+          <template #icon>
+            <wd-icon name="delete" size="20px" color="#faad14" class="mr-16rpx" />
+          </template>
+        </wd-cell>
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部协议和版权 -->
+    <view class="mt-80rpx flex flex-col items-center">
+      <view class="mb-40rpx flex items-center text-26rpx">
+        <text class="text-[#1890ff]" @click="handleGoAgreement">《用户协议》</text>
+        <text class="text-gray-500">与</text>
+        <text class="text-[#1890ff]" @click="handleGoPrivacy">《隐私协议》</text>
+      </view>
+      <text class="mb-10rpx text-24rpx text-gray-400">
+        Copyright © 2026 iocoder.cn All Rights Reserved.
+      </text>
+      <text class="text-24rpx text-gray-400">
+        芋道源码
+      </text>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const version = ref('1.0.0') // 当前版本号
+const storageSize = ref('') // 本地缓存大小
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+
+/** 获取应用版本号 */
+function getAppVersion() {
+  // #ifdef APP-PLUS
+  const appInfo = uni.getSystemInfoSync()
+  version.value = appInfo.appVersion || '1.0.0'
+  // #endif
+}
+
+/** 获取本地缓存大小 */
+function getStorageSize() {
+  const info = uni.getStorageInfoSync()
+  storageSize.value = `${info.currentSize}KB`
+}
+
+/** 显示版本信息 */
+function handleShowVersion() {
+  toast.info(`当前版本:v${version.value}`)
+}
+
+/** 清除缓存 */
+function handleClearCache() {
+  uni.showModal({
+    title: '提示',
+    content: '确定要清除本地缓存吗?',
+    success: (res) => {
+      if (!res.confirm) {
+        return
+      }
+      try {
+        uni.clearStorageSync()
+        getStorageSize()
+        toast.success('缓存清除成功')
+      } catch {
+        toast.error('缓存清除失败')
+      }
+    },
+  })
+}
+
+/** 跳转到用户协议 */
+function handleGoAgreement() {
+  uni.navigateTo({ url: '/pages/user/settings/agreement/index' })
+}
+
+/** 跳转到隐私协议 */
+function handleGoPrivacy() {
+  uni.navigateTo({ url: '/pages/user/settings/privacy/index' })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getStorageSize()
+  getAppVersion()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.cell-group) {
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
+}
+</style>

+ 157 - 0
src/pages/user/settings/privacy/index.vue

@@ -0,0 +1,157 @@
+<template>
+  <view class="min-h-screen bg-white">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="隐私协议"
+      left-arrow
+      placeholder
+      safe-area-inset-top
+      fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 协议内容 -->
+    <view class="p-32rpx">
+      <view class="mb-40rpx text-center">
+        <text class="text-36rpx font-bold text-gray-800">隐私保护政策</text>
+      </view>
+
+      <view class="text-28rpx leading-relaxed text-gray-600">
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">引言</text>
+          <text class="block">
+            芋道移动端(以下简称"我们")非常重视用户的隐私和个人信息保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用我们的服务前,仔细阅读并理解本隐私政策。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">一、我们收集的信息</text>
+          <text class="block">
+            1.1 账号信息:当您注册账号时,我们会收集您的手机号码、邮箱地址等信息。
+          </text>
+          <text class="mt-16rpx block">
+            1.2 个人资料:您可以选择填写昵称、头像、性别、生日等个人资料信息。
+          </text>
+          <text class="mt-16rpx block">
+            1.3 设备信息:我们可能会收集您的设备型号、操作系统版本、设备标识符等信息。
+          </text>
+          <text class="mt-16rpx block">
+            1.4 日志信息:我们会收集您使用服务时的操作日志,包括访问时间、功能使用记录等。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">二、信息的使用</text>
+          <text class="block">
+            2.1 为您提供服务:我们使用收集的信息来提供、维护和改进我们的服务。
+          </text>
+          <text class="mt-16rpx block">
+            2.2 安全保障:我们使用信息来验证身份、预防欺诈和保护账号安全。
+          </text>
+          <text class="mt-16rpx block">
+            2.3 服务优化:我们可能使用信息来分析服务使用情况,以优化用户体验。
+          </text>
+          <text class="mt-16rpx block">
+            2.4 通知推送:我们可能向您发送服务通知、安全提醒等信息。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">三、信息的存储</text>
+          <text class="block">
+            3.1 我们会采取合理的安全措施来保护您的个人信息,防止未经授权的访问、使用或泄露。
+          </text>
+          <text class="mt-16rpx block">
+            3.2 您的个人信息将存储在中华人民共和国境内的服务器上。
+          </text>
+          <text class="mt-16rpx block">
+            3.3 我们仅在实现服务目的所必需的期限内保留您的个人信息。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">四、信息的共享</text>
+          <text class="block">
+            4.1 未经您的同意,我们不会向第三方共享您的个人信息,但以下情况除外:
+          </text>
+          <text class="mt-16rpx block">
+            - 为遵守法律法规或政府机关的要求;
+          </text>
+          <text class="mt-16rpx block">
+            - 为保护我们或用户的合法权益;
+          </text>
+          <text class="mt-16rpx block">
+            - 在涉及合并、收购或资产转让时,我们可能会转移您的信息。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">五、您的权利</text>
+          <text class="block">
+            5.1 访问权:您有权访问我们持有的关于您的个人信息。
+          </text>
+          <text class="mt-16rpx block">
+            5.2 更正权:如果您发现我们持有的个人信息不准确,您有权要求更正。
+          </text>
+          <text class="mt-16rpx block">
+            5.3 删除权:在特定情况下,您有权要求我们删除您的个人信息。
+          </text>
+          <text class="mt-16rpx block">
+            5.4 撤回同意:您可以随时撤回之前给予的同意。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">六、未成年人保护</text>
+          <text class="block">
+            6.1 我们非常重视对未成年人个人信息的保护。如果您是未满18周岁的未成年人,请在监护人的陪同下阅读本政策。
+          </text>
+          <text class="mt-16rpx block">
+            6.2 我们不会主动收集未成年人的个人信息。如果发现我们在未经监护人同意的情况下收集了未成年人的信息,我们将尽快删除相关信息。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">七、政策更新</text>
+          <text class="block">
+            7.1 我们可能会不时更新本隐私政策。更新后的政策将在应用内公布。
+          </text>
+          <text class="mt-16rpx block">
+            7.2 对于重大变更,我们会通过显著方式通知您。
+          </text>
+        </view>
+
+        <view class="mb-32rpx">
+          <text class="mb-16rpx block font-bold text-gray-800">八、联系我们</text>
+          <text class="block">
+            如果您对本隐私政策有任何疑问、意见或建议,请通过以下方式联系我们:
+          </text>
+          <text class="mt-16rpx block">
+            邮箱:7685413@qq.com
+          </text>
+          <text class="mt-16rpx block">
+            电话:400-999-9999
+          </text>
+        </view>
+
+        <view class="mt-48rpx text-center text-24rpx text-gray-400">
+          <text>最后更新日期:2025 年 1 月 1 日</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+/** 返回上一页 */
+function handleBack() {
+  uni.navigateBack()
+}
+</script>

+ 1 - 1
uno.config.ts

@@ -71,7 +71,7 @@ export default defineConfig({
     },
   ],
   // 动态图标需要在这里配置,或者写在vue页面中注释掉
-  safelist: ['i-carbon-code', 'i-carbon-home', 'i-carbon-user'],
+  safelist: ['i-carbon-code', 'i-carbon-home', 'i-carbon-user', 'i-carbon-document', 'i-carbon-chat', 'i-carbon-user-avatar'],
   rules: [
     [
       'p-safe',