Browse Source

feat:【system】【infra】新增租户、租户套餐、社交、OAuth2、参数配置、数据源配置等管理页面及相关 API 接口

YunaiV 4 tháng trước cách đây
mục cha
commit
0193a408c7
39 tập tin đã thay đổi với 4411 bổ sung0 xóa
  1. 45 0
      src/api/infra/config/index.ts
  2. 36 0
      src/api/infra/data-source-config/index.ts
  3. 48 0
      src/api/system/oauth2/client/index.ts
  4. 24 0
      src/api/system/oauth2/token/index.ts
  5. 41 0
      src/api/system/social/client/index.ts
  6. 28 0
      src/api/system/social/user/index.ts
  7. 42 0
      src/api/system/tenant-package/index.ts
  8. 46 0
      src/api/system/tenant/index.ts
  9. 169 0
      src/pages-infra/config/components/search-form.vue
  10. 133 0
      src/pages-infra/config/detail/index.vue
  11. 166 0
      src/pages-infra/config/form/index.vue
  12. 171 0
      src/pages-infra/config/index.vue
  13. 124 0
      src/pages-infra/data-source-config/detail/index.vue
  14. 68 0
      src/pages-infra/data-source-config/form/index.vue
  15. 122 0
      src/pages-infra/data-source-config/index.vue
  16. 133 0
      src/pages-system/oauth2/client/detail/index.vue
  17. 187 0
      src/pages-system/oauth2/client/form/index.vue
  18. 147 0
      src/pages-system/oauth2/components/client-list.vue
  19. 94 0
      src/pages-system/oauth2/components/client-search-form.vue
  20. 155 0
      src/pages-system/oauth2/components/token-list.vue
  21. 110 0
      src/pages-system/oauth2/components/token-search-form.vue
  22. 53 0
      src/pages-system/oauth2/index.vue
  23. 135 0
      src/pages-system/social/client/detail/index.vue
  24. 181 0
      src/pages-system/social/client/form/index.vue
  25. 143 0
      src/pages-system/social/components/client-list.vue
  26. 140 0
      src/pages-system/social/components/client-search-form.vue
  27. 121 0
      src/pages-system/social/components/user-list.vue
  28. 110 0
      src/pages-system/social/components/user-search-form.vue
  29. 53 0
      src/pages-system/social/index.vue
  30. 81 0
      src/pages-system/social/user/detail/index.vue
  31. 139 0
      src/pages-system/tenant/components/package-list.vue
  32. 153 0
      src/pages-system/tenant/components/package-search-form.vue
  33. 147 0
      src/pages-system/tenant/components/tenant-list.vue
  34. 185 0
      src/pages-system/tenant/components/tenant-search-form.vue
  35. 53 0
      src/pages-system/tenant/index.vue
  36. 127 0
      src/pages-system/tenant/package/detail/index.vue
  37. 139 0
      src/pages-system/tenant/package/form/index.vue
  38. 150 0
      src/pages-system/tenant/tenant/detail/index.vue
  39. 212 0
      src/pages-system/tenant/tenant/form/index.vue

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

@@ -0,0 +1,45 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 参数配置信息 */
+export interface Config {
+  id?: number
+  category: string
+  name: string
+  key: string
+  value: string
+  type: number
+  visible: boolean
+  remark: string
+  createTime?: Date
+}
+
+/** 获取参数配置分页列表 */
+export function getConfigPage(params: PageParam) {
+  return http.get<PageResult<Config>>('/infra/config/page', params)
+}
+
+/** 获取参数配置详情 */
+export function getConfig(id: number) {
+  return http.get<Config>(`/infra/config/get?id=${id}`)
+}
+
+/** 根据参数键名查询参数值 */
+export function getConfigKey(configKey: string) {
+  return http.get<string>(`/infra/config/get-value-by-key?key=${configKey}`)
+}
+
+/** 创建参数配置 */
+export function createConfig(data: Config) {
+  return http.post<number>('/infra/config/create', data)
+}
+
+/** 更新参数配置 */
+export function updateConfig(data: Config) {
+  return http.put<boolean>('/infra/config/update', data)
+}
+
+/** 删除参数配置 */
+export function deleteConfig(id: number) {
+  return http.delete<boolean>(`/infra/config/delete?id=${id}`)
+}

+ 36 - 0
src/api/infra/data-source-config/index.ts

@@ -0,0 +1,36 @@
+import { http } from '@/http/http'
+
+/** 数据源配置信息 */
+export interface DataSourceConfig {
+  id?: number
+  name: string
+  url: string
+  username: string
+  password: string
+  createTime?: Date
+}
+
+/** 获取数据源配置列表(无分页) */
+export function getDataSourceConfigList() {
+  return http.get<DataSourceConfig[]>('/infra/data-source-config/list')
+}
+
+/** 获取数据源配置详情 */
+export function getDataSourceConfig(id: number) {
+  return http.get<DataSourceConfig>(`/infra/data-source-config/get?id=${id}`)
+}
+
+/** 创建数据源配置 */
+export function createDataSourceConfig(data: DataSourceConfig) {
+  return http.post<number>('/infra/data-source-config/create', data)
+}
+
+/** 更新数据源配置 */
+export function updateDataSourceConfig(data: DataSourceConfig) {
+  return http.put<boolean>('/infra/data-source-config/update', data)
+}
+
+/** 删除数据源配置 */
+export function deleteDataSourceConfig(id: number) {
+  return http.delete<boolean>(`/infra/data-source-config/delete?id=${id}`)
+}

+ 48 - 0
src/api/system/oauth2/client/index.ts

@@ -0,0 +1,48 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** OAuth2.0 客户端信息 */
+export interface OAuth2Client {
+  id?: number
+  clientId: string
+  secret: string
+  name: string
+  logo: string
+  description: string
+  status: number
+  accessTokenValiditySeconds: number
+  refreshTokenValiditySeconds: number
+  redirectUris: string[]
+  autoApprove: boolean
+  authorizedGrantTypes: string[]
+  scopes: string[]
+  authorities: string[]
+  resourceIds: string[]
+  additionalInformation: string
+  createTime?: Date
+}
+
+/** 获取 OAuth2.0 客户端分页列表 */
+export function getOAuth2ClientPage(params: PageParam) {
+  return http.get<PageResult<OAuth2Client>>('/system/oauth2-client/page', params)
+}
+
+/** 获取 OAuth2.0 客户端详情 */
+export function getOAuth2Client(id: number) {
+  return http.get<OAuth2Client>(`/system/oauth2-client/get?id=${id}`)
+}
+
+/** 创建 OAuth2.0 客户端 */
+export function createOAuth2Client(data: OAuth2Client) {
+  return http.post<number>('/system/oauth2-client/create', data)
+}
+
+/** 更新 OAuth2.0 客户端 */
+export function updateOAuth2Client(data: OAuth2Client) {
+  return http.put<boolean>('/system/oauth2-client/update', data)
+}
+
+/** 删除 OAuth2.0 客户端 */
+export function deleteOAuth2Client(id: number) {
+  return http.delete<boolean>(`/system/oauth2-client/delete?id=${id}`)
+}

+ 24 - 0
src/api/system/oauth2/token/index.ts

@@ -0,0 +1,24 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** OAuth2.0 令牌信息 */
+export interface OAuth2Token {
+  id?: number
+  accessToken: string
+  refreshToken: string
+  userId: number
+  userType: number
+  clientId: string
+  createTime?: Date
+  expiresTime?: Date
+}
+
+/** 获取 OAuth2.0 令牌分页列表 */
+export function getOAuth2TokenPage(params: PageParam) {
+  return http.get<PageResult<OAuth2Token>>('/system/oauth2-token/page', params)
+}
+
+/** 删除 OAuth2.0 令牌 */
+export function deleteOAuth2Token(accessToken: string) {
+  return http.delete<boolean>(`/system/oauth2-token/delete?accessToken=${accessToken}`)
+}

+ 41 - 0
src/api/system/social/client/index.ts

@@ -0,0 +1,41 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 社交客户端信息 */
+export interface SocialClient {
+  id?: number
+  name: string
+  socialType: number
+  userType: number
+  clientId: string
+  clientSecret: string
+  agentId?: string
+  publicKey?: string
+  status: number
+  createTime?: Date
+}
+
+/** 获取社交客户端分页列表 */
+export function getSocialClientPage(params: PageParam) {
+  return http.get<PageResult<SocialClient>>('/system/social-client/page', params)
+}
+
+/** 获取社交客户端详情 */
+export function getSocialClient(id: number) {
+  return http.get<SocialClient>(`/system/social-client/get?id=${id}`)
+}
+
+/** 创建社交客户端 */
+export function createSocialClient(data: SocialClient) {
+  return http.post<number>('/system/social-client/create', data)
+}
+
+/** 更新社交客户端 */
+export function updateSocialClient(data: SocialClient) {
+  return http.put<boolean>('/system/social-client/update', data)
+}
+
+/** 删除社交客户端 */
+export function deleteSocialClient(id: number) {
+  return http.delete<boolean>(`/system/social-client/delete?id=${id}`)
+}

+ 28 - 0
src/api/system/social/user/index.ts

@@ -0,0 +1,28 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 社交用户信息 */
+export interface SocialUser {
+  id?: number
+  type: number
+  openid: string
+  token: string
+  rawTokenInfo: string
+  nickname: string
+  avatar: string
+  rawUserInfo: string
+  code: string
+  state: string
+  createTime?: Date
+  updateTime?: Date
+}
+
+/** 获取社交用户分页列表 */
+export function getSocialUserPage(params: PageParam) {
+  return http.get<PageResult<SocialUser>>('/system/social-user/page', params)
+}
+
+/** 获取社交用户详情 */
+export function getSocialUser(id: number) {
+  return http.get<SocialUser>(`/system/social-user/get?id=${id}`)
+}

+ 42 - 0
src/api/system/tenant-package/index.ts

@@ -0,0 +1,42 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 租户套餐信息 */
+export interface TenantPackage {
+  id?: number
+  name: string
+  status: number
+  remark: string
+  menuIds: number[]
+  createTime?: Date
+}
+
+/** 获取租户套餐分页列表 */
+export function getTenantPackagePage(params: PageParam) {
+  return http.get<PageResult<TenantPackage>>('/system/tenant-package/page', params)
+}
+
+/** 获取租户套餐精简信息列表 */
+export function getTenantPackageList() {
+  return http.get<TenantPackage[]>('/system/tenant-package/get-simple-list')
+}
+
+/** 获取租户套餐详情 */
+export function getTenantPackage(id: number) {
+  return http.get<TenantPackage>(`/system/tenant-package/get?id=${id}`)
+}
+
+/** 创建租户套餐 */
+export function createTenantPackage(data: TenantPackage) {
+  return http.post<number>('/system/tenant-package/create', data)
+}
+
+/** 更新租户套餐 */
+export function updateTenantPackage(data: TenantPackage) {
+  return http.put<boolean>('/system/tenant-package/update', data)
+}
+
+/** 删除租户套餐 */
+export function deleteTenantPackage(id: number) {
+  return http.delete<boolean>(`/system/tenant-package/delete?id=${id}`)
+}

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

@@ -0,0 +1,46 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 租户信息 */
+export interface Tenant {
+  id?: number
+  name: string
+  packageId: number
+  contactName: string
+  contactMobile: string
+  accountCount: number
+  expireTime: Date
+  websites: string[]
+  status: number
+  createTime?: Date
+}
+
+/** 获取租户分页列表 */
+export function getTenantPage(params: PageParam) {
+  return http.get<PageResult<Tenant>>('/system/tenant/page', params)
+}
+
+/** 获取租户精简信息列表 */
+export function getSimpleTenantList() {
+  return http.get<Tenant[]>('/system/tenant/simple-list')
+}
+
+/** 获取租户详情 */
+export function getTenant(id: number) {
+  return http.get<Tenant>(`/system/tenant/get?id=${id}`)
+}
+
+/** 创建租户 */
+export function createTenant(data: Tenant) {
+  return http.post<number>('/system/tenant/create', data)
+}
+
+/** 更新租户 */
+export function updateTenant(data: Tenant) {
+  return http.put<boolean>('/system/tenant/update', data)
+}
+
+/** 删除租户 */
+export function deleteTenant(id: number) {
+  return http.delete<boolean>(`/system/tenant/delete?id=${id}`)
+}

+ 169 - 0
src/pages-infra/config/components/search-form.vue

@@ -0,0 +1,169 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          参数名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入参数名称"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          参数键名
+        </view>
+        <wd-input
+          v-model="formData.key"
+          placeholder="请输入参数键名"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          系统内置
+        </view>
+        <wd-radio-group v-model="formData.type" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CONFIG_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          创建时间
+        </view>
+        <view class="yd-search-form-date-range-container">
+          <view class="flex-1" @click="visibleCreateTime[0] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          -
+          <view class="flex-1" @click="visibleCreateTime[1] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
+        <view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[0] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
+        <view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[1] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate, formatDateRange } from '@/utils/date'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  name: undefined as string | undefined,
+  key: undefined as string | undefined,
+  type: -1, // -1 表示全部
+  createTime: [undefined, undefined] as [number | undefined, number | undefined],
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`名称:${formData.name}`)
+  }
+  if (formData.key) {
+    conditions.push(`键名:${formData.key}`)
+  }
+  if (formData.type !== -1) {
+    conditions.push(`类型:${getDictLabel(DICT_TYPE.INFRA_CONFIG_TYPE, formData.type)}`)
+  }
+  if (formData.createTime?.[0] && formData.createTime?.[1]) {
+    conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索参数配置'
+})
+
+// 时间范围选择器状态
+const visibleCreateTime = ref<[boolean, boolean]>([false, false])
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 创建时间[0]确认 */
+function handleCreateTime0Confirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  visibleCreateTime.value[0] = false
+}
+
+/** 创建时间[1]确认 */
+function handleCreateTime1Confirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  visibleCreateTime.value[1] = false
+}
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    name: formData.name || undefined,
+    key: formData.key || undefined,
+    type: formData.type === -1 ? undefined : formData.type,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.key = undefined
+  formData.type = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 133 - 0
src/pages-infra/config/detail/index.vue

@@ -0,0 +1,133 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="参数配置详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="参数主键" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="参数分类" :value="String(formData?.category ?? '-')" />
+        <wd-cell title="参数名称" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="参数键名" :value="String(formData?.key ?? '-')" />
+        <wd-cell title="参数键值" :value="String(formData?.value ?? '-')" />
+        <wd-cell title="是否可见">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="formData?.visible" />
+        </wd-cell>
+        <wd-cell title="系统内置">
+          <dict-tag :type="DICT_TYPE.INFRA_CONFIG_TYPE" :value="formData?.type" />
+        </wd-cell>
+        <wd-cell title="备注" :value="String(formData?.remark ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['infra:config:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['infra:config:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Config } from '@/api/infra/config'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteConfig, getConfig } from '@/api/infra/config'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<Config>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-infra/config/index')
+}
+
+/** 加载参数配置详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getConfig(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑参数配置 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-infra/config/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 deleteConfig(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 166 - 0
src/pages-infra/config/form/index.vue

@@ -0,0 +1,166 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.category"
+            label="参数分类"
+            label-width="200rpx"
+            prop="category"
+            clearable
+            placeholder="请输入参数分类"
+          />
+          <wd-input
+            v-model="formData.name"
+            label="参数名称"
+            label-width="200rpx"
+            prop="name"
+            clearable
+            placeholder="请输入参数名称"
+          />
+          <wd-input
+            v-model="formData.key"
+            label="参数键名"
+            label-width="200rpx"
+            prop="key"
+            clearable
+            placeholder="请输入参数键名"
+          />
+          <wd-input
+            v-model="formData.value"
+            label="参数键值"
+            label-width="200rpx"
+            prop="value"
+            clearable
+            placeholder="请输入参数键值"
+          />
+          <wd-cell title="是否可见" title-width="200rpx" prop="visible" center>
+            <wd-radio-group v-model="formData.visible" shape="button">
+              <wd-radio :value="true">
+                是
+              </wd-radio>
+              <wd-radio :value="false">
+                否
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="200rpx"
+            prop="remark"
+            clearable
+            placeholder="请输入备注"
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="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 { Config } from '@/api/infra/config'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createConfig, getConfig, updateConfig } from '@/api/infra/config'
+import { navigateBackPlus } from '@/utils'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑参数配置' : '新增参数配置')
+const formLoading = ref(false)
+const formData = ref<Config>({
+  id: undefined,
+  category: '',
+  name: '',
+  key: '',
+  value: '',
+  type: 0,
+  visible: true,
+  remark: '',
+})
+const formRules = {
+  category: [{ required: true, message: '参数分类不能为空' }],
+  name: [{ required: true, message: '参数名称不能为空' }],
+  key: [{ required: true, message: '参数键名不能为空' }],
+  value: [{ required: true, message: '参数键值不能为空' }],
+  visible: [{ required: true, message: '是否可见不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-infra/config/index')
+}
+
+/** 加载参数配置详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getConfig(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateConfig(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createConfig(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 171 - 0
src/pages-infra/config/index.vue

@@ -0,0 +1,171 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="参数配置管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 搜索组件 -->
+    <SearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 参数配置列表 -->
+    <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.INFRA_CONFIG_TYPE" :value="item.type" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">参数分类:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.category || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">参数键名:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.key || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">参数键值:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.value || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">是否可见:</text>
+            <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="item.visible" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</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>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['infra:config:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Config } from '@/api/infra/config'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { onMounted, ref } from 'vue'
+import { getConfigPage } from '@/api/infra/config'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<Config[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+
+/** 查询参数配置列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getConfigPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增参数配置 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-infra/config/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: Config) {
+  uni.navigateTo({
+    url: `/pages-infra/config/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 124 - 0
src/pages-infra/data-source-config/detail/index.vue

@@ -0,0 +1,124 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="数据源配置详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="主键编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="数据源名称" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="数据源连接" :value="String(formData?.url ?? '-')" />
+        <wd-cell title="用户名" :value="String(formData?.username ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮(主数据源不可编辑/删除) -->
+    <view v-if="formData?.id !== 0" class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['infra:data-source-config:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['infra:data-source-config:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DataSourceConfig } from '@/api/infra/data-source-config'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteDataSourceConfig, getDataSourceConfig } from '@/api/infra/data-source-config'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<DataSourceConfig>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-infra/data-source-config/index')
+}
+
+/** 加载数据源配置详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getDataSourceConfig(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑数据源配置 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-infra/data-source-config/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 deleteDataSourceConfig(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 68 - 0
src/pages-infra/data-source-config/form/index.vue

@@ -0,0 +1,68 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.name"
+            label="数据源名称"
+            label-width="200rpx"
+            prop="name"
+            clearable
+            placeholder="请输入数据源名称"
+          />
+          <wd-input
+            v-model="formData.url"
+            label="数据源连接"
+            label-width="200rpx"
+            prop="url"
+            clearable
+            placeholder="请输入数据源连接"
+          />
+          <wd-input
+            v-model="formData.username"
+            label="用户名"
+            label-width="200rpx"
+            prop="username"
+            clearable
+            placeholder="请输入用户名"
+          />
+          <wd-input
+            v-model="formData.password"
+            label="密码"
+            label-width="200rpx"
+            prop="password"
+            type="password"
+            clearable
+            placeholder="请输入密码"
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="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 { DataSourceConfig } from '@/api/infra/data-source-config'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'

+ 122 - 0
src/pages-infra/data-source-config/index.vue

@@ -0,0 +1,122 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="数据源配置管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 数据源配置列表 -->
+    <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>
+            <view v-if="item.id === 0" class="rounded-4rpx bg-[#e6f7ff] px-12rpx py-4rpx text-24rpx text-[#1890ff]">
+              主数据源
+            </view>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">主键编号:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.id }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">数据源连接:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.url || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">用户名:</text>
+            <text>{{ item.username || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-if="!loading && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无数据源配置数据" />
+      </view>
+    </view>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['infra:data-source-config:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DataSourceConfig } from '@/api/infra/data-source-config'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getDataSourceConfigList } from '@/api/infra/data-source-config'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { formatDateTime } from '@/utils/date'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const list = ref<DataSourceConfig[]>([])
+const loading = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+
+/** 查询数据源配置列表 */
+async function getList() {
+  loading.value = true
+  try {
+    toast.loading('加载中...')
+    list.value = await getDataSourceConfigList()
+  } finally {
+    loading.value = false
+    toast.close()
+  }
+}
+
+/** 新增数据源配置 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-infra/data-source-config/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: DataSourceConfig) {
+  uni.navigateTo({
+    url: `/pages-infra/data-source-config/detail/index?id=${item.id}`,
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 133 - 0
src/pages-system/oauth2/client/detail/index.vue

@@ -0,0 +1,133 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="OAuth2 客户端详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="客户端编号" :value="String(formData?.clientId ?? '-')" />
+        <wd-cell title="客户端密钥" :value="String(formData?.secret ?? '-')" />
+        <wd-cell title="应用名" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="应用描述" :value="String(formData?.description ?? '-')" />
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="访问令牌有效期" :value="`${formData?.accessTokenValiditySeconds ?? '-'} 秒`" />
+        <wd-cell title="刷新令牌有效期" :value="`${formData?.refreshTokenValiditySeconds ?? '-'} 秒`" />
+        <wd-cell title="授权类型" :value="formData?.authorizedGrantTypes?.join(', ') || '-'" />
+        <wd-cell title="授权范围" :value="formData?.scopes?.join(', ') || '-'" />
+        <wd-cell title="可重定向 URI" :value="formData?.redirectUris?.join(', ') || '-'" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:oauth2-client:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:oauth2-client:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { OAuth2Client } from '@/api/system/oauth2/client'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteOAuth2Client, getOAuth2Client } from '@/api/system/oauth2/client'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<OAuth2Client>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/oauth2/index')
+}
+
+/** 加载 OAuth2 客户端详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getOAuth2Client(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑 OAuth2 客户端 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/oauth2/client/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除 OAuth2 客户端 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该 OAuth2 客户端吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteOAuth2Client(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 187 - 0
src/pages-system/oauth2/client/form/index.vue

@@ -0,0 +1,187 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.clientId"
+            label="客户端编号"
+            label-width="220rpx"
+            prop="clientId"
+            clearable
+            placeholder="请输入客户端编号"
+          />
+          <wd-input
+            v-model="formData.secret"
+            label="客户端密钥"
+            label-width="220rpx"
+            prop="secret"
+            clearable
+            placeholder="请输入客户端密钥"
+          />
+          <wd-input
+            v-model="formData.name"
+            label="应用名"
+            label-width="220rpx"
+            prop="name"
+            clearable
+            placeholder="请输入应用名"
+          />
+          <wd-textarea
+            v-model="formData.description"
+            label="应用描述"
+            label-width="220rpx"
+            prop="description"
+            clearable
+            placeholder="请输入应用描述"
+          />
+          <wd-cell title="状态" title-width="220rpx" prop="status" center>
+            <wd-radio-group v-model="formData.status" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-input
+            v-model="formData.accessTokenValiditySeconds"
+            label="访问令牌有效期"
+            label-width="220rpx"
+            prop="accessTokenValiditySeconds"
+            type="number"
+            clearable
+            placeholder="请输入访问令牌有效期(秒)"
+          />
+          <wd-input
+            v-model="formData.refreshTokenValiditySeconds"
+            label="刷新令牌有效期"
+            label-width="220rpx"
+            prop="refreshTokenValiditySeconds"
+            type="number"
+            clearable
+            placeholder="请输入刷新令牌有效期(秒)"
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="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 { OAuth2Client } from '@/api/system/oauth2/client'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createOAuth2Client, getOAuth2Client, updateOAuth2Client } from '@/api/system/oauth2/client'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑 OAuth2 客户端' : '新增 OAuth2 客户端')
+const formLoading = ref(false)
+const formData = ref<OAuth2Client>({
+  id: undefined,
+  clientId: '',
+  secret: '',
+  name: '',
+  logo: '',
+  description: '',
+  status: 0,
+  accessTokenValiditySeconds: 1800,
+  refreshTokenValiditySeconds: 43200,
+  redirectUris: [],
+  autoApprove: false,
+  authorizedGrantTypes: [],
+  scopes: [],
+  authorities: [],
+  resourceIds: [],
+  additionalInformation: '',
+})
+const formRules = {
+  clientId: [{ required: true, message: '客户端编号不能为空' }],
+  secret: [{ required: true, message: '客户端密钥不能为空' }],
+  name: [{ required: true, message: '应用名不能为空' }],
+  accessTokenValiditySeconds: [{ required: true, message: '访问令牌有效期不能为空' }],
+  refreshTokenValiditySeconds: [{ required: true, message: '刷新令牌有效期不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/oauth2/index')
+}
+
+/** 加载 OAuth2 客户端详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getOAuth2Client(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateOAuth2Client(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createOAuth2Client(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 147 - 0
src/pages-system/oauth2/components/client-list.vue

@@ -0,0 +1,147 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <ClientSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- OAuth2 客户端列表 -->
+    <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 shrink-0 text-[#999]">客户端编号:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.clientId || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">客户端密钥:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.secret || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">访问令牌有效期:</text>
+            <text>{{ item.accessTokenValiditySeconds }} 秒</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">刷新令牌有效期:</text>
+            <text>{{ item.refreshTokenValiditySeconds }} 秒</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无 OAuth2 客户端数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:oauth2-client:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { OAuth2Client } from '@/api/system/oauth2/client'
+import type { LoadMoreState } from '@/http/types'
+import { onMounted, ref } from 'vue'
+import { getOAuth2ClientPage } from '@/api/system/oauth2/client'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import ClientSearchForm from './client-search-form.vue'
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<OAuth2Client[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询 OAuth2 客户端列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getOAuth2ClientPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增 OAuth2 客户端 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/oauth2/client/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: OAuth2Client) {
+  uni.navigateTo({
+    url: `/pages-system/oauth2/client/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 94 - 0
src/pages-system/oauth2/components/client-search-form.vue

@@ -0,0 +1,94 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          应用名
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入应用名"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  name: undefined as string | undefined,
+  status: -1,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`应用名:${formData.name}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索 OAuth2 客户端'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    name: formData.name || undefined,
+    status: formData.status === -1 ? undefined : formData.status,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 155 - 0
src/pages-system/oauth2/components/token-list.vue

@@ -0,0 +1,155 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <TokenSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- OAuth2 令牌列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.accessToken"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-28rpx text-[#333] font-semibold">
+              用户编号: {{ item.userId }}
+            </view>
+            <dict-tag :type="DICT_TYPE.USER_TYPE" :value="item.userType" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">访问令牌:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.accessToken || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">刷新令牌:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.refreshToken || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">客户端编号:</text>
+            <text>{{ item.clientId || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">过期时间:</text>
+            <text>{{ formatDateTime(item.expiresTime) || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</text>
+          </view>
+          <!-- 删除按钮 -->
+          <view
+            v-if="hasAccessByCodes(['system:oauth2-token:delete'])"
+            class="mt-16rpx flex justify-end"
+          >
+            <wd-button size="small" type="error" @click="handleDelete(item)">
+              删除
+            </wd-button>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无 OAuth2 令牌数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { OAuth2Token } from '@/api/system/oauth2/token'
+import type { LoadMoreState } from '@/http/types'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteOAuth2Token, getOAuth2TokenPage } from '@/api/system/oauth2/token'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import TokenSearchForm from './token-search-form.vue'
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const total = ref(0)
+const list = ref<OAuth2Token[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询 OAuth2 令牌列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getOAuth2TokenPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 删除 OAuth2 令牌 */
+function handleDelete(item: OAuth2Token) {
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该令牌吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      try {
+        await deleteOAuth2Token(item.accessToken)
+        toast.success('删除成功')
+        // 刷新列表
+        handleQuery()
+      } catch {
+        // 错误处理
+      }
+    },
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 110 - 0
src/pages-system/oauth2/components/token-search-form.vue

@@ -0,0 +1,110 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          用户编号
+        </view>
+        <wd-input
+          v-model="formData.userId"
+          placeholder="请输入用户编号"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          用户类型
+        </view>
+        <wd-radio-group v-model="formData.userType" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          客户端编号
+        </view>
+        <wd-input
+          v-model="formData.clientId"
+          placeholder="请输入客户端编号"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  userId: undefined as string | undefined,
+  userType: -1,
+  clientId: undefined as string | undefined,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.userId) {
+    conditions.push(`用户编号:${formData.userId}`)
+  }
+  if (formData.userType !== -1) {
+    conditions.push(`类型:${getDictLabel(DICT_TYPE.USER_TYPE, formData.userType)}`)
+  }
+  if (formData.clientId) {
+    conditions.push(`客户端:${formData.clientId}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索 OAuth2 令牌'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    userId: formData.userId || undefined,
+    userType: formData.userType === -1 ? undefined : formData.userType,
+    clientId: formData.clientId || undefined,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.userId = undefined
+  formData.userType = -1
+  formData.clientId = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 53 - 0
src/pages-system/oauth2/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="OAuth2 管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- Tab 切换 -->
+    <view class="bg-white">
+      <wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
+        <wd-tab title="OAuth2 客户端" />
+        <wd-tab title="OAuth2 令牌" />
+      </wd-tabs>
+    </view>
+
+    <!-- 列表内容 -->
+    <ClientList v-show="tabType === 'client'" />
+    <TokenList v-show="tabType === 'token'" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { navigateBackPlus } from '@/utils'
+import ClientList from './components/client-list.vue'
+import TokenList from './components/token-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['client', 'token']
+const tabIndex = ref(0)
+const tabType = computed<string>(() => tabTypes[tabIndex.value])
+
+/** Tab 切换 */
+function handleTabChange({ index }: { index: number }) {
+  tabIndex.value = index
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 135 - 0
src/pages-system/social/client/detail/index.vue

@@ -0,0 +1,135 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="社交客户端详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="应用名" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="社交平台">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="formData?.socialType" />
+        </wd-cell>
+        <wd-cell title="用户类型">
+          <dict-tag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
+        </wd-cell>
+        <wd-cell title="客户端编号" :value="String(formData?.clientId ?? '-')" />
+        <wd-cell title="客户端密钥" :value="String(formData?.clientSecret ?? '-')" />
+        <wd-cell title="agentId" :value="String(formData?.agentId ?? '-')" />
+        <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="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:social-client:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:social-client:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SocialClient } from '@/api/system/social/client'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteSocialClient, getSocialClient } from '@/api/system/social/client'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<SocialClient>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/social/index')
+}
+
+/** 加载社交客户端详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getSocialClient(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑社交客户端 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/social/client/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 deleteSocialClient(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 181 - 0
src/pages-system/social/client/form/index.vue

@@ -0,0 +1,181 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.name"
+            label="应用名"
+            label-width="200rpx"
+            prop="name"
+            clearable
+            placeholder="请输入应用名"
+          />
+          <wd-cell title="社交平台" title-width="200rpx" prop="socialType" center>
+            <wd-picker
+              v-model="formData.socialType"
+              :columns="getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+              label-key="label"
+              value-key="value"
+              placeholder="请选择社交平台"
+            />
+          </wd-cell>
+          <wd-cell title="用户类型" title-width="200rpx" prop="userType" center>
+            <wd-radio-group v-model="formData.userType" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-input
+            v-model="formData.clientId"
+            label="客户端编号"
+            label-width="200rpx"
+            prop="clientId"
+            clearable
+            placeholder="请输入客户端编号,对应各平台的 appKey"
+          />
+          <wd-input
+            v-model="formData.clientSecret"
+            label="客户端密钥"
+            label-width="200rpx"
+            prop="clientSecret"
+            clearable
+            placeholder="请输入客户端密钥,对应各平台的 appSecret"
+          />
+          <wd-input
+            v-model="formData.agentId"
+            label="agentId"
+            label-width="200rpx"
+            prop="agentId"
+            clearable
+            placeholder="授权方的网页应用 ID,有则填"
+          />
+          <wd-cell title="状态" title-width="200rpx" prop="status" center>
+            <wd-radio-group v-model="formData.status" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="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 { SocialClient } from '@/api/system/social/client'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createSocialClient, getSocialClient, updateSocialClient } from '@/api/system/social/client'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑社交客户端' : '新增社交客户端')
+const formLoading = ref(false)
+const formData = ref<SocialClient>({
+  id: undefined,
+  name: '',
+  socialType: 0,
+  userType: 1,
+  clientId: '',
+  clientSecret: '',
+  agentId: '',
+  status: 0,
+})
+const formRules = {
+  name: [{ required: true, message: '应用名不能为空' }],
+  socialType: [{ required: true, message: '社交平台不能为空' }],
+  userType: [{ required: true, message: '用户类型不能为空' }],
+  clientId: [{ required: true, message: '客户端编号不能为空' }],
+  clientSecret: [{ required: true, message: '客户端密钥不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/social/index')
+}
+
+/** 加载社交客户端详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getSocialClient(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateSocialClient(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createSocialClient(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 143 - 0
src/pages-system/social/components/client-list.vue

@@ -0,0 +1,143 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <ClientSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 社交客户端列表 -->
+    <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 shrink-0 text-[#999]">社交平台:</text>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="item.socialType" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">用户类型:</text>
+            <dict-tag :type="DICT_TYPE.USER_TYPE" :value="item.userType" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">客户端编号:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.clientId || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</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>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:social-client:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SocialClient } from '@/api/system/social/client'
+import type { LoadMoreState } from '@/http/types'
+import { onMounted, ref } from 'vue'
+import { getSocialClientPage } from '@/api/system/social/client'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import ClientSearchForm from './client-search-form.vue'
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<SocialClient[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询社交客户端列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getSocialClientPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增社交客户端 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/social/client/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: SocialClient) {
+  uni.navigateTo({
+    url: `/pages-system/social/client/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 140 - 0
src/pages-system/social/components/client-search-form.vue

@@ -0,0 +1,140 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          应用名
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入应用名"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          社交平台
+        </view>
+        <wd-radio-group v-model="formData.socialType" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          用户类型
+        </view>
+        <wd-radio-group v-model="formData.userType" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  name: undefined as string | undefined,
+  socialType: -1,
+  userType: -1,
+  status: -1,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`应用名:${formData.name}`)
+  }
+  if (formData.socialType !== -1) {
+    conditions.push(`平台:${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, formData.socialType)}`)
+  }
+  if (formData.userType !== -1) {
+    conditions.push(`类型:${getDictLabel(DICT_TYPE.USER_TYPE, formData.userType)}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索社交客户端'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    name: formData.name || undefined,
+    socialType: formData.socialType === -1 ? undefined : formData.socialType,
+    userType: formData.userType === -1 ? undefined : formData.userType,
+    status: formData.status === -1 ? undefined : formData.status,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.socialType = -1
+  formData.userType = -1
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 121 - 0
src/pages-system/social/components/user-list.vue

@@ -0,0 +1,121 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <UserSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 社交用户列表 -->
+    <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.nickname || '-' }}
+            </view>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="item.type" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">社交 openid:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.openid || '-' }}</text>
+          </view>
+          <view v-if="item.avatar" class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">头像:</text>
+            <image :src="item.avatar" class="h-80rpx w-80rpx rounded-8rpx" mode="aspectFill" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</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>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SocialUser } from '@/api/system/social/user'
+import type { LoadMoreState } from '@/http/types'
+import { onMounted, ref } from 'vue'
+import { getSocialUserPage } from '@/api/system/social/user'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import UserSearchForm from './user-search-form.vue'
+
+const total = ref(0)
+const list = ref<SocialUser[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询社交用户列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getSocialUserPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 查看详情 */
+function handleDetail(item: SocialUser) {
+  uni.navigateTo({
+    url: `/pages-system/social/user/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

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

@@ -0,0 +1,110 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          社交平台
+        </view>
+        <wd-radio-group v-model="formData.type" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          用户昵称
+        </view>
+        <wd-input
+          v-model="formData.nickname"
+          placeholder="请输入用户昵称"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          社交 openid
+        </view>
+        <wd-input
+          v-model="formData.openid"
+          placeholder="请输入社交 openid"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  type: -1,
+  nickname: undefined as string | undefined,
+  openid: undefined as string | undefined,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.type !== -1) {
+    conditions.push(`平台:${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, formData.type)}`)
+  }
+  if (formData.nickname) {
+    conditions.push(`昵称:${formData.nickname}`)
+  }
+  if (formData.openid) {
+    conditions.push(`openid:${formData.openid}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索社交用户'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    type: formData.type === -1 ? undefined : formData.type,
+    nickname: formData.nickname || undefined,
+    openid: formData.openid || undefined,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.type = -1
+  formData.nickname = undefined
+  formData.openid = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 53 - 0
src/pages-system/social/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="社交用户管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- Tab 切换 -->
+    <view class="bg-white">
+      <wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
+        <wd-tab title="社交客户端" />
+        <wd-tab title="社交用户" />
+      </wd-tabs>
+    </view>
+
+    <!-- 列表内容 -->
+    <ClientList v-show="tabType === 'client'" />
+    <UserList v-show="tabType === 'user'" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { navigateBackPlus } from '@/utils'
+import ClientList from './components/client-list.vue'
+import UserList from './components/user-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['client', 'user']
+const tabIndex = ref(0)
+const tabType = computed<string>(() => tabTypes[tabIndex.value])
+
+/** Tab 切换 */
+function handleTabChange({ index }: { index: number }) {
+  tabIndex.value = index
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

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

@@ -0,0 +1,81 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="社交用户详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="社交平台">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="formData?.type" />
+        </wd-cell>
+        <wd-cell title="用户昵称" :value="String(formData?.nickname ?? '-')" />
+        <wd-cell v-if="formData?.avatar" title="用户头像">
+          <image :src="formData.avatar" class="h-120rpx w-120rpx rounded-8rpx" mode="aspectFill" />
+        </wd-cell>
+        <wd-cell title="社交 openid" :value="String(formData?.openid ?? '-')" />
+        <wd-cell title="社交 token" :value="String(formData?.token ?? '-')" />
+        <wd-cell title="原始 Token 数据" :value="String(formData?.rawTokenInfo ?? '-')" />
+        <wd-cell title="原始 User 数据" :value="String(formData?.rawUserInfo ?? '-')" />
+        <wd-cell title="最后一次的认证 code" :value="String(formData?.code ?? '-')" />
+        <wd-cell title="最后一次的认证 state" :value="String(formData?.state ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+        <wd-cell title="更新时间" :value="formatDateTime(formData?.updateTime) || '-'" />
+      </wd-cell-group>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SocialUser } from '@/api/system/social/user'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getSocialUser } from '@/api/system/social/user'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<SocialUser>()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/social/index')
+}
+
+/** 加载社交用户详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getSocialUser(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 139 - 0
src/pages-system/tenant/components/package-list.vue

@@ -0,0 +1,139 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <PackageSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 租户套餐列表 -->
+    <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 shrink-0 text-[#999]">套餐编号:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.id || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">备注:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.remark || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</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>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:tenant-package:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { TenantPackage } from '@/api/system/tenant-package'
+import type { LoadMoreState } from '@/http/types'
+import { onMounted, ref } from 'vue'
+import { getTenantPackagePage } from '@/api/system/tenant-package'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import PackageSearchForm from './package-search-form.vue'
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<TenantPackage[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询租户套餐列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getTenantPackagePage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增租户套餐 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/tenant/package/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: TenantPackage) {
+  uni.navigateTo({
+    url: `/pages-system/tenant/package/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 153 - 0
src/pages-system/tenant/components/package-search-form.vue

@@ -0,0 +1,153 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          套餐名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入套餐名称"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          创建时间
+        </view>
+        <view class="yd-search-form-date-range-container">
+          <view class="flex-1" @click="visibleCreateTime[0] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          -
+          <view class="flex-1" @click="visibleCreateTime[1] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
+        <view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[0] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
+        <view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[1] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate, formatDateRange } from '@/utils/date'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  name: undefined as string | undefined,
+  status: -1, // -1 表示全部
+  createTime: [undefined, undefined] as [number | undefined, number | undefined],
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`套餐名称:${formData.name}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
+  }
+  if (formData.createTime?.[0] && formData.createTime?.[1]) {
+    conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索租户套餐'
+})
+
+// 时间范围选择器状态
+const visibleCreateTime = ref<[boolean, boolean]>([false, false])
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 创建时间[0]确认 */
+function handleCreateTime0Confirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  visibleCreateTime.value[0] = false
+}
+
+/** 创建时间[1]确认 */
+function handleCreateTime1Confirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  visibleCreateTime.value[1] = false
+}
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    name: formData.name || undefined,
+    status: formData.status === -1 ? undefined : formData.status,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.status = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 147 - 0
src/pages-system/tenant/components/tenant-list.vue

@@ -0,0 +1,147 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <TenantSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 租户列表 -->
+    <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 shrink-0 text-[#999]">联系人:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.contactName || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">联系手机:</text>
+            <text>{{ item.contactMobile || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">账号额度:</text>
+            <text>{{ item.accountCount || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">过期时间:</text>
+            <text>{{ formatDateTime(item.expireTime) || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</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>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:tenant:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Tenant } from '@/api/system/tenant'
+import type { LoadMoreState } from '@/http/types'
+import { onMounted, ref } from 'vue'
+import { getTenantPage } from '@/api/system/tenant'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import TenantSearchForm from './tenant-search-form.vue'
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<Tenant[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询租户列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getTenantPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增租户 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/tenant/tenant/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: Tenant) {
+  uni.navigateTo({
+    url: `/pages-system/tenant/tenant/detail/index?id=${item.id}`,
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 185 - 0
src/pages-system/tenant/components/tenant-search-form.vue

@@ -0,0 +1,185 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          租户名
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入租户名"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          联系人
+        </view>
+        <wd-input
+          v-model="formData.contactName"
+          placeholder="请输入联系人"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          联系手机
+        </view>
+        <wd-input
+          v-model="formData.contactMobile"
+          placeholder="请输入联系手机"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          创建时间
+        </view>
+        <view class="yd-search-form-date-range-container">
+          <view class="flex-1" @click="visibleCreateTime[0] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          -
+          <view class="flex-1" @click="visibleCreateTime[1] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
+        <view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[0] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
+        <view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[1] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <view class="yd-search-form-actions">
+        <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 } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate, formatDateRange } from '@/utils/date'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  name: undefined as string | undefined,
+  contactName: undefined as string | undefined,
+  contactMobile: undefined as string | undefined,
+  status: -1, // -1 表示全部
+  createTime: [undefined, undefined] as [number | undefined, number | undefined],
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`租户名:${formData.name}`)
+  }
+  if (formData.contactName) {
+    conditions.push(`联系人:${formData.contactName}`)
+  }
+  if (formData.contactMobile) {
+    conditions.push(`手机:${formData.contactMobile}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
+  }
+  if (formData.createTime?.[0] && formData.createTime?.[1]) {
+    conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索租户'
+})
+
+// 时间范围选择器状态
+const visibleCreateTime = ref<[boolean, boolean]>([false, false])
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 创建时间[0]确认 */
+function handleCreateTime0Confirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  visibleCreateTime.value[0] = false
+}
+
+/** 创建时间[1]确认 */
+function handleCreateTime1Confirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  visibleCreateTime.value[1] = false
+}
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    name: formData.name || undefined,
+    contactName: formData.contactName || undefined,
+    contactMobile: formData.contactMobile || undefined,
+    status: formData.status === -1 ? undefined : formData.status,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.contactName = undefined
+  formData.contactMobile = undefined
+  formData.status = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 53 - 0
src/pages-system/tenant/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="租户管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- Tab 切换 -->
+    <view class="bg-white">
+      <wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
+        <wd-tab title="租户列表" />
+        <wd-tab title="租户套餐" />
+      </wd-tabs>
+    </view>
+
+    <!-- 列表内容 -->
+    <TenantList v-show="tabType === 'tenant'" />
+    <PackageList v-show="tabType === 'package'" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { navigateBackPlus } from '@/utils'
+import TenantList from './components/tenant-list.vue'
+import PackageList from './components/package-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['tenant', 'package']
+const tabIndex = ref(0)
+const tabType = computed<string>(() => tabTypes[tabIndex.value])
+
+/** Tab 切换 */
+function handleTabChange({ index }: { index: number }) {
+  tabIndex.value = index
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 127 - 0
src/pages-system/tenant/package/detail/index.vue

@@ -0,0 +1,127 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="租户套餐详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="套餐编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="套餐名称" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="备注" :value="String(formData?.remark ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:tenant-package:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:tenant-package:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { TenantPackage } from '@/api/system/tenant-package'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteTenantPackage, getTenantPackage } from '@/api/system/tenant-package'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<TenantPackage>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/tenant/index')
+}
+
+/** 加载租户套餐详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getTenantPackage(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑租户套餐 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/tenant/package/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 deleteTenantPackage(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 139 - 0
src/pages-system/tenant/package/form/index.vue

@@ -0,0 +1,139 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.name"
+            label="套餐名称"
+            label-width="200rpx"
+            prop="name"
+            clearable
+            placeholder="请输入套餐名称"
+          />
+          <wd-cell title="状态" title-width="200rpx" prop="status" center>
+            <wd-radio-group v-model="formData.status" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="200rpx"
+            prop="remark"
+            clearable
+            placeholder="请输入备注"
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="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 { TenantPackage } from '@/api/system/tenant-package'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createTenantPackage, getTenantPackage, updateTenantPackage } from '@/api/system/tenant-package'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑租户套餐' : '新增租户套餐')
+const formLoading = ref(false)
+const formData = ref<TenantPackage>({
+  id: undefined,
+  name: '',
+  status: 0,
+  remark: '',
+  menuIds: [],
+})
+const formRules = {
+  name: [{ required: true, message: '套餐名称不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/tenant/index')
+}
+
+/** 加载租户套餐详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getTenantPackage(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateTenantPackage(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createTenantPackage(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 150 - 0
src/pages-system/tenant/tenant/detail/index.vue

@@ -0,0 +1,150 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="租户详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="租户编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="租户名称" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="租户套餐" :value="getPackageName(formData?.packageId)" />
+        <wd-cell title="联系人" :value="String(formData?.contactName ?? '-')" />
+        <wd-cell title="联系手机" :value="String(formData?.contactMobile ?? '-')" />
+        <wd-cell title="账号额度" :value="String(formData?.accountCount ?? '-')" />
+        <wd-cell title="过期时间" :value="formatDateTime(formData?.expireTime) || '-'" />
+        <wd-cell title="绑定域名" :value="formData?.websites?.join(', ') || '-'" />
+        <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="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:tenant:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:tenant:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Tenant } from '@/api/system/tenant'
+import type { TenantPackage } from '@/api/system/tenant-package'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteTenant, getTenant } from '@/api/system/tenant'
+import { getTenantPackageList } from '@/api/system/tenant-package'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<Tenant>()
+const packageList = ref<TenantPackage[]>([])
+const deleting = ref(false)
+
+/** 获取套餐名称 */
+function getPackageName(packageId?: number) {
+  if (packageId === 0) {
+    return '系统租户'
+  }
+  const pkg = packageList.value.find(item => item.id === packageId)
+  return pkg?.name || '-'
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/tenant/index')
+}
+
+/** 加载租户套餐列表 */
+async function loadPackageList() {
+  packageList.value = await getTenantPackageList()
+}
+
+/** 加载租户详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getTenant(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑租户 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/tenant/tenant/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 deleteTenant(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await loadPackageList()
+  await getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 212 - 0
src/pages-system/tenant/tenant/form/index.vue

@@ -0,0 +1,212 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.name"
+            label="租户名称"
+            label-width="200rpx"
+            prop="name"
+            clearable
+            placeholder="请输入租户名称"
+          />
+          <wd-cell title="租户套餐" title-width="200rpx" prop="packageId" center>
+            <wd-picker
+              v-model="formData.packageId"
+              :columns="packageOptions"
+              label-key="name"
+              value-key="id"
+              placeholder="请选择租户套餐"
+            />
+          </wd-cell>
+          <wd-input
+            v-model="formData.contactName"
+            label="联系人"
+            label-width="200rpx"
+            prop="contactName"
+            clearable
+            placeholder="请输入联系人"
+          />
+          <wd-input
+            v-model="formData.contactMobile"
+            label="联系手机"
+            label-width="200rpx"
+            prop="contactMobile"
+            clearable
+            placeholder="请输入联系手机"
+          />
+          <!-- 新增时显示用户名和密码 -->
+          <wd-input
+            v-if="!props.id"
+            v-model="formData.username"
+            label="用户名称"
+            label-width="200rpx"
+            prop="username"
+            clearable
+            placeholder="请输入用户名称"
+          />
+          <wd-input
+            v-if="!props.id"
+            v-model="formData.password"
+            label="用户密码"
+            label-width="200rpx"
+            prop="password"
+            type="password"
+            clearable
+            placeholder="请输入用户密码"
+          />
+          <wd-input
+            v-model="formData.accountCount"
+            label="账号额度"
+            label-width="200rpx"
+            prop="accountCount"
+            type="number"
+            clearable
+            placeholder="请输入账号额度"
+          />
+          <wd-cell title="过期时间" title-width="200rpx" prop="expireTime" center>
+            <wd-datetime-picker
+              v-model="formData.expireTime"
+              type="date"
+              placeholder="请选择过期时间"
+            />
+          </wd-cell>
+          <wd-cell title="租户状态" title-width="200rpx" prop="status" center>
+            <wd-radio-group v-model="formData.status" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="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 { Tenant } from '@/api/system/tenant'
+import type { TenantPackage } from '@/api/system/tenant-package'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createTenant, getTenant, updateTenant } from '@/api/system/tenant'
+import { getTenantPackageList } from '@/api/system/tenant-package'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑租户' : '新增租户')
+const formLoading = ref(false)
+const packageOptions = ref<TenantPackage[]>([])
+const formData = ref<Tenant & { username?: string; password?: string }>({
+  id: undefined,
+  name: '',
+  packageId: 0,
+  contactName: '',
+  contactMobile: '',
+  accountCount: 0,
+  expireTime: new Date(),
+  websites: [],
+  status: 0,
+  username: '',
+  password: '',
+})
+const formRules = {
+  name: [{ required: true, message: '租户名称不能为空' }],
+  packageId: [{ required: true, message: '租户套餐不能为空' }],
+  contactName: [{ required: true, message: '联系人不能为空' }],
+  accountCount: [{ required: true, message: '账号额度不能为空' }],
+  expireTime: [{ required: true, message: '过期时间不能为空' }],
+  username: [{ required: true, message: '用户名称不能为空' }],
+  password: [{ required: true, message: '用户密码不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/tenant/index')
+}
+
+/** 加载租户套餐列表 */
+async function loadPackageList() {
+  packageOptions.value = await getTenantPackageList()
+}
+
+/** 加载租户详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getTenant(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateTenant(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createTenant(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await loadPackageList()
+  await getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>