Selaa lähdekoodia

feat:【infra】【system】新增文件与字典管理前端页面及相关 API 封装

YunaiV 4 kuukautta sitten
vanhempi
commit
fc5e60965f

+ 67 - 0
src/api/infra/file-config/index.ts

@@ -0,0 +1,67 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 文件客户端配置 */
+export interface FileClientConfig {
+  basePath?: string
+  host?: string
+  port?: number
+  username?: string
+  password?: string
+  mode?: string
+  endpoint?: string
+  bucket?: string
+  accessKey?: string
+  accessSecret?: string
+  enablePathStyleAccess?: boolean
+  enablePublicAccess?: boolean
+  region?: string
+  domain?: string
+}
+
+/** 文件配置信息 */
+export interface FileConfig {
+  id?: number
+  name: string
+  storage?: number
+  master?: boolean
+  visible?: boolean
+  config?: FileClientConfig
+  remark?: string
+  createTime?: Date
+}
+
+/** 查询文件配置分页列表 */
+export function getFileConfigPage(params: PageParam) {
+  return http.get<PageResult<FileConfig>>('/infra/file-config/page', params)
+}
+
+/** 查询文件配置详情 */
+export function getFileConfig(id: number) {
+  return http.get<FileConfig>(`/infra/file-config/get?id=${id}`)
+}
+
+/** 新增文件配置 */
+export function createFileConfig(data: FileConfig) {
+  return http.post<number>('/infra/file-config/create', data)
+}
+
+/** 修改文件配置 */
+export function updateFileConfig(data: FileConfig) {
+  return http.put<boolean>('/infra/file-config/update', data)
+}
+
+/** 删除文件配置 */
+export function deleteFileConfig(id: number) {
+  return http.delete<boolean>(`/infra/file-config/delete?id=${id}`)
+}
+
+/** 更新文件配置为主配置 */
+export function updateFileConfigMaster(id: number) {
+  return http.put<boolean>(`/infra/file-config/update-master?id=${id}`)
+}
+
+/** 测试文件配置 */
+export function testFileConfig(id: number) {
+  return http.get<string>(`/infra/file-config/test?id=${id}`)
+}

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

@@ -1,3 +1,4 @@
+import type { PageParam, PageResult } from '@/http/types'
 import { http } from '@/http/http'
 
 /** 字典数据 */
@@ -18,3 +19,28 @@ export interface DictData {
 export function getSimpleDictDataList() {
   return http.get<DictData[]>('/system/dict-data/simple-list')
 }
+
+/** 查询字典数据分页列表 */
+export function getDictDataPage(params: PageParam) {
+  return http.get<PageResult<DictData>>('/system/dict-data/page', params)
+}
+
+/** 查询字典数据详情 */
+export function getDictData(id: number) {
+  return http.get<DictData>(`/system/dict-data/get?id=${id}`)
+}
+
+/** 新增字典数据 */
+export function createDictData(data: DictData) {
+  return http.post<number>('/system/dict-data/create', data)
+}
+
+/** 修改字典数据 */
+export function updateDictData(data: DictData) {
+  return http.put<boolean>('/system/dict-data/update', data)
+}
+
+/** 删除字典数据 */
+export function deleteDictData(id: number) {
+  return http.delete<boolean>(`/system/dict-data/delete?id=${id}`)
+}

+ 42 - 0
src/api/system/dict/type/index.ts

@@ -0,0 +1,42 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 字典类型 */
+export interface DictType {
+  id?: number
+  name: string
+  type: string
+  status: number
+  remark?: string
+  createTime?: Date
+}
+
+/** 查询字典类型(精简)列表 */
+export function getSimpleDictTypeList() {
+  return http.get<DictType[]>('/system/dict-type/list-all-simple')
+}
+
+/** 查询字典类型分页列表 */
+export function getDictTypePage(params: PageParam) {
+  return http.get<PageResult<DictType>>('/system/dict-type/page', params)
+}
+
+/** 查询字典类型详情 */
+export function getDictType(id: number) {
+  return http.get<DictType>(`/system/dict-type/get?id=${id}`)
+}
+
+/** 新增字典类型 */
+export function createDictType(data: DictType) {
+  return http.post<number>('/system/dict-type/create', data)
+}
+
+/** 修改字典类型 */
+export function updateDictType(data: DictType) {
+  return http.put<boolean>('/system/dict-type/update', data)
+}
+
+/** 删除字典类型 */
+export function deleteDictType(id: number) {
+  return http.delete<boolean>(`/system/dict-type/delete?id=${id}`)
+}

+ 211 - 0
src/pages-infra/file/components/config-list.vue

@@ -0,0 +1,211 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <ConfigSearchForm @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>
+            <view class="flex items-center gap-8rpx">
+              <view v-if="item.master" class="rounded-4rpx bg-green-500 px-8rpx py-2rpx text-24rpx text-white">
+                主配置
+              </view>
+              <dict-tag :type="DICT_TYPE.INFRA_FILE_STORAGE" :value="item.storage" />
+            </view>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">配置编号:</text>
+            <text>{{ item.id }}</text>
+          </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.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 class="mt-16rpx flex justify-end gap-16rpx">
+            <wd-button
+              v-if="hasAccessByCodes(['infra:file-config:update'])"
+              size="small" type="info" @click.stop="handleTest(item)"
+            >
+              测试
+            </wd-button>
+            <wd-button
+              v-if="hasAccessByCodes(['infra:file-config:update']) && !item.master"
+              size="small" type="warning" @click.stop="handleMaster(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="暂无文件配置数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['infra:file-config:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { FileConfig } from '@/api/infra/file-config'
+import type { LoadMoreState } from '@/http/types'
+import { ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getFileConfigPage, testFileConfig, updateFileConfigMaster } from '@/api/infra/file-config'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import ConfigSearchForm from './config-search-form.vue'
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const total = ref(0)
+const list = ref<FileConfig[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getFileConfigPage(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/file/config/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: FileConfig) {
+  uni.navigateTo({
+    url: `/pages-infra/file/config/detail/index?id=${item.id}`,
+  })
+}
+
+/** 测试文件配置 */
+async function handleTest(item: FileConfig) {
+  try {
+    toast.loading('测试上传中...')
+    const url = await testFileConfig(item.id!)
+    toast.close()
+    uni.showModal({
+      title: '测试上传成功',
+      content: '是否要访问该文件?',
+      confirmText: '访问',
+      cancelText: '取消',
+      success: (res) => {
+        if (res.confirm && url) {
+          // 复制链接到剪贴板
+          uni.setClipboardData({
+            data: url,
+            success: () => {
+              toast.success('链接已复制,请在浏览器中打开')
+            },
+          })
+        }
+      },
+    })
+  } catch {
+    toast.show('测试失败')
+  }
+}
+
+/** 设为主配置 */
+function handleMaster(item: FileConfig) {
+  uni.showModal({
+    title: '提示',
+    content: `是否要将"${item.name}"设为主配置?`,
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      try {
+        toast.loading('设置中...')
+        await updateFileConfigMaster(item.id!)
+        toast.success('设置成功')
+        // 刷新列表
+        handleQuery()
+      } catch {
+        toast.show('设置失败')
+      }
+    },
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 153 - 0
src/pages-infra/file/components/config-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.storage" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
+            :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,
+  storage: -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.storage !== -1) {
+    conditions.push(`存储器:${getDictLabel(DICT_TYPE.INFRA_FILE_STORAGE, formData.storage)}`)
+  }
+  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,
+    storage: formData.storage === -1 ? undefined : formData.storage,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.storage = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 240 - 0
src/pages-infra/file/components/file-list.vue

@@ -0,0 +1,240 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <FileSearchForm @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 line-clamp-1">
+              {{ item.name || item.path }}
+            </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.path }}</text>
+          </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.type || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">文件大小:</text>
+            <text>{{ formatFileSize(item.size) }}</text>
+          </view>
+          <!-- 文件预览 -->
+          <view v-if="item.type && item.type.includes('image')" class="mb-12rpx">
+            <image
+              :src="item.url"
+              mode="aspectFit"
+              class="h-200rpx w-full rounded-8rpx"
+              @click.stop="handlePreviewImage(item.url)"
+            />
+          </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 class="mt-16rpx flex justify-end gap-16rpx">
+            <wd-button size="small" type="info" @click.stop="handleCopyUrl(item)">
+              复制链接
+            </wd-button>
+            <wd-button
+              v-if="hasAccessByCodes(['infra:file:delete'])"
+              size="small" type="error" @click.stop="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="暂无文件数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 上传按钮 -->
+    <wd-fab
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleUpload"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { LoadMoreState } from '@/http/types'
+import { ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { uploadFile } from '@/api/infra/file'
+import { useAccess } from '@/hooks/useAccess'
+import { http } from '@/http/http'
+import { formatDateTime } from '@/utils/date'
+import FileSearchForm from './file-search-form.vue'
+
+/** 文件信息 */
+interface FileInfo {
+  id?: number
+  configId?: number
+  path: string
+  name?: string
+  url?: string
+  size?: number
+  type?: string
+  createTime?: Date
+}
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const total = ref(0)
+const list = ref<FileInfo[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 格式化文件大小 */
+function formatFileSize(size?: number) {
+  if (!size) return '-'
+  if (size < 1024) return `${size} B`
+  if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`
+  if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`
+  return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
+}
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await http.get<{ list: FileInfo[], total: number }>('/infra/file/page', 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 handleUpload() {
+  uni.chooseImage({
+    count: 1,
+    success: async (res) => {
+      const filePath = res.tempFilePaths[0]
+      try {
+        toast.loading('上传中...')
+        await uploadFile(filePath)
+        toast.success('上传成功')
+        // 刷新列表
+        handleQuery()
+      } catch {
+        toast.show('上传失败')
+      }
+    },
+  })
+}
+
+/** 复制链接 */
+function handleCopyUrl(item: FileInfo) {
+  if (!item.url) {
+    toast.show('文件 URL 为空')
+    return
+  }
+  uni.setClipboardData({
+    data: item.url,
+    success: () => {
+      toast.success('复制成功')
+    },
+  })
+}
+
+/** 预览图片 */
+function handlePreviewImage(url?: string) {
+  if (!url) return
+  uni.previewImage({
+    urls: [url],
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: FileInfo) {
+  uni.navigateTo({
+    url: `/pages-infra/file/detail/index?id=${item.id}`,
+  })
+}
+
+/** 删除文件 */
+function handleDelete(item: FileInfo) {
+  uni.showModal({
+    title: '提示',
+    content: `确定要删除文件"${item.name || item.path}"吗?`,
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      try {
+        toast.loading('删除中...')
+        await http.delete(`/infra/file/delete?id=${item.id}`)
+        toast.success('删除成功')
+        // 刷新列表
+        handleQuery()
+      } catch {
+        toast.show('删除失败')
+      }
+    },
+  })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 144 - 0
src/pages-infra/file/components/file-search-form.vue

@@ -0,0 +1,144 @@
+<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.path"
+          placeholder="请输入文件路径"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          文件类型
+        </view>
+        <wd-input
+          v-model="formData.type"
+          placeholder="请输入文件类型"
+          clearable
+        />
+      </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 { getNavbarHeight } from '@/utils'
+import { formatDate, formatDateRange } from '@/utils/date'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  path: undefined as string | undefined,
+  type: undefined as string | undefined,
+  createTime: [undefined, undefined] as [number | undefined, number | undefined],
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.path) {
+    conditions.push(`路径:${formData.path}`)
+  }
+  if (formData.type) {
+    conditions.push(`类型:${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', {
+    path: formData.path || undefined,
+    type: formData.type || undefined,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.path = undefined
+  formData.type = undefined
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

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

@@ -0,0 +1,157 @@
+<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.INFRA_FILE_STORAGE" :value="formData?.storage" />
+        </wd-cell>
+        <wd-cell title="主配置">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="formData?.master" />
+        </wd-cell>
+        <wd-cell title="备注" :value="String(formData?.remark ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+
+      <!-- 存储配置详情 -->
+      <wd-cell-group v-if="formData?.config" border title="存储配置">
+        <!-- DB / Local / FTP / SFTP 配置 -->
+        <template v-if="formData.storage && formData.storage >= 10 && formData.storage <= 12">
+          <wd-cell title="基础路径" :value="String(formData.config.basePath ?? '-')" />
+          <template v-if="formData.storage >= 11 && formData.storage <= 12">
+            <wd-cell title="主机地址" :value="String(formData.config.host ?? '-')" />
+            <wd-cell title="主机端口" :value="String(formData.config.port ?? '-')" />
+            <wd-cell title="用户名" :value="String(formData.config.username ?? '-')" />
+            <wd-cell title="密码" :value="String(formData.config.password ?? '-')" />
+          </template>
+          <wd-cell v-if="formData.storage === 11" title="连接模式" :value="formData.config.mode === 'Active' ? '主动模式' : '被动模式'" />
+        </template>
+        <!-- S3 配置 -->
+        <template v-if="formData.storage === 20">
+          <wd-cell title="节点地址" :value="String(formData.config.endpoint ?? '-')" />
+          <wd-cell title="存储 bucket" :value="String(formData.config.bucket ?? '-')" />
+          <wd-cell title="accessKey" :value="String(formData.config.accessKey ?? '-')" />
+          <wd-cell title="accessSecret" :value="String(formData.config.accessSecret ?? '-')" />
+          <wd-cell title="Path Style" :value="formData.config.enablePathStyleAccess ? '启用' : '禁用'" />
+          <wd-cell title="公开访问" :value="formData.config.enablePublicAccess ? '公开' : '私有'" />
+          <wd-cell title="区域" :value="String(formData.config.region ?? '-')" />
+        </template>
+        <!-- 通用配置 -->
+        <wd-cell title="自定义域名" :value="String(formData.config.domain ?? '-')" />
+      </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:file-config:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['infra:file-config:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { FileConfig } from '@/api/infra/file-config'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteFileConfig, getFileConfig } from '@/api/infra/file-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<FileConfig>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-infra/file/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getFileConfig(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-infra/file/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 deleteFileConfig(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

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

@@ -0,0 +1,307 @@
+<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="storage" center>
+            <wd-picker
+              v-model="formData.storage"
+              :columns="getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
+              label-key="label"
+              value-key="value"
+              :disabled="!!formData.id"
+              placeholder="请选择存储器"
+            />
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="200rpx"
+            prop="remark"
+            clearable
+            placeholder="请输入备注"
+          />
+        </wd-cell-group>
+
+        <!-- DB / Local / FTP / SFTP 配置 -->
+        <wd-cell-group v-if="formData.storage && formData.storage >= 10 && formData.storage <= 12" border title="存储配置">
+          <wd-input
+            v-model="formData.config!.basePath"
+            label="基础路径"
+            label-width="200rpx"
+            prop="config.basePath"
+            clearable
+            placeholder="请输入基础路径"
+          />
+          <!-- FTP / SFTP 配置 -->
+          <template v-if="formData.storage >= 11 && formData.storage <= 12">
+            <wd-input
+              v-model="formData.config!.host"
+              label="主机地址"
+              label-width="200rpx"
+              prop="config.host"
+              clearable
+              placeholder="请输入主机地址"
+            />
+            <wd-input
+              v-model.number="formData.config!.port"
+              label="主机端口"
+              label-width="200rpx"
+              prop="config.port"
+              type="number"
+              clearable
+              placeholder="请输入主机端口"
+            />
+            <wd-input
+              v-model="formData.config!.username"
+              label="用户名"
+              label-width="200rpx"
+              prop="config.username"
+              clearable
+              placeholder="请输入用户名"
+            />
+            <wd-input
+              v-model="formData.config!.password"
+              label="密码"
+              label-width="200rpx"
+              prop="config.password"
+              clearable
+              placeholder="请输入密码"
+            />
+          </template>
+          <!-- FTP 连接模式 -->
+          <wd-cell v-if="formData.storage === 11" title="连接模式" title-width="200rpx" prop="config.mode" center>
+            <wd-radio-group v-model="formData.config!.mode" shape="button">
+              <wd-radio value="Active">
+                主动模式
+              </wd-radio>
+              <wd-radio value="Passive">
+                被动模式
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+        </wd-cell-group>
+
+        <!-- S3 配置 -->
+        <wd-cell-group v-if="formData.storage === 20" border title="S3 配置">
+          <wd-input
+            v-model="formData.config!.endpoint"
+            label="节点地址"
+            label-width="200rpx"
+            prop="config.endpoint"
+            clearable
+            placeholder="请输入节点地址"
+          />
+          <wd-input
+            v-model="formData.config!.bucket"
+            label="存储 bucket"
+            label-width="200rpx"
+            prop="config.bucket"
+            clearable
+            placeholder="请输入 bucket"
+          />
+          <wd-input
+            v-model="formData.config!.accessKey"
+            label="accessKey"
+            label-width="200rpx"
+            prop="config.accessKey"
+            clearable
+            placeholder="请输入 accessKey"
+          />
+          <wd-input
+            v-model="formData.config!.accessSecret"
+            label="accessSecret"
+            label-width="200rpx"
+            prop="config.accessSecret"
+            clearable
+            placeholder="请输入 accessSecret"
+          />
+          <wd-cell title="Path Style" title-width="200rpx" prop="config.enablePathStyleAccess" center>
+            <wd-radio-group v-model="formData.config!.enablePathStyleAccess" shape="button">
+              <wd-radio :value="true">
+                启用
+              </wd-radio>
+              <wd-radio :value="false">
+                禁用
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-cell title="公开访问" title-width="200rpx" prop="config.enablePublicAccess" center>
+            <wd-radio-group v-model="formData.config!.enablePublicAccess" shape="button">
+              <wd-radio :value="true">
+                公开
+              </wd-radio>
+              <wd-radio :value="false">
+                私有
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-input
+            v-model="formData.config!.region"
+            label="区域"
+            label-width="200rpx"
+            prop="config.region"
+            clearable
+            placeholder="请填写区域,一般仅 AWS 需要填写"
+          />
+        </wd-cell-group>
+
+        <!-- 通用配置 -->
+        <wd-cell-group v-if="formData.storage" border title="通用配置">
+          <wd-input
+            v-model="formData.config!.domain"
+            label="自定义域名"
+            label-width="200rpx"
+            prop="config.domain"
+            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 { FileConfig } from '@/api/infra/file-config'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createFileConfig, getFileConfig, updateFileConfig } from '@/api/infra/file-config'
+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<FileConfig>({
+  id: undefined,
+  name: '',
+  storage: undefined,
+  remark: '',
+  config: {
+    basePath: '',
+    host: '',
+    port: undefined,
+    username: '',
+    password: '',
+    mode: 'Passive',
+    endpoint: '',
+    bucket: '',
+    accessKey: '',
+    accessSecret: '',
+    enablePathStyleAccess: false,
+    enablePublicAccess: false,
+    region: '',
+    domain: '',
+  },
+})
+const formRules = {
+  name: [{ required: true, message: '配置名不能为空' }],
+  storage: [{ required: true, message: '存储器不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-infra/file/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  const data = await getFileConfig(props.id)
+  formData.value = {
+    ...data,
+    config: data.config || {
+      basePath: '',
+      host: '',
+      port: undefined,
+      username: '',
+      password: '',
+      mode: 'Passive',
+      endpoint: '',
+      bucket: '',
+      accessKey: '',
+      accessSecret: '',
+      enablePathStyleAccess: false,
+      enablePublicAccess: false,
+      region: '',
+      domain: '',
+    },
+  }
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateFileConfig(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createFileConfig(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 171 - 0
src/pages-infra/file/detail/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"
+    />
+
+    <!-- 详情内容 -->
+    <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?.path ?? '-')" />
+        <wd-cell title="文件 URL" :value="String(formData?.url ?? '-')" />
+        <wd-cell title="文件大小" :value="formatFileSize(formData?.size)" />
+        <wd-cell title="文件类型" :value="String(formData?.type ?? '-')" />
+        <wd-cell title="上传时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+
+      <!-- 文件预览 -->
+      <view v-if="formData?.type && formData.type.includes('image')" class="m-24rpx">
+        <view class="mb-16rpx text-28rpx text-[#999]">
+          文件预览
+        </view>
+        <image
+          :src="formData.url"
+          mode="aspectFit"
+          class="w-full rounded-8rpx"
+          @click="handlePreviewImage"
+        />
+      </view>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button class="flex-1" type="info" @click="handleCopyUrl">
+          复制链接
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['infra:file:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { useAccess } from '@/hooks/useAccess'
+import { http } from '@/http/http'
+import { navigateBackPlus } from '@/utils'
+import { formatDateTime } from '@/utils/date'
+
+/** 文件信息 */
+interface FileInfo {
+  id?: number
+  configId?: number
+  path: string
+  name?: string
+  url?: string
+  size?: number
+  type?: string
+  createTime?: Date
+}
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<FileInfo>()
+const deleting = ref(false)
+
+/** 格式化文件大小 */
+function formatFileSize(size?: number) {
+  if (!size) return '-'
+  if (size < 1024) return `${size} B`
+  if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`
+  if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`
+  return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-infra/file/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await http.get<FileInfo>(`/infra/file/get?id=${props.id}`)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 复制链接 */
+function handleCopyUrl() {
+  if (!formData.value?.url) {
+    toast.show('文件 URL 为空')
+    return
+  }
+  uni.setClipboardData({
+    data: formData.value.url,
+    success: () => {
+      toast.success('复制成功')
+    },
+  })
+}
+
+/** 预览图片 */
+function handlePreviewImage() {
+  if (!formData.value?.url) return
+  uni.previewImage({
+    urls: [formData.value.url],
+  })
+}
+
+/** 删除 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该文件吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await http.delete(`/infra/file/delete?id=${props.id}`)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 52 - 0
src/pages-infra/file/index.vue

@@ -0,0 +1,52 @@
+<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>
+    <!-- 列表内容 -->
+    <FileList v-show="tabType === 'file'" />
+    <ConfigList v-show="tabType === 'config'" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { navigateBackPlus } from '@/utils'
+import ConfigList from './components/config-list.vue'
+import FileList from './components/file-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['file', 'config']
+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>

+ 210 - 0
src/pages-system/dict/components/data-list.vue

@@ -0,0 +1,210 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <DataSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 字典数据列表 -->
+    <view class="p-24rpx">
+      <!-- 当前字典类型提示 -->
+      <view v-if="dictType" class="mb-24rpx rounded-12rpx bg-blue-50 p-16rpx text-28rpx text-blue-600">
+        当前字典类型:{{ dictType }}
+      </view>
+      <view v-else class="mb-24rpx rounded-12rpx bg-orange-50 p-16rpx text-28rpx text-orange-600">
+        请先在"字典类型"中选择一个字典类型
+      </view>
+
+      <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.label }}
+            </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.value }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">字典排序:</text>
+            <text>{{ item.sort }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">颜色类型:</text>
+            <view v-if="item.colorType" class="rounded-4rpx px-8rpx py-2rpx text-24rpx text-white" :style="{ backgroundColor: getColorStyle(item.colorType) }">
+              {{ item.colorType }}
+            </view>
+            <text v-else>-</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">CSS Class:</text>
+            <view v-if="item.cssClass" class="rounded-4rpx px-8rpx py-2rpx text-24rpx text-white" :style="{ backgroundColor: item.cssClass }">
+              {{ item.cssClass }}
+            </view>
+            <text v-else>-</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:dict:create']) && dictType"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DictData } from '@/api/system/dict/data'
+import type { LoadMoreState } from '@/http/types'
+import { ref, watch } from 'vue'
+import { getDictDataPage } from '@/api/system/dict/data'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import DataSearchForm from './data-search-form.vue'
+
+const props = defineProps<{
+  dictType?: string
+}>()
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<DictData[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  dictType: undefined as string | undefined,
+})
+
+/** 颜色类型映射 */
+const colorMap: Record<string, string> = {
+  processing: '#1890ff',
+  success: '#52c41a',
+  default: '#d9d9d9',
+  warning: '#faad14',
+  error: '#ff4d4f',
+  pink: '#eb2f96',
+  red: '#f5222d',
+  orange: '#fa8c16',
+  green: '#52c41a',
+  cyan: '#13c2c2',
+  blue: '#1890ff',
+  purple: '#722ed1',
+}
+
+/** 获取颜色样式 */
+function getColorStyle(colorType: string) {
+  return colorMap[colorType] || colorType
+}
+
+/** 查询列表 */
+async function getList() {
+  if (!props.dictType) {
+    list.value = []
+    loadMoreState.value = 'finished'
+    return
+  }
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getDictDataPage({
+      ...queryParams.value,
+      dictType: props.dictType,
+    })
+    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,
+    dictType: props.dictType,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增 */
+function handleAdd() {
+  uni.navigateTo({
+    url: `/pages-system/dict/data/form/index?dictType=${props.dictType}`,
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: DictData) {
+  uni.navigateTo({
+    url: `/pages-system/dict/data/detail/index?id=${item.id}`,
+  })
+}
+
+/** 监听 dictType 变化,重新查询 */
+watch(
+  () => props.dictType,
+  () => {
+    if (props.dictType) {
+      queryParams.value.pageNo = 1
+      list.value = []
+      getList()
+    }
+  },
+)
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  if (props.dictType) {
+    getList()
+  }
+})
+</script>

+ 94 - 0
src/pages-system/dict/components/data-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.label"
+          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({
+  label: undefined as string | undefined,
+  status: -1,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.label) {
+    conditions.push(`标签:${formData.label}`)
+  }
+  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', {
+    label: formData.label || undefined,
+    status: formData.status === -1 ? undefined : formData.status,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.label = undefined
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 154 - 0
src/pages-system/dict/components/type-list.vue

@@ -0,0 +1,154 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <TypeSearchForm @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.type }}</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 class="mt-16rpx flex justify-end">
+            <wd-button size="small" type="info" @click.stop="handleSelectType(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="暂无字典类型数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:dict:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DictType } from '@/api/system/dict/type'
+import type { LoadMoreState } from '@/http/types'
+import { ref } from 'vue'
+import { getDictTypePage } from '@/api/system/dict/type'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import TypeSearchForm from './type-search-form.vue'
+
+const emit = defineEmits<{
+  select: [dictType: string]
+}>()
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<DictType[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getDictTypePage(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/dict/type/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: DictType) {
+  uni.navigateTo({
+    url: `/pages-system/dict/type/detail/index?id=${item.id}`,
+  })
+}
+
+/** 选择字典类型,查看数据 */
+function handleSelectType(item: DictType) {
+  emit('select', item.type)
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 169 - 0
src/pages-system/dict/components/type-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.type"
+          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,
+  type: undefined as string | undefined,
+  status: -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.type) {
+    conditions.push(`类型:${formData.type}`)
+  }
+  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,
+    type: formData.type || undefined,
+    status: formData.status === -1 ? undefined : formData.status,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.type = undefined
+  formData.status = -1
+  formData.createTime = [undefined, undefined]
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 163 - 0
src/pages-system/dict/data/detail/index.vue

@@ -0,0 +1,163 @@
+<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?.dictType ?? '-')" />
+        <wd-cell title="字典标签" :value="String(formData?.label ?? '-')" />
+        <wd-cell title="字典键值" :value="String(formData?.value ?? '-')" />
+        <wd-cell title="字典排序" :value="String(formData?.sort ?? '-')" />
+        <wd-cell title="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="颜色类型">
+          <view v-if="formData?.colorType" class="rounded-4rpx px-8rpx py-2rpx text-24rpx text-white" :style="{ backgroundColor: getColorStyle(formData.colorType) }">
+            {{ formData.colorType }}
+          </view>
+          <text v-else>-</text>
+        </wd-cell>
+        <wd-cell title="CSS Class">
+          <view v-if="formData?.cssClass" class="rounded-4rpx px-8rpx py-2rpx text-24rpx text-white" :style="{ backgroundColor: formData.cssClass }">
+            {{ formData.cssClass }}
+          </view>
+          <text v-else>-</text>
+        </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:dict:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:dict:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DictData } from '@/api/system/dict/data'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteDictData, getDictData } from '@/api/system/dict/data'
+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<DictData>()
+const deleting = ref(false)
+
+/** 颜色类型映射 */
+const colorMap: Record<string, string> = {
+  processing: '#1890ff',
+  success: '#52c41a',
+  default: '#d9d9d9',
+  warning: '#faad14',
+  error: '#ff4d4f',
+  pink: '#eb2f96',
+  red: '#f5222d',
+  orange: '#fa8c16',
+  green: '#52c41a',
+  cyan: '#13c2c2',
+  blue: '#1890ff',
+  purple: '#722ed1',
+}
+
+/** 获取颜色样式 */
+function getColorStyle(colorType: string) {
+  return colorMap[colorType] || colorType
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/dict/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getDictData(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/dict/data/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 deleteDictData(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 222 - 0
src/pages-system/dict/data/form/index.vue

@@ -0,0 +1,222 @@
+<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-cell title="字典类型" title-width="200rpx" prop="dictType" center>
+            <wd-picker
+              v-model="formData.dictType"
+              :columns="dictTypeOptions"
+              label-key="label"
+              value-key="value"
+              :disabled="!!formData.id"
+              placeholder="请选择字典类型"
+            />
+          </wd-cell>
+          <wd-input
+            v-model="formData.label"
+            label="数据标签"
+            label-width="200rpx"
+            prop="label"
+            clearable
+            placeholder="请输入数据标签"
+          />
+          <wd-input
+            v-model="formData.value"
+            label="数据键值"
+            label-width="200rpx"
+            prop="value"
+            clearable
+            placeholder="请输入数据键值"
+          />
+          <wd-input
+            v-model.number="formData.sort"
+            label="显示排序"
+            label-width="200rpx"
+            prop="sort"
+            type="number"
+            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-cell title="颜色类型" title-width="200rpx" prop="colorType" center>
+            <wd-picker
+              v-model="formData.colorType"
+              :columns="colorOptions"
+              label-key="label"
+              value-key="value"
+              placeholder="请选择颜色类型"
+            />
+          </wd-cell>
+          <wd-input
+            v-model="formData.cssClass"
+            label="CSS Class"
+            label-width="200rpx"
+            prop="cssClass"
+            clearable
+            placeholder="请输入 CSS Class,如 #108ee9"
+          />
+          <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 { DictData } from '@/api/system/dict/data'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createDictData, getDictData, updateDictData } from '@/api/system/dict/data'
+import { getSimpleDictTypeList } from '@/api/system/dict/type'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+  dictType?: string | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑字典数据' : '新增字典数据')
+const formLoading = ref(false)
+const formData = ref<DictData>({
+  id: undefined,
+  dictType: props.dictType || '',
+  label: '',
+  value: '',
+  sort: 0,
+  status: 0,
+  colorType: '',
+  cssClass: '',
+  remark: '',
+})
+const formRules = {
+  dictType: [{ required: true, message: '字典类型不能为空' }],
+  label: [{ required: true, message: '数据标签不能为空' }],
+  value: [{ required: true, message: '数据键值不能为空' }],
+  sort: [{ required: true, message: '显示排序不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 字典类型选项 */
+const dictTypeOptions = ref<{ label: string, value: string }[]>([])
+
+/** 颜色类型选项 */
+const colorOptions = [
+  { value: '', label: '无' },
+  { value: 'processing', label: '主要' },
+  { value: 'success', label: '成功' },
+  { value: 'default', label: '默认' },
+  { value: 'warning', label: '警告' },
+  { value: 'error', label: '危险' },
+  { value: 'pink', label: 'pink' },
+  { value: 'red', label: 'red' },
+  { value: 'orange', label: 'orange' },
+  { value: 'green', label: 'green' },
+  { value: 'cyan', label: 'cyan' },
+  { value: 'blue', label: 'blue' },
+  { value: 'purple', label: 'purple' },
+]
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/dict/index')
+}
+
+/** 加载字典类型列表 */
+async function loadDictTypeList() {
+  const list = await getSimpleDictTypeList()
+  dictTypeOptions.value = list.map(item => ({
+    label: item.name,
+    value: item.type,
+  }))
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getDictData(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateDictData(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createDictData(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await loadDictTypeList()
+  await getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 59 - 0
src/pages-system/dict/index.vue

@@ -0,0 +1,59 @@
+<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>
+    <!-- 列表内容 -->
+    <TypeList v-show="tabType === 'type'" @select="handleTypeSelect" />
+    <DataList v-show="tabType === 'data'" :dict-type="selectedDictType" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { navigateBackPlus } from '@/utils'
+import DataList from './components/data-list.vue'
+import TypeList from './components/type-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['type', 'data']
+const tabIndex = ref(0)
+const tabType = computed<string>(() => tabTypes[tabIndex.value])
+const selectedDictType = ref<string>() // 选中的字典类型
+
+/** Tab 切换 */
+function handleTabChange({ index }: { index: number }) {
+  tabIndex.value = index
+}
+
+/** 选择字典类型 */
+function handleTypeSelect(dictType: string) {
+  selectedDictType.value = dictType
+  tabIndex.value = 1 // 切换到字典数据 tab
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 128 - 0
src/pages-system/dict/type/detail/index.vue

@@ -0,0 +1,128 @@
+<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?.type ?? '-')" />
+        <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:dict:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:dict:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { DictType } from '@/api/system/dict/type'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteDictType, getDictType } from '@/api/system/dict/type'
+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<DictType>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/dict/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getDictType(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/dict/type/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 deleteDictType(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 149 - 0
src/pages-system/dict/type/form/index.vue

@@ -0,0 +1,149 @@
+<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.type"
+            label="字典类型"
+            label-width="200rpx"
+            prop="type"
+            clearable
+            :disabled="!!formData.id"
+            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 { DictType } from '@/api/system/dict/type'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createDictType, getDictType, updateDictType } from '@/api/system/dict/type'
+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<DictType>({
+  id: undefined,
+  name: '',
+  type: '',
+  status: 0,
+  remark: '',
+})
+const formRules = {
+  name: [{ required: true, message: '字典名称不能为空' }],
+  type: [{ required: true, message: '字典类型不能为空' }],
+  status: [{ required: true, message: '状态不能为空' }],
+}
+const formRef = ref()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/dict/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getDictType(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateDictType(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createDictType(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>