chore: 初始化uniapp前端项目基础框架

新增项目配置、静态资源、工具类、状态管理、API接口、组件库集成等全套基础代码,完成项目初始化搭建
This commit is contained in:
zhangtao
2026-08-02 03:49:27 +08:00
parent 7f8320d3ed
commit 3c9b77f994
278 changed files with 55387 additions and 2 deletions
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { useTeam } from '@/composables/useTeam'
definePage({
name: 'about',
style: {
navigationBarTitleText: '关于',
},
})
const { data: coreTeam } = useTeam()
function openUrl(url?: string) {
if (!url) {
return
}
window.open(url, '_blank')
}
// 打开公众号二维码
function openWeChat() {
uni.previewImage({
urls: ['https://wot-ui.cn/wechatPublicAccount.png'],
})
}
// 打开捐赠二维码
function donate() {
uni.previewImage({
urls: ['https://wot-ui.cn/weixinQrcode.jpg'],
})
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3 flex flex-col gap-2">
<text class="text-6 font-bold wot-text-text-main">
关于我们
</text>
<text class="text-3.5 leading-snug wot-text-text-secondary">
我是不如摸鱼去,一个前端打工仔,我和我的小伙伴们正在致力于开发轻量、高效的uni-app组件库和高效、易用的uni-app快速开发模板。
</text>
</view>
<!-- 核心团队 -->
<demo-block title="核心团队" transparent>
<view class="grid grid-cols-2 gap-3">
<view
v-for="member in coreTeam"
:key="member.name"
class="rounded-2 p-4 text-center wot-bg-filled-oppo"
@click="openUrl(member.github)"
>
<image
:src="member.avatar"
class="mx-auto mb-2 h-16 w-16 border-2 border-blue-200 rounded-full dark:border-blue-800"
/>
<view class="mb-1 text-3.5 font-bold wot-text-text-main">
{{ member.name }}
</view>
<view class="mb-2 text-2.5 text-blue-600 dark:text-blue-400">
{{ member.title }}
</view>
<view class="text-2.5 leading-snug wot-text-text-secondary">
{{ member.desc }}
</view>
</view>
</view>
</demo-block>
<!-- 关于 uni-helper -->
<demo-block title="关于 uni-helper 团队" transparent>
<view class="rounded-3 p-5 wot-bg-filled-oppo">
<text class="mb-3 block text-3.5 leading-relaxed wot-text-text-secondary">
<text class="text-blue-600" @click="openUrl('https://uni-helper.cn/')">
uni-helper
</text>
是一个旨在增强 uni-app 系列产品的开发体验为爱发电的非官方组织。作为靠爱发电的非官方项目,uni-helper 提供了打包工具插件支持、编辑器扩展支持、NPM 包等并尽力维护它们。
</text>
<text class="text-3.5 leading-relaxed wot-text-text-secondary">
在此我们特别向 uni-helper 团队表示感谢,他们为 uni-app 系列产品提供了强大的支持,包括打包工具插件支持、编辑器扩展支持等,这使我们得以站在巨人的巨人的肩膀上完成此项目。
</text>
</view>
</demo-block>
<!-- 更多信息 -->
<demo-block title="更多信息" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell
title="关注公众号"
title-width="200px"
label="uni-app教程、组件库讯息一手掌握!"
is-link
@click="openWeChat"
/>
<wd-cell
title="捐赠"
title-width="200px"
label="每一份捐赠都是对我们莫大的鼓励!"
is-link
@click="donate"
/>
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,448 @@
<script setup lang="ts">
import type { DashboardStats } from '@/api/module_monitor/dashboard'
import type { ServerInfo } from '@/api/module_monitor/server'
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { DashboardAPI } from '@/api/module_monitor/dashboard'
import { ServerAPI } from '@/api/module_monitor/server'
import SkeletonPage from '@/components/SkeletonPage.vue'
definePage({
name: 'dashboard',
style: { navigationBarTitleText: '数据概览' },
})
const toast = useToast()
const loading = ref(false)
const stats = ref<DashboardStats | null>(null)
const serverInfo = ref<ServerInfo | null>(null)
// 7-day mock login trend data
const loginTrend = ref([
{ day: '一', value: 45, label: '128' },
{ day: '二', value: 60, label: '186' },
{ day: '三', value: 85, label: '245' },
{ day: '四', value: 72, label: '210' },
{ day: '五', value: 100, label: '312' },
{ day: '六', value: 68, label: '198' },
{ day: '日', value: 38, label: '95' },
])
const alerts = ref([
{ id: 1, level: 'danger', text: '磁盘使用率超过 85%', time: '10分钟前' },
{ id: 2, level: 'warning', text: '内存使用率持续升高', time: '30分钟前' },
{ id: 3, level: 'info', text: '新用户注册量异常增长', time: '1小时前' },
])
const cpuPercent = computed(() => serverInfo.value?.cpu?.used_percent ?? 0)
const memPercent = computed(() => serverInfo.value?.memory?.used_percent ?? 0)
function healthColor(percent: number) {
if (percent < 50)
return 'var(--success-color, #10B981)'
if (percent < 80)
return 'var(--warning-color, #F59E0B)'
return 'var(--danger-color, #EF4444)'
}
async function loadData() {
loading.value = true
try {
const [dashRes, serverRes] = await Promise.all([
DashboardAPI.getStats().catch(() => null),
ServerAPI.getInfo().catch(() => null),
])
if (dashRes)
stats.value = dashRes
if (serverRes)
serverInfo.value = serverRes
}
catch (e) {
toast.error(getErrorMessage(e, '加载失败'))
}
finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
onLoad(() => loadData())
onShow(() => {
const pages = getCurrentPages()
if (pages.length > 0 && pages[pages.length - 1].route === 'subPages/biz/dashboard/index') {
uni.$emit('updateTabbar', 'work')
}
})
onPullDownRefresh(() => loadData())
</script>
<template>
<view class="dashboard-page">
<!-- Nav header -->
<view class="dash-nav">
<text class="dash-nav__title">
数据概览
</text>
<view class="dash-nav__badge">
<text class="dash-nav__badge-text">
今日
</text>
</view>
</view>
<SkeletonPage v-if="loading && !stats" :rows="6" />
<template v-else>
<!-- Stats row -->
<view class="stats-row">
<view class="stat-card card-pressable">
<text class="stat-card__value" style="color: var(--primary-color);">
{{ stats?.total_users?.toLocaleString() ?? '-' }}
</text>
<text class="stat-card__label">
总用户数
</text>
</view>
<view class="stat-card card-pressable">
<text class="stat-card__value" style="color: var(--success-color);">
{{ stats?.today_login_count ?? '-' }}
</text>
<text class="stat-card__label">
今日活跃
</text>
</view>
<view class="stat-card card-pressable">
<text class="stat-card__value" style="color: var(--warning-color);">
{{ cpuPercent }}%
</text>
<text class="stat-card__label">
系统负载
</text>
</view>
</view>
<!-- Chart section -->
<view class="chart-card">
<view class="chart-card__header">
<text class="chart-card__title">
登录趋势 (近7天)
</text>
<text class="chart-card__period">
7天
</text>
</view>
<view class="chart-bars">
<view v-for="item in loginTrend" :key="item.day" class="chart-bar-wrap">
<view class="chart-bar" :style="{ height: `${item.value}%` }">
<text class="chart-bar__tip">
{{ item.label }}
</text>
</view>
<text class="chart-bar__label">
{{ item.day }}
</text>
</view>
</view>
</view>
<!-- System health -->
<view class="health-row">
<view class="health-card card-pressable">
<view class="health-card__info">
<text class="health-card__label">
CPU
</text>
<text class="health-card__value" :style="{ color: healthColor(cpuPercent) }">
{{ cpuPercent }}%
</text>
</view>
<view class="health-card__dot" :style="{ background: healthColor(cpuPercent) }" />
</view>
<view class="health-card card-pressable">
<view class="health-card__info">
<text class="health-card__label">
内存
</text>
<text class="health-card__value" :style="{ color: healthColor(memPercent) }">
{{ memPercent }}%
</text>
</view>
<view class="health-card__dot" :style="{ background: healthColor(memPercent) }" />
</view>
</view>
<!-- Alerts -->
<view class="alert-card">
<view class="alert-card__header">
<text class="alert-card__title">
系统告警
</text>
<view class="alert-card__badge">
<text class="alert-card__badge-text">
{{ alerts.length }}
</text>
</view>
</view>
<view
v-for="alert in alerts" :key="alert.id"
class="alert-item"
:class="`alert-item--${alert.level}`"
>
<view class="alert-item__dot" :class="`alert-item__dot--${alert.level}`" />
<text class="alert-item__text">
{{ alert.text }}
</text>
<text class="alert-item__time">
{{ alert.time }}
</text>
</view>
</view>
</template>
</view>
</template>
<style lang="scss" scoped>
.dashboard-page {
min-height: 100vh;
background: var(--page-bg-color, #F9F9F9);
padding: var(--spacing-2xl, 32rpx);
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.dash-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
margin-bottom: var(--spacing-lg, 20rpx);
&__title {
font-size: var(--font-2xl, 40rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__badge {
background: var(--bg-color-2, #F2F3F5);
border-radius: var(--radius-full, 9999rpx);
padding: 8rpx 24rpx;
}
&__badge-text {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
}
}
.stats-row {
display: flex;
gap: var(--spacing-md, 16rpx);
margin-bottom: var(--spacing-lg, 20rpx);
}
.stat-card {
flex: 1;
background: var(--card-bg-color, #FFFFFF);
border-radius: var(--radius-md, 16rpx);
padding: var(--spacing-md, 16rpx);
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178, 0.06));
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
&__value {
font-size: var(--font-2xl, 40rpx);
font-weight: 700;
font-family: 'Inter', sans-serif;
}
&__label {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
}
}
.chart-card {
background: var(--card-bg-color, #FFFFFF);
border-radius: var(--radius-md, 16rpx);
padding: var(--spacing-lg, 20rpx);
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178, 0.06));
margin-bottom: var(--spacing-lg, 20rpx);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg, 20rpx);
}
&__title {
font-size: var(--font-md, 28rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__period {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
}
}
.chart-bars {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 280rpx;
gap: 12rpx;
}
.chart-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
height: 100%;
justify-content: flex-end;
}
.chart-bar {
width: 52rpx;
min-height: 16rpx;
background: linear-gradient(180deg, var(--primary-color-light, #E8F0FF) 0%, var(--primary-color, #4F8CFF) 100%);
border-radius: 8rpx;
position: relative;
transition: height 0.6s ease;
&__tip {
position: absolute;
top: -36rpx;
left: 50%;
transform: translateX(-50%);
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
white-space: nowrap;
opacity: 0.8;
}
&__label {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
}
}
.health-row {
display: flex;
gap: var(--spacing-md, 16rpx);
margin-bottom: var(--spacing-lg, 20rpx);
}
.health-card {
flex: 1;
background: var(--card-bg-color, #FFFFFF);
border-radius: var(--radius-md, 16rpx);
padding: var(--spacing-md, 16rpx);
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178, 0.06));
display: flex;
align-items: center;
justify-content: space-between;
&__info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
&__label {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
}
&__value {
font-size: var(--font-xl, 36rpx);
font-weight: 700;
font-family: 'Inter', sans-serif;
}
&__dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
}
.alert-card {
background: var(--card-bg-color, #FFFFFF);
border-radius: var(--radius-md, 16rpx);
padding: var(--spacing-lg, 20rpx);
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178, 0.06));
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md, 16rpx);
}
&__title {
font-size: var(--font-md, 28rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__badge {
background: var(--danger-color, #EF4444);
border-radius: 50%;
width: 44rpx;
height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
&__badge-text {
font-size: var(--font-sm, 24rpx);
font-weight: 700;
color: var(--text-color-inverse, #FFFFFF);
}
}
.alert-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx;
border-radius: var(--radius-sm, 8rpx);
margin-bottom: 12rpx;
&:last-child { margin-bottom: 0; }
&--danger { background: var(--danger-color-light, #FEF2F2); }
&--warning { background: var(--warning-color-light, #FFF8E6); }
&--info { background: var(--info-color-light, #F2F3F5); }
&__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
&--danger { background: var(--danger-color, #EF4444); }
&--warning { background: var(--warning-color, #F59E0B); }
&--info { background: var(--info-color, #8892A3); }
}
&__text {
flex: 1;
font-size: var(--font-sm, 24rpx);
color: var(--text-color, #0A1628);
}
&__time {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
flex-shrink: 0;
}
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import type { DictDataForm, DictDataItem, DictTypeForm, DictTypeItem } from '@/api/module_system/dict'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { DictAPI } from '@/api/module_system/dict'
definePage({ name: 'work-dicts', style: { navigationBarTitleText: '字典管理' } })
const toast = useToast()
const searchName = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<DictTypeForm>({ dict_name: '', dict_type: '', status: 0, description: '' })
const STATUS_OPTIONS = [{ value: 0, label: '正常' }, { value: 1, label: '禁用' }]
/* ===== Type list ===== */
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<DictTypeItem>({
fetcher: p => DictAPI.getTypePage({ ...p, dict_name: searchName.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchName.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { dict_name: '', dict_type: '', status: 0, description: '' })
}
function openCreate() {
formTitle.value = '新增字典'
currentId.value = undefined
resetForm()
showForm.value = true
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await DictAPI.updateType(currentId.value, formData)
toast.success('更新成功')
}
else {
await DictAPI.createType(formData)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({ title: '提示', content: '确认删除?', success: async (res) => {
if (res.confirm) {
try {
await DictAPI.removeType([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
} })
}
/* ===== Data sub-list ===== */
const showDataSection = ref(false)
const showDataForm = ref(false)
const dataFormTitle = ref('')
const dataCurrentId = ref<number>()
const currentTypeId = ref(0)
const currentTypeCode = ref('')
const dataFormData = reactive<DictDataForm>({ dict_label: '', dict_value: '', dict_type: '', dict_type_id: 0, dict_sort: 1, status: 0, description: '', is_default: false })
const {
list: dataList,
total: dataTotal,
loading: dataLoading,
pageParams: dataPageParams,
loadData: loadDataList,
toFirst: dataToFirst,
loadPrev: dataLoadPrev,
loadNext: dataLoadNext,
} = useListPage<DictDataItem>({
fetcher: p => DictAPI.getDataPage({ ...p, dict_type_id: currentTypeId.value }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function loadDictData(item: DictTypeItem) {
currentTypeCode.value = item.dict_type
currentTypeId.value = item.id
dataFormData.dict_type = item.dict_type
dataFormData.dict_type_id = item.id
dataToFirst()
loadDataList()
showDataSection.value = true
}
function resetDataForm() {
Object.assign(dataFormData, { dict_label: '', dict_value: '', dict_sort: 1, status: 0, description: '', is_default: false, dict_type: currentTypeCode.value, dict_type_id: currentTypeId.value })
}
function openCreateData() {
dataFormTitle.value = '新增字典项'
dataCurrentId.value = undefined
resetDataForm()
showDataForm.value = true
}
async function openEditData(id: number) {
dataFormTitle.value = '编辑字典项'
dataCurrentId.value = id
try {
const detail = await DictAPI.getDataDetail(id)
Object.assign(dataFormData, {
dict_label: detail.dict_label || '',
dict_value: detail.dict_value || '',
dict_sort: detail.dict_sort ?? 1,
status: detail.status ?? 0,
description: detail.description || '',
is_default: detail.is_default ?? false,
dict_type: detail.dict_type || currentTypeCode.value,
dict_type_id: detail.dict_type_id || currentTypeId.value,
})
showDataForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取详情失败')) }
}
async function handleSubmitData() {
dataLoading.value = true
try {
if (dataCurrentId.value) {
await DictAPI.updateData(dataCurrentId.value, dataFormData)
toast.success('更新成功')
}
else {
await DictAPI.createData(dataFormData)
toast.success('创建成功')
}
showDataForm.value = false
loadDataList()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { dataLoading.value = false }
}
function handleDeleteData(id: number) {
uni.showModal({ title: '提示', content: '确认删除?', success: async (res) => {
if (res.confirm) {
try {
await DictAPI.removeData([id])
toast.success('删除成功')
loadDataList()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
} })
}
function closeDataSection() {
showDataSection.value = false
}
onReachBottom(() => {
if (!showDataSection.value && !loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view v-if="!showDataSection">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchName" placeholder="搜索字典名称" clearable class="flex-1" />
<wd-button size="small" type="primary" plain @click="onSearch">搜索</wd-button>
<wd-button size="small" plain @click="onReset">重置</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">+ 新增</wd-button>
</view>
<view class="px-sm">
<view class="admin-card">
<SkeletonPage v-if="loading" />
<ListEmpty v-else-if="list.length === 0" text="暂无字典" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" is-link @click="loadDictData(item)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.dict_name }}
</text><text class="text-xs text-muted block mt-xs">
类型: {{ item.dict_type }}
</text>
</view>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="item.status" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
</view>
<view v-else>
<view class="search-bar">
<view class="flex items-center gap-sm">
<u-icon name="arrow-left" size="20" @click="closeDataSection" /><text class="font-bold text-md">
{{ currentTypeCode }} - 字典项
</text>
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ dataTotal }} 项
</text><wd-button size="small" type="primary" @click="openCreateData">+ 新增</wd-button>
</view>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!dataLoading && dataList.length === 0" text="暂无字典项" /><wd-cell-group v-else>
<wd-cell v-for="item in dataList" :key="item.id" is-link @click="openEditData(item.id!)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.dict_label }}
</text><text class="text-xs text-muted block mt-xs">
值: {{ item.dict_value }}
</text>
</view>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="item.status" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDeleteData(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="dataPageParams.page_no" :page-size="dataPageParams.page_size" :total="dataTotal" @prev="dataLoadPrev" @next="dataLoadNext" />
<!-- Data form -->
<wd-popup v-model="showDataForm" position="bottom" round custom-style="max-height: 60vh; overflow-y: auto;" @close="showDataForm = false">
<view class="p-xl">
<wd-navbar :title="dataFormTitle" left-arrow @click-left="showDataForm = false" /><wd-form :model="dataFormData" class="mt-lg">
<wd-form-item label="标签" prop="dict_label" border>
<wd-input v-model="dataFormData.dict_label" placeholder="请输入" />
</wd-form-item><wd-form-item label="值" prop="dict_value" border>
<wd-input v-model="dataFormData.dict_value" placeholder="请输入" />
</wd-form-item><wd-form-item label="排序" border>
<wd-input v-model="dataFormData.dict_sort" type="number" placeholder="请输入" />
</wd-form-item><wd-form-item label="状态" border>
<wd-radio-group v-model="dataFormData.status">
<wd-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item><wd-form-item label="备注" border>
<wd-textarea v-model="dataFormData.description" placeholder="请输入" />
</wd-form-item>
</wd-form><view class="flex gap-md mt-xl">
<wd-button block plain @click="showDataForm = false">取消</wd-button><wd-button block type="primary" :loading="dataLoading" @click="handleSubmitData">保存</wd-button>
</view>
</view>
</wd-popup>
</view>
<!-- Type form (shared, outside v-if/v-else) -->
<u-popup :show="showForm" mode="bottom" :round="10" custom-style="max-height: 60vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<u-navbar :title="formTitle" left-icon="arrow-left" @left-click="showForm = false" /><u-form :model="formData" class="mt-lg">
<u-form-item label="字典名称" prop="dict_name" :border-bottom="true">
<u-input v-model="formData.dict_name" border="none" placeholder="请输入" />
</u-form-item><u-form-item label="字典类型" prop="dict_type" :border-bottom="true">
<u-input v-model="formData.dict_type" border="none" placeholder="请输入" />
</u-form-item><u-form-item label="状态" :border-bottom="true">
<u-radio-group v-model="formData.status">
<u-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :name="opt.value" :label="opt.value">
{{ opt.label }}
</u-radio>
</u-radio-group>
</u-form-item><u-form-item label="备注" :border-bottom="true">
<u-textarea v-model="formData.description" border="none" placeholder="请输入" />
</u-form-item>
</u-form><view class="flex gap-md mt-xl">
<u-button :block="true" :plain="true" text="取消" @click="showForm = false" /><u-button :block="true" type="primary" :loading="loading" text="保存" @click="handleSubmit" />
</view>
</view>
</u-popup>
</view>
</template>
@@ -0,0 +1,186 @@
<script setup lang="ts">
import type { NoticeForm, NoticeItem } from '@/api/module_system/notice'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { NoticeAPI } from '@/api/module_system/notice'
definePage({ name: 'work-notices', style: { navigationBarTitleText: '通知公告' } })
const toast = useToast()
const searchTitle = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<NoticeForm>({ notice_title: '', notice_content: '', notice_type: '1', status: 0, description: '' })
const NOTICE_TYPE_OPTIONS = [{ value: '1', label: '通知' }, { value: '2', label: '公告' }]
const STATUS_OPTIONS = [{ value: 0, label: '草稿' }, { value: 1, label: '已发布' }, { value: 2, label: '已归档' }]
function statusLabel(s: number | string | undefined) {
const n = Number(s)
if (n === 1)
return 'published'
if (n === 2)
return 'archived'
return 'draft'
}
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<NoticeItem>({
fetcher: p => NoticeAPI.getPage({ ...p, notice_title: searchTitle.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchTitle.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { notice_title: '', notice_content: '', notice_type: '1', status: 0, description: '' })
}
function openCreate() {
formTitle.value = '新增公告'
currentId.value = undefined
resetForm()
showForm.value = true
}
async function openEdit(id: number) {
formTitle.value = '编辑公告'
currentId.value = id
try {
const detail = await NoticeAPI.getDetail(id)
Object.assign(formData, {
notice_title: detail.notice_title || '',
notice_content: detail.notice_content || '',
notice_type: detail.notice_type || '1',
status: detail.status ?? 0,
description: detail.description || '',
})
showForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取详情失败')) }
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await NoticeAPI.update(currentId.value, formData)
toast.success('更新成功')
}
else {
await NoticeAPI.create(formData)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await NoticeAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<u-input v-model="searchTitle" placeholder="搜索公告标题" clearable border="surround" class="flex-1" />
<u-button size="mini" type="primary" :plain="true" text="搜索" @click="onSearch" />
<u-button size="mini" :plain="true" text="重置" @click="onReset" />
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">+ 新增</wd-button>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无公告" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" is-link @click="openEdit(item.id!)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.notice_title }}
</text><text class="text-xs text-muted block mt-xs">
{{ item.description || '' }}
</text>
</view>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="statusLabel(item.status)" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="公告标题" prop="notice_title" border>
<wd-input v-model="formData.notice_title" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="公告内容" prop="notice_content" border>
<wd-textarea v-model="formData.notice_content" placeholder="请输入内容" />
</wd-form-item>
<wd-form-item label="公告类型" border>
<wd-radio-group v-model="formData.notice_type">
<wd-radio v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="状态" border>
<wd-radio-group v-model="formData.status">
<wd-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="备注" border>
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="flex gap-md mt-xl">
<wd-button block plain @click="showForm = false">取消</wd-button><wd-button block type="primary" :loading="loading" @click="handleSubmit">保存</wd-button>
</view>
</view>
</wd-popup>
</template>
</view>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import type { ParamsForm, ParamsItem } from '@/api/module_system/params'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { computed, reactive, ref } from 'vue'
import { ParamsAPI } from '@/api/module_system/params'
definePage({ name: 'work-params', style: { navigationBarTitleText: '参数管理' } })
const toast = useToast()
const loading = ref(false)
const list = ref<ParamsItem[]>([])
const searchName = ref('')
const showForm = ref(false)
const currentId = ref<number>()
const formData = reactive<ParamsForm>({ config_name: '', config_key: '', config_value: '', config_type: false, status: 0, description: '' })
const STATUS_OPTIONS = [{ value: 0, label: '正常' }, { value: 1, label: '禁用' }]
const filteredList = computed(() => {
if (!searchName.value)
return list.value
const keyword = searchName.value.toLowerCase()
return list.value.filter(item =>
(item.config_name || '').toLowerCase().includes(keyword)
|| (item.config_key || '').toLowerCase().includes(keyword),
)
})
function displayName(item: ParamsItem) {
return item.config_name || '未知'
}
function displayKey(item: ParamsItem) {
return item.config_key || ''
}
function displayValue(item: ParamsItem) {
return item.config_value || ''
}
async function loadData() {
loading.value = true
try {
const res = await ParamsAPI.getInfo()
list.value = res || []
}
catch (e) { toast.error(getErrorMessage(e, '加载失败')) }
finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
function onSearch() { /* 前端实时过滤 */ }
function onReset() {
searchName.value = ''
}
function openEdit(item: ParamsItem) {
currentId.value = item.id
Object.assign(formData, {
config_name: displayName(item),
config_key: displayKey(item),
config_value: displayValue(item),
config_type: item.config_type ?? false,
status: item.status ?? 0,
description: item.description || '',
})
showForm.value = true
}
async function handleSubmit() {
loading.value = true
try {
await ParamsAPI.update(currentId.value!, formData)
toast.success('更新成功')
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchName" placeholder="搜索参数名称/键名" clearable class="flex-1" />
<wd-button size="small" type="primary" plain @click="onSearch">搜索</wd-button>
<wd-button size="small" plain @click="onReset">重置</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ filteredList.length }} 条
</text>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="filteredList.length === 0" text="暂无数据" />
<wd-cell-group v-else>
<wd-cell v-for="item in filteredList" :key="item.id" is-link @click="openEdit(item)">
<template #title>
<view>
<text class="font-medium text-md">
{{ displayName(item) }}
</text><text class="text-xs text-muted block mt-xs">
{{ `键: ${displayKey(item)}` }}
</text>
</view>
</template>
<template #label>
<text class="text-xs text-muted">
{{ displayValue(item) || '-' }}
</text>
</template>
<StatusBadge :status="item.status" />
</wd-cell>
</wd-cell-group>
</view>
</view>
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar title="编辑参数" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="参数名称" prop="config_name" border>
<wd-input v-model="formData.config_name" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="参数键名" prop="config_key" border>
<wd-input v-model="formData.config_key" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="参数键值" border>
<wd-textarea v-model="formData.config_value" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="状态" border>
<wd-radio-group v-model="formData.status">
<wd-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="备注" border>
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="flex gap-md mt-xl">
<wd-button block plain @click="showForm = false">取消</wd-button>
<wd-button block type="primary" :loading="loading" @click="handleSubmit">保存</wd-button>
</view>
</view>
</wd-popup>
</template>
</view>
</template>
@@ -0,0 +1,500 @@
<script setup lang="ts">
import type { TicketComment, TicketItem } from '@/api/module_system/ticket'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { TicketAPI } from '@/api/module_system/ticket'
import SkeletonPage from '@/components/SkeletonPage.vue'
import { logger } from '@/utils/logger'
definePage({
name: 'work-ticket-detail',
style: { navigationBarTitleText: '工单详情' },
})
const toast = useToast()
const loading = ref(false)
const ticket = ref<TicketItem | null>(null)
const comments = ref<TicketComment[]>([])
const commentTotal = ref(0)
const commentPage = reactive({ page_no: 1, page_size: 20 })
const commentText = ref('')
const submitting = ref(false)
const ticketId = ref(0)
async function loadTicket() {
if (!ticketId.value)
return
loading.value = true
try {
ticket.value = await TicketAPI.getDetail(ticketId.value)
}
catch (e) {
toast.error(getErrorMessage(e, '加载工单详情失败'))
}
finally {
loading.value = false
}
}
async function loadComments() {
if (!ticketId.value)
return
try {
const res = await TicketAPI.getComments(ticketId.value, { page_no: commentPage.page_no, page_size: commentPage.page_size })
comments.value = res.list || []
commentTotal.value = res.total || 0
}
catch (e) {
logger.error('加载评论失败', e)
}
}
async function submitComment() {
if (!commentText.value.trim())
return toast.warning('请输入评论内容')
submitting.value = true
try {
await TicketAPI.createComment(ticketId.value, { content: commentText.value.trim() })
commentText.value = ''
toast.success('评论成功')
loadComments()
}
catch (e) {
toast.error(getErrorMessage(e, '评论失败'))
}
finally {
submitting.value = false
}
}
function getTypeLabel(type?: string) {
const map: Record<string, string> = { suggestion: '建议', bug: '缺陷', optimize: '优化', other: '其他' }
return map[type || ''] || type || '其他'
}
function getTypeColor(type?: string) {
const map: Record<string, string> = { suggestion: '#4F8CFF', bug: '#EF4444', optimize: '#F59E0B', other: '#6B7280' }
return map[type || ''] || '#6B7280'
}
function getStatusLabel(status?: string | number) {
const map: Record<string, string> = { 0: '待处理', 1: '处理中', 2: '已完成', 3: '已关闭' }
return map[String(status ?? '')] || '未知'
}
function getStatusColor(status?: string | number) {
const map: Record<string, string> = { 0: '#F59E0B', 1: '#4F8CFF', 2: '#10B981', 3: '#6B7280' }
return map[String(status ?? '')] || '#6B7280'
}
function parseImages(images?: string): string[] {
if (!images)
return []
try {
return JSON.parse(images)
}
catch {
return []
}
}
function previewImages(images: string[], current: number) {
uni.previewImage({ urls: images, current })
}
onPullDownRefresh(async () => {
try {
await Promise.all([loadTicket(), loadComments()])
}
finally {
uni.stopPullDownRefresh()
}
})
onLoad((options) => {
ticketId.value = Number(options?.id || 0)
if (ticketId.value) {
loadTicket()
loadComments()
}
})
</script>
<template>
<view class="detail-page">
<SkeletonPage v-if="loading && !ticket" :rows="5" />
<template v-else-if="ticket">
<!-- Ticket Header -->
<view class="ticket-header fade-in-up">
<text class="ticket-header__title">
{{ ticket.title || '未命名工单' }}
</text>
<view class="ticket-header__badges">
<view class="badge" :style="{ background: `${getTypeColor(ticket.ticket_type)}18`, color: getTypeColor(ticket.ticket_type) }">
{{ getTypeLabel(ticket.ticket_type) }}
</view>
<view class="badge" :style="{ background: `${getStatusColor(ticket.status)}18`, color: getStatusColor(ticket.status) }">
{{ getStatusLabel(ticket.status) }}
</view>
</view>
</view>
<!-- Ticket Info -->
<view class="ticket-info fade-in-up-1">
<view class="info-row">
<text class="info-row__label">
创建时间
</text>
<text class="info-row__value">
{{ ticket.created_time || '—' }}
</text>
</view>
<view class="info-row">
<text class="info-row__label">
处理人
</text>
<text class="info-row__value">
{{ ticket.assigned_by?.name || '未分配' }}
</text>
</view>
<view class="info-row">
<text class="info-row__label">
更新时间
</text>
<text class="info-row__value">
{{ ticket.updated_time || '—' }}
</text>
</view>
</view>
<!-- Ticket Content -->
<view v-if="ticket.ticket_content || ticket.summary" class="ticket-content fade-in-up-2">
<text class="section-title">
工单内容
</text>
<text class="ticket-content__text">
{{ ticket.summary || ticket.ticket_content }}
</text>
</view>
<!-- Images -->
<view v-if="parseImages(ticket.images).length > 0" class="ticket-images fade-in-up-2">
<text class="section-title">
附件图片
</text>
<view class="images-grid">
<image
v-for="(img, idx) in parseImages(ticket.images)"
:key="img"
:src="img"
class="images-grid__item"
mode="aspectFill"
lazy-load
@click="previewImages(parseImages(ticket.images), idx)"
/>
</view>
</view>
<!-- Reply -->
<view v-if="ticket.reply" class="ticket-reply fade-in-up-3">
<text class="section-title">
处理回复
</text>
<view class="reply-box">
<text class="reply-box__text">
{{ ticket.reply }}
</text>
</view>
</view>
<!-- Comments -->
<view class="comments-section fade-in-up-3">
<view class="comments-header">
<text class="section-title">
评论 ({{ commentTotal }})
</text>
</view>
<ListEmpty v-if="comments.length === 0" text="暂无评论,快来抢沙发" />
<view v-else class="comments-list">
<view v-for="comment in comments" :key="comment.id" class="comment-item">
<view class="comment-item__avatar">
<text class="comment-item__avatar-text">
{{ (comment.username || '匿').charAt(0) }}
</text>
</view>
<view class="comment-item__body">
<view class="comment-item__header">
<text class="comment-item__name">
{{ comment.username || '匿名用户' }}
</text>
<text class="comment-item__time">
{{ comment.created_time || '' }}
</text>
</view>
<text class="comment-item__content">
{{ comment.content }}
</text>
</view>
</view>
<view v-if="commentTotal > comments.length" class="load-more" @click="commentPage.page_no++; loadComments()">
<text class="load-more__text">
加载更多
</text>
</view>
</view>
</view>
</template>
<ListEmpty v-else text="工单不存在或已删除" />
<!-- Comment Input -->
<view v-if="ticket" class="comment-bar">
<u-input
v-model="commentText"
placeholder="写下你的评论..."
clearable
border="surround"
style="flex:1"
/>
<u-button
size="mini"
type="primary"
:loading="submitting"
:disabled="!commentText.trim()"
text="发送"
@click="submitComment"
/>
</view>
</view>
</template>
<style lang="scss" scoped>
.detail-page {
padding: 0 32rpx 140rpx;
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Ticket Header ===== */
.ticket-header {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin: 24rpx 0 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: var(--text-color, #0A1628);
line-height: 1.4;
margin-bottom: 16rpx;
}
&__badges {
display: flex;
gap: 12rpx;
}
}
.badge {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-weight: 500;
}
/* ===== Ticket Info ===== */
.ticket-info {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8rpx 0;
&__label {
font-size: 26rpx;
color: var(--text-color-3, #6B7280);
}
&__value {
font-size: 26rpx;
color: var(--text-color, #0A1628);
font-weight: 500;
}
}
/* ===== Sections ===== */
.section-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
margin-bottom: 16rpx;
}
/* ===== Content ===== */
.ticket-content {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__text {
font-size: 28rpx;
color: var(--text-color-2, #4B5563);
line-height: 1.6;
}
}
/* ===== Images ===== */
.ticket-images {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
}
.images-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
&__item {
width: 200rpx;
height: 200rpx;
border-radius: 16rpx;
background: var(--page-bg-color, #F9F9F9);
}
}
/* ===== Reply ===== */
.ticket-reply {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
}
.reply-box {
background: var(--primary-color-light, rgba(1,77,178,0.06));
border-radius: 16rpx;
padding: 16rpx;
&__text {
font-size: 28rpx;
color: var(--text-color-2, #4B5563);
line-height: 1.6;
}
}
/* ===== Comments ===== */
.comments-section {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
}
.comments-header {
margin-bottom: 16rpx;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.comment-item {
display: flex;
gap: 16rpx;
&__avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), var(--primary-color-dark, #2970FF));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__avatar-text {
font-size: 28rpx;
font-weight: 600;
color: #FFFFFF;
}
&__body {
flex: 1;
min-width: 0;
}
&__header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
&__name {
font-size: 26rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__time {
font-size: 22rpx;
color: var(--text-color-4, #B0B0B0);
}
&__content {
font-size: 28rpx;
color: var(--text-color-2, #4B5563);
line-height: 1.5;
}
}
/* ===== Load more ===== */
.load-more {
display: flex;
align-items: center;
justify-content: center;
height: 72rpx;
&__text {
font-size: 26rpx;
color: var(--primary-color, #4F8CFF);
}
}
/* ===== Comment Bar ===== */
.comment-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 32rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: var(--card-bg-color, #FFFFFF);
border-top: 1rpx solid var(--border-color, #F0F0F0);
z-index: 100;
}
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import type { TicketForm, TicketItem } from '@/api/module_system/ticket'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { TicketAPI } from '@/api/module_system/ticket'
definePage({ name: 'work-tickets', style: { navigationBarTitleText: '工单管理' } })
const router = useRouter()
const toast = useToast()
const searchTitle = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const initForm: TicketForm = { title: '', ticket_content: '', ticket_type: '', status: undefined, description: '' }
const formData = reactive<TicketForm>({ ...initForm })
const showPickerType = ref(false)
const showPickerStatus = ref(false)
const TYPE_OPTIONS = [{ value: 'suggestion', label: '建议' }, { value: 'bug', label: '缺陷' }, { value: 'optimize', label: '优化' }, { value: 'other', label: '其他' }]
const STATUS_OPTIONS = [{ value: 0, label: '待处理' }, { value: 1, label: '处理中' }, { value: 2, label: '已完成' }, { value: 3, label: '已关闭' }]
interface PickerConfirmEvent { value: Array<string | number>, selectedOptions: any[] }
function handleTypeConfirm(e: PickerConfirmEvent) {
const item = TYPE_OPTIONS.find(o => o.value === e.value[0])
if (item)
formData.ticket_type = item.value
showPickerType.value = false
}
function handleStatusConfirm(e: PickerConfirmEvent) {
const item = STATUS_OPTIONS.find(o => o.value === e.value[0])
if (item)
formData.status = item.value
showPickerStatus.value = false
}
const TICKET_STATUS_MAP: Record<number, string> = { 0: 'pending', 1: 'processing', 2: 'completed', 3: 'closed' }
function ticketStatus(status: number | string | undefined) {
return TICKET_STATUS_MAP[Number(status)] || String(status)
}
function displayContent(item: TicketItem) {
return item.ticket_content || ''
}
function displayType(item: TicketItem) {
return item.ticket_type || ''
}
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<TicketItem>({
fetcher: p => TicketAPI.getPage({ ...p, title: searchTitle.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchTitle.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { ...initForm })
}
function openCreate() {
formTitle.value = '新增'
currentId.value = undefined
resetForm()
showForm.value = true
}
function navigateToDetail(id: number) {
router.push({ name: 'work-ticket-detail', query: { id } })
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await TicketAPI.update(currentId.value, { ...formData })
toast.success('更新成功')
}
else {
await TicketAPI.create({ ...formData })
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await TicketAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchTitle" placeholder="搜索标题" clearable class="flex-1" />
<wd-button size="small" type="primary" plain @click="onSearch">搜索</wd-button>
<wd-button size="small" plain @click="onReset">重置</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">+ 新增</wd-button>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无数据" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" is-link @click="navigateToDetail(item.id!)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.title }}
</text>
</view>
</template>
<template #label>
<text class="text-xs text-muted">
{{ displayType(item) ? `${displayType(item)} · ` : '' }}{{ displayContent(item) }}
</text>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="ticketStatus(item.status)" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
</template>
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="工单标题">
<wd-input v-model="formData.title" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="内容">
<wd-textarea v-model="formData.ticket_content" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="类型">
<view @click="showPickerType = true">
<wd-cell title="类型" :value="TYPE_OPTIONS.find(o => o.value === formData.ticket_type)?.label || '请选择'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerType" :columns="[TYPE_OPTIONS]" @confirm="handleTypeConfirm" @cancel="showPickerType = false" />
</wd-form-item>
<wd-form-item v-if="currentId" label="状态">
<view @click="showPickerStatus = true">
<wd-cell title="状态" :value="STATUS_OPTIONS.find(o => o.value === formData.status)?.label || '请选择'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerStatus" :columns="[STATUS_OPTIONS]" @confirm="handleStatusConfirm" @cancel="showPickerStatus = false" />
</wd-form-item>
<wd-form-item label="备注">
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="flex gap-md mt-xl">
<wd-button block plain @click="showForm = false">取消</wd-button><wd-button block type="primary" :loading="loading" @click="handleSubmit">保存</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
@@ -0,0 +1,497 @@
<script setup lang="ts">
import type { DeptItem } from '@/api/module_system/dept'
import type { UserForm } from '@/api/module_system/user'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { DeptAPI } from '@/api/module_system/dept'
import { RoleAPI } from '@/api/module_system/role'
import UserAPI from '@/api/module_system/user'
import SkeletonPage from '@/components/SkeletonPage.vue'
definePage({
name: 'work-user-detail',
style: { navigationBarTitleText: '用户详情' },
})
const toast = useToast()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const userId = ref<number>()
const user = ref<UserForm>({})
const deptOptions = ref<{ value: number, label: string }[]>([])
const roleOptions = ref<{ value: number, label: string }[]>([])
const showPickerDept = ref(false)
const showPickerRole = ref(false)
interface PickerConfirmEvent { value: Array<string | number>, selectedOptions: any[] }
function handleDeptConfirm(e: PickerConfirmEvent) {
const item = deptOptions.value.find(o => o.value === e.value[0])
if (item)
user.value.dept_id = item.value
showPickerDept.value = false
}
function handleRoleConfirm(e: PickerConfirmEvent) {
const item = roleOptions.value.find(o => o.value === e.value[0])
if (item)
user.value.role_ids = [item.value]
showPickerRole.value = false
}
const STATUS_OPTIONS = [
{ value: 1, label: '正常', color: '#10B981', bg: '#E8F8F1' },
{ value: 0, label: '禁用', color: '#EF4444', bg: '#FEF2F2' },
]
const GENDER_OPTIONS = [
{ value: 0, label: '男' },
{ value: 1, label: '女' },
{ value: 2, label: '未知' },
]
function statusBadge(status?: number) {
return STATUS_OPTIONS.find(s => s.value === status) || STATUS_OPTIONS[1]
}
function genderLabel(gender?: number) {
return GENDER_OPTIONS.find(g => g.value === gender)?.label || '未知'
}
function flattenDept(items: DeptItem[], prefix = ''): { value: number, label: string }[] {
let r: { value: number, label: string }[] = []
for (const item of items) {
r.push({ value: item.id, label: prefix + item.name })
if (item.children?.length)
r = r.concat(flattenDept(item.children, `${prefix} `))
}
return r
}
async function loadOptions() {
try {
const [deptTree, roleRes] = await Promise.all([
DeptAPI.getTree().catch(() => [] as DeptItem[]),
RoleAPI.getOptions().catch(() => []),
])
deptOptions.value = flattenDept(deptTree || [])
roleOptions.value = (roleRes || []).map(r => ({ value: r.value, label: r.label }))
}
catch { /* silent */ }
}
async function loadUser() {
if (!userId.value)
return
loading.value = true
try {
const res = await UserAPI.getUserDetail(userId.value)
user.value = res
}
catch (e) {
toast.error(getErrorMessage(e, '加载用户详情失败'))
}
finally {
loading.value = false
}
}
async function handleSave() {
if (!user.value.id)
return
saving.value = true
try {
await UserAPI.updateUser(user.value)
toast.success('保存成功')
isEdit.value = false
}
catch (e) {
toast.error(getErrorMessage(e, '保存失败'))
}
finally {
saving.value = false
}
}
function handleToggleStatus() {
const newStatus = user.value.status === 1 ? 0 : 1
const action = newStatus === 0 ? '禁用' : '启用'
uni.showModal({
title: '确认操作',
content: `确认${action}该用户吗?`,
success: async (res) => {
if (res.confirm) {
try {
user.value.status = newStatus
await UserAPI.updateUser(user.value)
toast.success(`${action}成功`)
}
catch (e) {
toast.error(getErrorMessage(e, '操作失败'))
}
}
},
})
}
function goBack() {
router.back()
}
onLoad((query) => {
userId.value = Number(query?.id)
loadOptions()
loadUser()
})
</script>
<template>
<view class="user-detail-page">
<SkeletonPage v-if="loading" :rows="6" />
<template v-else>
<!-- Nav -->
<view class="detail-nav">
<text class="detail-nav__back" @click="goBack">
← 返回
</text>
<text class="detail-nav__title">
用户详情
</text>
<view class="detail-nav__edit" @click="isEdit = !isEdit">
<text class="detail-nav__edit-text">
{{ isEdit ? '取消' : '编辑' }}
</text>
</view>
</view>
<!-- Avatar -->
<view class="detail-avatar-area">
<view class="detail-avatar">
<text class="detail-avatar__text">
{{ (user.name || user.username || '?').charAt(0) }}
</text>
</view>
<text class="detail-avatar__name">
{{ user.name || user.username || '未知用户' }}
</text>
</view>
<!-- View mode -->
<view v-if="!isEdit" class="detail-card">
<view class="detail-row">
<text class="detail-row__label">
用户名
</text>
<text class="detail-row__value">
{{ user.username || '-' }}
</text>
</view>
<view class="detail-divider" />
<view class="detail-row">
<text class="detail-row__label">
邮箱
</text>
<text class="detail-row__value">
{{ user.email || '-' }}
</text>
</view>
<view class="detail-divider" />
<view class="detail-row">
<text class="detail-row__label">
手机号
</text>
<text class="detail-row__value">
{{ user.mobile || '-' }}
</text>
</view>
<view class="detail-divider" />
<view class="detail-row">
<text class="detail-row__label">
性别
</text>
<text class="detail-row__value">
{{ genderLabel(user.gender) }}
</text>
</view>
<view class="detail-divider" />
<view class="detail-row">
<text class="detail-row__label">
角色
</text>
<text class="detail-row__value" style="color: var(--primary-color);">
{{ user.role_names?.join(', ') || roleOptions.find(r => user.role_ids?.includes(r.value))?.label || '-' }}
</text>
</view>
<view class="detail-divider" />
<view class="detail-row">
<text class="detail-row__label">
部门
</text>
<text class="detail-row__value">
{{ user.dept_name || deptOptions.find(d => d.value === user.dept_id)?.label || '-' }}
</text>
</view>
<view class="detail-divider" />
<view class="detail-row">
<text class="detail-row__label">
状态
</text>
<view class="detail-status" :style="{ background: statusBadge(user.status).bg, color: statusBadge(user.status).color }">
<text class="detail-status__text">
{{ statusBadge(user.status).label }}
</text>
</view>
</view>
</view>
<!-- Edit mode -->
<view v-else class="detail-card">
<view class="form-row">
<text class="form-row__label">
用户名
</text>
<wd-input v-model="user.username" placeholder="请输入用户名" />
</view>
<view class="detail-divider" />
<view class="form-row">
<text class="form-row__label">
姓名
</text>
<wd-input v-model="user.name" placeholder="请输入姓名" />
</view>
<view class="detail-divider" />
<view class="form-row">
<text class="form-row__label">
邮箱
</text>
<wd-input v-model="user.email" placeholder="请输入邮箱" />
</view>
<view class="detail-divider" />
<view class="form-row">
<text class="form-row__label">
手机号
</text>
<wd-input v-model="user.mobile" placeholder="请输入手机号" />
</view>
<view class="detail-divider" />
<view class="form-row">
<text class="form-row__label">
性别
</text>
<wd-radio-group v-model="user.gender">
<wd-radio v-for="g in GENDER_OPTIONS" :key="g.value" :value="g.value">
{{ g.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="detail-divider" />
<view class="form-row">
<text class="form-row__label">
部门
</text>
<view style="flex:1" @click="showPickerDept = true">
<wd-cell :value="deptOptions.find(o => o.value === user.dept_id)?.label || '选择部门'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerDept" :columns="[deptOptions]" @confirm="handleDeptConfirm" @cancel="showPickerDept = false" />
</view>
<view class="detail-divider" />
<view class="form-row">
<text class="form-row__label">
角色
</text>
<view style="flex:1" @click="showPickerRole = true">
<wd-cell :value="roleOptions.find(o => o.value === user.role_ids?.[0])?.label || '选择角色'" is-link :border="false" />
</view>
<wd-picker :visible="showPickerRole" :columns="[roleOptions]" @confirm="handleRoleConfirm" @cancel="showPickerRole = false" />
</view>
</view>
<!-- Actions -->
<view v-if="isEdit" class="detail-actions">
<view class="detail-btn detail-btn--primary" @click="handleSave">
<text class="detail-btn__text">
保存修改
</text>
</view>
</view>
<view v-else class="detail-actions">
<view class="detail-btn detail-btn--primary" @click="isEdit = true">
<text class="detail-btn__text">
编辑资料
</text>
</view>
<view
class="detail-btn detail-btn--danger"
@click="handleToggleStatus"
>
<text class="detail-btn__text">
{{ user.status === 1 ? '禁用账号' : '启用账号' }}
</text>
</view>
</view>
</template>
</view>
</template>
<style lang="scss" scoped>
.user-detail-page {
min-height: 100vh;
background: var(--page-bg-color, #F9F9F9);
padding: var(--spacing-lg, 16px);
padding-bottom: calc(var(--spacing-lg, 16px) + env(safe-area-inset-bottom));
}
.detail-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin-bottom: var(--spacing-lg, 16px);
&__back {
font-size: var(--font-md, 28rpx);
color: var(--primary-color, #4F8CFF);
}
&__title {
font-size: var(--font-xl, 36rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__edit {
background: var(--primary-color, #4F8CFF);
border-radius: var(--radius-full, 9999rpx);
padding: 4px 14px;
}
&__edit-text {
font-size: var(--font-sm, 24rpx);
color: #FFFFFF;
}
}
.detail-avatar-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: var(--spacing-lg, 16px);
}
.detail-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), var(--primary-color-dark, #2970FF));
display: flex;
align-items: center;
justify-content: center;
&__text {
font-size: 32px;
font-weight: 700;
color: #FFFFFF;
}
&__name {
font-size: var(--font-xl, 36rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
}
.detail-card {
background: var(--card-bg-color, #FFFFFF);
border-radius: var(--radius-md, 16rpx);
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
margin-bottom: var(--spacing-lg, 16px);
overflow: hidden;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
&__label {
font-size: var(--font-md, 28rpx);
color: var(--text-color-3, #6B7280);
}
&__value {
font-size: var(--font-md, 28rpx);
color: var(--text-color, #0A1628);
}
}
.detail-divider {
height: 1px;
background: var(--border-color, #EAEAEA);
margin: 0 16px;
}
.detail-status {
padding: 4px 12px;
border-radius: var(--radius-full, 9999rpx);
font-size: var(--font-xs, 20rpx);
font-weight: 500;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
gap: 12px;
&__label {
font-size: var(--font-md, 28rpx);
color: var(--text-color-3, #6B7280);
flex-shrink: 0;
width: 60px;
}
:deep(.wd-input) {
flex: 1;
}
}
.detail-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-md, 12px);
}
.detail-btn {
height: 48px;
border-radius: var(--radius-full, 9999rpx);
display: flex;
align-items: center;
justify-content: center;
&--primary {
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), var(--primary-color-dark, #2970FF));
box-shadow: 0 4px 12px rgba(1, 77, 178, 0.25);
}
&--danger {
background: var(--danger-color-light, #FEF2F2);
}
&__text {
font-size: var(--font-lg, 32rpx);
font-weight: 600;
color: #FFFFFF;
}
&--danger &__text {
color: var(--danger-color, #EF4444);
font-weight: 500;
}
}
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import type { VersionForm, VersionItem } from '@/api/module_system/version'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { VersionAPI } from '@/api/module_system/version'
definePage({ name: 'work-versions', style: { navigationBarTitleText: '版本管理' } })
const toast = useToast()
const searchName = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const initForm: VersionForm = { version: '', title: '', date: '', content: '', description: '', sort: 0, status: 0 }
const formData = reactive<VersionForm>({ ...initForm })
const STATUS_MAP: Record<number, string> = { 0: 'draft', 1: 'published', 2: 'deprecated' }
const STATUS_LABELS: Record<number, string> = { 0: '草稿', 1: '已发布', 2: '已废弃' }
const STATUS_OPTIONS = [{ value: 0, label: '草稿' }, { value: 1, label: '已发布' }, { value: 2, label: '已废弃' }]
function getStatus(status: number | string | undefined) {
return STATUS_MAP[Number(status)] || 'draft'
}
function getStatusLabel(status: number | string | undefined) {
return STATUS_LABELS[Number(status)] || '未知'
}
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<VersionItem>({
fetcher: p => VersionAPI.getPage({ ...p, title: searchName.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchName.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { ...initForm })
}
function openCreate() {
formTitle.value = '新增'
currentId.value = undefined
resetForm()
showForm.value = true
}
async function openEdit(id: number) {
formTitle.value = '编辑'
currentId.value = id
try {
const detail = await VersionAPI.getDetail(id)
Object.assign(formData, {
version: detail.version || '',
title: detail.title || '',
date: detail.date || '',
content: detail.content || '',
description: detail.description || '',
sort: detail.sort ?? 0,
status: detail.status ?? 0,
})
showForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取详情失败')) }
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await VersionAPI.update(currentId.value, { ...formData })
toast.success('更新成功')
}
else {
await VersionAPI.create({ ...formData })
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await VersionAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<u-input v-model="searchName" placeholder="搜索版本标题" clearable border="surround" class="flex-1" />
<u-button size="mini" type="primary" :plain="true" text="搜索" @click="onSearch" />
<u-button size="mini" :plain="true" text="重置" @click="onReset" />
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">+ 新增</wd-button>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无数据" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" is-link @click="openEdit(item.id!)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.title || '未命名' }}
</text><text class="text-xs text-muted block mt-xs">
v{{ item.version || '-' }}{{ item.date ? ` · ${item.date}` : '' }}
</text>
</view>
</template>
<template #label>
<text class="text-xs text-muted">
{{ getStatusLabel(item.status) }}
</text>
</template>
<view class="flex items-center gap-xs">
<StatusBadge :status="getStatus(item.status)" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form :model="formData" class="mt-lg">
<wd-form-item label="版本号">
<wd-input v-model="formData.version" placeholder="如 1.0.0" />
</wd-form-item>
<wd-form-item label="版本标题">
<wd-input v-model="formData.title" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="发布日期">
<wd-input v-model="formData.date" placeholder="如 2024-01-01" />
</wd-form-item>
<wd-form-item label="排序">
<wd-input v-model="formData.sort" type="number" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="状态">
<wd-radio-group v-model="formData.status">
<wd-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item label="更新内容">
<wd-textarea v-model="formData.content" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="备注">
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="flex gap-md mt-xl">
<wd-button block plain @click="showForm = false">取消</wd-button>
<wd-button block type="primary" :loading="loading" @click="handleSubmit">保存</wd-button>
</view>
</view>
</wd-popup>
</template>
</view>
</template>
@@ -0,0 +1,457 @@
<script setup lang="ts">
import type { AIModelConfig, AIModelForm } from '@/api/module_ai/chat'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { ChatAPI } from '@/api/module_ai/chat'
import SkeletonPage from '@/components/SkeletonPage.vue'
definePage({
name: 'work-ai-models',
style: { navigationBarTitleText: 'AI 模型配置' },
})
const toast = useToast()
const loading = ref(false)
const models = ref<AIModelConfig[]>([])
const showForm = ref(false)
const formTitle = ref('新建模型配置')
const editingId = ref<number | null>(null)
const submitting = ref(false)
const form = reactive<AIModelForm>({
name: '',
base_url: '',
api_key: '',
model_id: '',
temperature: 0.7,
})
async function loadModels() {
loading.value = true
try {
const res = await ChatAPI.getModels()
models.value = res || []
}
catch (e) {
toast.error(getErrorMessage(e, '加载模型列表失败'))
}
finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
function openCreate() {
formTitle.value = '新建模型配置'
editingId.value = null
Object.assign(form, { name: '', base_url: '', api_key: '', model_id: '', temperature: 0.7 })
showForm.value = true
}
function openEdit(model: AIModelConfig) {
formTitle.value = '编辑模型配置'
editingId.value = model.config_id
Object.assign(form, {
name: model.name,
base_url: model.base_url,
api_key: model.api_key,
model_id: model.model_id,
temperature: model.temperature ?? 0.7,
})
showForm.value = true
}
async function submitForm() {
if (!form.name.trim())
return toast.warning('请输入配置名称')
if (!form.base_url.trim())
return toast.warning('请输入 API 地址')
if (!form.model_id.trim())
return toast.warning('请输入模型 ID')
submitting.value = true
try {
if (editingId.value) {
await ChatAPI.updateModel(editingId.value, { ...form })
toast.success('更新成功')
}
else {
await ChatAPI.createModel({ ...form })
toast.success('创建成功')
}
showForm.value = false
loadModels()
}
catch (e) {
toast.error(getErrorMessage(e, '保存失败'))
}
finally {
submitting.value = false
}
}
async function handleDelete(configId: number) {
uni.showModal({
title: '提示',
content: '确认删除此模型配置?',
success: async (res) => {
if (!res.confirm)
return
try {
await ChatAPI.deleteModel(configId)
toast.success('删除成功')
loadModels()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
},
})
}
async function handleActivate(configId: number) {
uni.showModal({
title: '提示',
content: '确认切换到此模型配置?',
success: async (res) => {
if (!res.confirm)
return
try {
await ChatAPI.activateModel(configId)
toast.success('已切换激活模型')
loadModels()
}
catch (e) { toast.error(getErrorMessage(e, '切换失败')) }
},
})
}
onPullDownRefresh(() => {
loadModels()
})
onLoad(() => {
loadModels()
})
</script>
<template>
<view class="list-page">
<view class="create-btn fade-in-up" @click="openCreate">
<text class="create-btn__icon">
+
</text>
<text class="create-btn__text">
新建模型配置
</text>
</view>
<SkeletonPage v-if="loading && models.length === 0" :rows="3" />
<ListEmpty v-else-if="!loading && models.length === 0" text="暂无模型配置" />
<view v-else class="models-list">
<view
v-for="(model, idx) in models"
:key="model.config_id"
class="model-item" :class="[`fade-in-up-${Math.min(idx % 5 + 1, 5)}`, model.is_active && 'model-item--active']"
>
<view class="model-item__header">
<view class="model-item__info">
<text class="model-item__name">
{{ model.name || '未命名' }}
</text>
<view v-if="model.is_active" class="model-item__active-tag">
<text class="model-item__active-text">
● 使用中
</text>
</view>
</view>
<text class="model-item__model-id">
{{ model.model_id }}
</text>
</view>
<view class="model-item__meta">
<text class="model-item__url">
{{ model.base_url }}
</text>
<text class="model-item__temp">
温度: {{ model.temperature ?? '—' }}
</text>
</view>
<view class="model-item__actions">
<view
v-if="!model.is_active"
class="model-action model-action--primary btn-press"
@click="handleActivate(model.config_id)"
>
<text class="model-action__text">
⚡ 激活
</text>
</view>
<view class="model-action btn-press" @click="openEdit(model)">
<text class="model-action__text">
✎ 编辑
</text>
</view>
<view class="model-action model-action--danger btn-press" @click="handleDelete(model.config_id)">
<text class="model-action__text">
✕ 删除
</text>
</view>
</view>
</view>
</view>
<view style="height: 120rpx;" />
<!-- Form Popup -->
<u-popup
:show="showForm"
mode="bottom"
custom-style="border-radius: 32rpx 32rpx 0 0; padding-bottom: 40rpx;"
@close="showForm = false"
>
<view class="form-panel">
<view class="form-panel__header">
<text class="form-panel__title">
{{ formTitle }}
</text>
<text class="form-panel__close" @click="showForm = false">
✕
</text>
</view>
<view class="form-group">
<text class="form-group__label">
配置名称 *
</text>
<u-input v-model="form.name" placeholder="如:生产环境 GPT-4" clearable border="surround" />
</view>
<view class="form-group">
<text class="form-group__label">
模型 ID *
</text>
<u-input v-model="form.model_id" placeholder="如:gpt-4o / deepseek-chat" clearable border="surround" />
</view>
<view class="form-group">
<text class="form-group__label">
API 地址 *
</text>
<u-input v-model="form.base_url" placeholder="https://api.openai.com/v1" clearable border="surround" />
</view>
<view class="form-group">
<text class="form-group__label">
API Key *
</text>
<u-input v-model="form.api_key" placeholder="sk-..." password clearable border="surround" />
</view>
<view class="form-group">
<text class="form-group__label">
温度 (0-2)
</text>
<u-input v-model="form.temperature" type="number" placeholder="0.7" border="surround" />
</view>
<view class="form-actions">
<u-button :block="true" type="primary" :loading="submitting" :text="editingId ? '更新' : '创建'" @click="submitForm" />
</view>
</view>
</u-popup>
</view>
</template>
<style lang="scss" scoped>
.list-page {
padding: 0 32rpx;
padding-bottom: 40rpx;
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Create button ===== */
.create-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 80rpx;
background: var(--primary-color, #4F8CFF);
border-radius: 24rpx;
margin: 24rpx 0;
box-shadow: 0 4rpx 16rpx rgba(1, 77, 178, 0.2);
transition: all 0.15s ease;
&:active { opacity: 0.85; transform: scale(0.98); }
&__icon { font-size: 36rpx; color: #FFFFFF; font-weight: 300; }
&__text { font-size: 28rpx; color: #FFFFFF; font-weight: 600; }
}
/* ===== Models list ===== */
.models-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.model-item {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
border: 2rpx solid transparent;
transition: all 0.2s ease;
&--active {
border-color: var(--primary-color, #4F8CFF);
box-shadow: 0 4rpx 16rpx rgba(1, 77, 178, 0.12);
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12rpx;
}
&__info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12rpx;
}
&__name {
font-size: 30rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__active-tag {
background: rgba(16, 185, 129, 0.1);
padding: 2rpx 10rpx;
border-radius: 8rpx;
}
&__active-text {
font-size: 20rpx;
color: #10B981;
font-weight: 500;
}
&__model-id {
font-size: 22rpx;
color: var(--primary-color, #4F8CFF);
background: var(--primary-color-light, rgba(1,77,178,0.08));
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-family: 'SF Mono', 'Consolas', monospace;
flex-shrink: 0;
}
&__meta {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
&__url {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
font-family: 'SF Mono', 'Consolas', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__temp {
font-size: 22rpx;
color: var(--text-color-4, #B0B0B0);
flex-shrink: 0;
}
&__actions {
display: flex;
gap: 12rpx;
padding-top: 12rpx;
border-top: 1rpx solid var(--border-color, #F0F0F0);
}
}
.model-action {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 56rpx;
border-radius: 12rpx;
background: var(--bg-color-2, #F5F6F8);
transition: all 0.15s ease;
&--primary { background: var(--primary-color-light, rgba(1,77,178,0.08)); }
&--danger { background: var(--danger-color-light, #FEF2F2); }
&--primary .model-action__text { color: var(--primary-color, #4F8CFF); }
&--danger .model-action__text { color: var(--danger-color, #EF4444); }
&__text {
font-size: 22rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
white-space: nowrap;
}
&:active { opacity: 0.7; transform: scale(0.95); }
}
/* ===== Form Panel ===== */
.form-panel {
padding: 32rpx;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
&__title {
font-size: 34rpx;
font-weight: 700;
color: var(--text-color, #0A1628);
}
&__close {
font-size: 32rpx;
color: var(--text-color-3, #6B7280);
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-color-2, #F5F6F8);
&:active { opacity: 0.6; }
}
}
.form-group {
margin-bottom: 24rpx;
&__label {
display: block;
font-size: 26rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
margin-bottom: 12rpx;
}
}
.form-actions {
margin-top: 32rpx;
}
</style>
@@ -0,0 +1,248 @@
<script setup lang="ts">
import type { ChatMessage, ChatSession } from '@/api/module_ai/chat'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, ref } from 'vue'
import { ChatAPI } from '@/api/module_ai/chat'
definePage({ name: 'work-chat', style: { navigationBarTitleText: 'AI 助手' } })
const toast = useToast()
const sessions = ref<ChatSession[]>([])
const currentSession = ref<number | null>(null)
const currentTitle = ref('AI 助手')
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const loading = ref(false)
const showSessions = ref(false)
// ===== 消息窗口化渲染 =====
// 长会话只渲染最近 MAX_VISIBLE_MESSAGES 条,避免消息过多时 DOM 节点与 setData 数据量膨胀导致卡顿;
// 通过"加载更早消息"按步长扩大渲染窗口,保持浏览位置不跳变。
const MAX_VISIBLE_MESSAGES = 60
const INCREMENT = 40
const messageWindow = ref(MAX_VISIBLE_MESSAGES)
const scrollTarget = ref('')
const visibleMessages = computed(() => messages.value.slice(-messageWindow.value))
const hasOlderMessages = computed(() => messages.value.length > messageWindow.value)
function loadOlderMessages() {
messageWindow.value += INCREMENT
// 滚动到新增窗口边界,保持已读消息位置不跳变
scrollTarget.value = `msg-${INCREMENT - 1}`
nextTick(() => {
scrollTarget.value = ''
})
}
function resetWindow() {
messageWindow.value = MAX_VISIBLE_MESSAGES
scrollTarget.value = ''
}
async function loadSessions() {
loading.value = true
try {
const res = await ChatAPI.getSessions()
sessions.value = res.list || []
}
catch (e) {
sessions.value = []
toast.error(getErrorMessage(e, '加载会话列表失败'))
}
finally { loading.value = false }
}
async function createSession() {
try {
const res = await ChatAPI.createSession('新对话')
// 兼容后端可能返回 { data: ChatSession } 或直接 ChatSession 两种结构
const newSession = (res as { data?: ChatSession })?.data ?? (res as ChatSession)
if (newSession?.id) {
sessions.value.unshift(newSession)
selectSession(newSession.id, newSession.title)
}
toast.success('新会话已创建')
}
catch (e) {
toast.error(getErrorMessage(e, '创建会话失败'))
}
}
async function selectSession(id: number, title?: string) {
currentSession.value = id
currentTitle.value = title || `会话 ${id}`
showSessions.value = false
loading.value = true
try {
const detail = await ChatAPI.getDetail(id)
// 兼容后端可能返回 { messages: [] } 或 { data: { messages: [] } } 两种结构
const detailRes = detail as { messages?: ChatMessage[], data?: { messages?: ChatMessage[] } }
const msgs = detailRes?.messages || detailRes?.data?.messages || []
resetWindow()
messages.value = msgs.map(m => ({ role: m.role || 'ai', content: m.content || '', time: String(m.created_at || m.time || '') }))
}
catch (e) {
messages.value = []
toast.error(getErrorMessage(e, '加载会话消息失败'))
}
finally { loading.value = false }
}
async function deleteSession(id: number) {
uni.showModal({
title: '提示',
content: '确认删除该会话?',
success: async (r) => {
if (r.confirm) {
try {
await ChatAPI.removeSession([id])
sessions.value = sessions.value.filter(s => s.id !== id)
if (currentSession.value === id) {
currentSession.value = null
currentTitle.value = 'AI 助手'
messages.value = []
}
toast.success('已删除')
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
async function sendMessage() {
if (!inputText.value.trim())
return
if (!currentSession.value) {
toast.info('请先创建或选择会话')
return
}
const text = inputText.value.trim()
messages.value.push({ role: 'user', content: text, time: new Date().toLocaleTimeString() })
inputText.value = ''
loading.value = true
// Scroll to bottom
nextTick(() => {
// scroll-view auto-scroll
})
try {
const res = await ChatAPI.sendMessage(currentSession.value, text)
// 兼容后端可能返回 ChatMessage 或 { data: { content } } 或 { reply } 多种结构
const replyRes = res as { content?: string, reply?: string, data?: { content?: string } }
const reply = replyRes?.content || replyRes?.reply || replyRes?.data?.content || '已收到您的消息。'
messages.value.push({ role: 'ai', content: reply, time: new Date().toLocaleTimeString() })
}
catch (e) {
messages.value.push({ role: 'ai', content: getErrorMessage(e, '请求失败,请稍后重试。'), time: new Date().toLocaleTimeString() })
}
finally {
loading.value = false
}
}
onLoad(() => {
loadSessions()
messages.value = [{ role: 'ai', content: '你好!我是 FastapiAdmin AI 助手,有什么可以帮助你的?' }]
})
</script>
<template>
<view class="chat-page" style="display:flex;flex-direction:column;height:100vh;background:var(--bg-color);">
<!-- Header -->
<view style="display:flex;align-items:center;gap:var(--spacing-sm,12rpx);padding:var(--spacing-md,16rpx);border-bottom:1px solid var(--border-color);background:var(--card-bg-color);">
<u-button size="mini" :plain="true" @click="showSessions = !showSessions">
<u-icon name="list-dot" size="16" />
</u-button>
<text class="font-bold text-md flex-1 text-center truncate">
{{ currentTitle }}
</text>
<u-button size="mini" type="primary" :plain="true" @click="createSession">
<u-icon name="plus" size="16" />
</u-button>
</view>
<!-- Session panel overlay -->
<view v-if="showSessions" class="session-overlay" style="position:absolute;top:88rpx;left:0;right:0;bottom:0;background:var(--bg-color);z-index:100;overflow-y:auto;">
<view class="p-sm">
<view class="flex items-center justify-between mb-md px-xs">
<text class="font-bold text-lg">
会话列表
</text>
<u-button size="mini" type="primary" text="新建" @click="createSession" />
</view>
<SkeletonPage v-if="loading && sessions.length === 0" />
<view v-for="s in sessions" v-else :key="s.id" class="admin-card">
<u-cell :title="s.title" :label="s.created_at ? String(s.created_at) : ''" :is-link="true" @click="selectSession(s.id, s.title)">
<template #right>
<u-icon name="trash" size="18" color="var(--danger-color)" @click.stop="deleteSession(s.id)" />
</template>
</u-cell>
</view>
<ListEmpty v-if="!loading && sessions.length === 0" text="暂无会话,点击右上角新建" />
</view>
</view>
<!-- Messages -->
<scroll-view
scroll-y
class="chat-messages"
style="flex:1;padding:var(--spacing-md,16rpx);"
:scroll-into-view="scrollTarget || `msg-${visibleMessages.length - 1}`"
:scroll-with-animation="true"
>
<!-- 窗口化渲染:仅渲染最近 N 条消息,支持加载更早消息 -->
<view v-if="hasOlderMessages" style="padding:16rpx 0;text-align:center;">
<u-button size="mini" :plain="true" :text="`加载更早消息(${messages.length - visibleMessages.length} 条)`" @click="loadOlderMessages" />
</view>
<view v-for="(msg, i) in visibleMessages" :id="`msg-${i}`" :key="i" style="margin-bottom:24rpx;">
<view class="flex items-start gap-sm" :class="msg.role === 'user' ? 'flex-row-reverse' : ''">
<u-avatar :size="32" shape="round" :src="msg.role === 'user' ? '/static/images/default-avatar.png' : '/static/logo.png'" />
<view
class="chat-bubble"
:style="{
background: msg.role === 'user' ? 'var(--primary-color)' : 'var(--card-bg-color)',
color: msg.role === 'user' ? 'var(--text-color-inverse)' : 'var(--text-color)',
borderRadius: msg.role === 'user' ? '16rpx 16rpx 4rpx 16rpx' : '16rpx 16rpx 16rpx 4rpx',
border: msg.role === 'user' ? 'none' : '1px solid var(--border-color)',
}"
>
<text style="white-space:pre-wrap;font-size:var(--font-sm,24rpx);line-height:1.7;">
{{ msg.content }}
</text>
<text v-if="msg.time" style="font-size:var(--font-xs,20rpx);opacity:0.6;margin-top:8rpx;display:block;" :style="msg.role === 'user' ? 'text-align:right' : 'text-align:left'">
{{ msg.time }}
</text>
</view>
</view>
</view>
<!-- Loading indicator -->
<view v-if="loading" style="margin-bottom:24rpx;">
<view class="flex items-start gap-sm">
<u-avatar :size="32" shape="round" src="/static/logo.png" />
<view class="chat-bubble" style="background:var(--card-bg-color);border:1px solid var(--border-color);border-radius:16rpx 16rpx 16rpx 4rpx;">
<u-loading-icon />
</view>
</view>
</view>
</scroll-view>
<!-- Input -->
<view style="border-top:1px solid var(--border-color);padding:var(--spacing-md,16rpx);background:var(--card-bg-color);padding-bottom:calc(var(--spacing-md) + env(safe-area-inset-bottom));">
<view class="flex items-center gap-sm">
<u-input v-model="inputText" placeholder="输入消息..." :disabled="loading" class="flex-1" confirm-type="send" border="surround" @confirm="sendMessage" />
<u-button :loading="loading" :disabled="!inputText.trim() || !currentSession" type="primary" text="发送" @click="sendMessage" />
</view>
</view>
</view>
</template>
<style scoped>
.chat-bubble {
padding: 16rpx 24rpx;
max-width: 72%;
word-break: break-word;
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import type { DbTableItem, GenTableItem } from '@/api/module_generator/gencode'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { GencodeAPI } from '@/api/module_generator/gencode'
definePage({ name: 'work-gencode', style: { navigationBarTitleText: '代码生成器' } })
const toast = useToast()
const activeTab = ref('biztables')
const showDbPicker = ref(false)
const showPreview = ref(false)
const previewId = ref<number>(0)
const previewContent = ref('')
const {
list: bizTables,
total: bizTotal,
loading: bizLoading,
pageParams: bizPageParams,
loadData: loadBizTables,
loadPrev: bizLoadPrev,
loadNext: bizLoadNext,
} = useListPage<GenTableItem>({
fetcher: p => GencodeAPI.getPage({ page: p.page_no, page_size: p.page_size }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
const {
list: dbTables,
total: dbTotal,
loading: dbLoading,
pageParams: dbPageParams,
loadData: loadDbTables,
loadPrev: dbLoadPrev,
loadNext: dbLoadNext,
} = useListPage<DbTableItem>({
fetcher: p => GencodeAPI.getDbTables({ page: p.page_no, page_size: p.page_size }),
onError: e => toast.error(getErrorMessage(e, '加载数据库表失败')),
})
async function importTable(tableName: string) {
bizLoading.value = true
try {
await GencodeAPI.importTable(tableName)
toast.success(`${tableName} 导入成功`)
showDbPicker.value = false
loadBizTables()
}
catch (e) { toast.error(getErrorMessage(e, '导入失败')) }
finally { bizLoading.value = false }
}
async function previewCode(id: number) {
bizLoading.value = true
try {
const res = await GencodeAPI.preview(id)
previewContent.value = typeof res === 'string' ? res : JSON.stringify(res, null, 2)
previewId.value = id
showPreview.value = true
}
catch (e) { toast.error(getErrorMessage(e, '预览失败')) }
finally { bizLoading.value = false }
}
async function batchGenerate(ids: number[]) {
bizLoading.value = true
try {
await GencodeAPI.batchGenerate(ids)
toast.success('代码生成成功,请下载 ZIP 文件')
}
catch (e) { toast.error(getErrorMessage(e, '生成失败')) }
finally { bizLoading.value = false }
}
async function syncDb(tableName: string) {
uni.showModal({
title: '提示',
content: `确认同步表 ${tableName} 到数据库?`,
success: async (res) => {
if (res.confirm) {
bizLoading.value = true
try {
await GencodeAPI.syncDb(tableName)
toast.success('同步成功')
}
catch (e) { toast.error(getErrorMessage(e, '同步失败')) }
finally { bizLoading.value = false }
}
},
})
}
onPullDownRefresh(() => {
if (activeTab.value === 'biztables')
loadBizTables()
else loadDbTables()
})
onLoad(() => loadBizTables())
</script>
<template>
<view class="page-wraper">
<!-- Tabs -->
<view class="flex items-center gap-sm p-md" style="background:var(--card-bg-color);border-bottom:1px solid var(--border-color);">
<u-button :type="activeTab === 'biztables' ? 'primary' : 'default'" size="mini" text="业务表" @click="activeTab = 'biztables'; loadBizTables()" />
<u-button :type="activeTab === 'dbtables' ? 'primary' : 'default'" size="mini" text="数据库表" @click="activeTab = 'dbtables'; loadDbTables()" />
</view>
<!-- Business Tables -->
<template v-if="activeTab === 'biztables'">
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ bizTotal }} 张表
</text>
<u-button size="mini" type="primary" text="导入表" @click="showDbPicker = true" />
</view>
<SkeletonPage v-if="bizLoading" :rows="5" />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="bizTables.length === 0" text="暂无业务表,请从数据库导入" />
<u-cell-group v-else>
<u-cell v-for="item in bizTables" :key="item.id || item.table_name">
<template #title>
<view class="flex items-center gap-sm">
<u-icon name="list" size="18" color="var(--primary-color)" />
<view>
<text class="font-medium text-md">
{{ item.table_name || '-' }}
</text>
<text class="text-xs text-muted block">
{{ item.table_comment || '' }}
</text>
</view>
</view>
</template>
<template #right>
<u-button size="mini" type="primary" :plain="true" text="生成" @click="batchGenerate([item.id])" />
<u-button size="mini" :plain="true" text="预览" @click="previewCode(item.id)" />
</template>
</u-cell>
</u-cell-group>
</view>
</view>
<PaginationBar :current="bizPageParams.page_no" :page-size="bizPageParams.page_size" :total="bizTotal" @prev="bizLoadPrev" @next="bizLoadNext" />
</template>
</template>
<!-- Database Tables -->
<template v-else>
<view class="action-bar">
<text class="text-md font-bold text-muted">
数据库表列表
</text>
<u-button size="mini" type="primary" :loading="dbLoading" text="刷新" @click="loadDbTables" />
</view>
<SkeletonPage v-if="dbLoading" :rows="5" />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="dbTables.length === 0" text="暂无数据库表" />
<u-cell-group v-else>
<u-cell v-for="item in dbTables" :key="item.table_name">
<template #title>
<view class="flex items-center gap-sm">
<u-icon name="list" size="18" color="var(--success-color)" />
<view>
<text class="font-medium text-md font-mono">
{{ item.table_name || item.name || '-' }}
</text>
<text class="text-xs text-muted block">
{{ item.table_comment || item.comment || '' }}
</text>
</view>
</view>
</template>
<template #right>
<u-button size="mini" type="success" :plain="true" text="导入" @click="importTable(item.table_name || '')" />
</template>
</u-cell>
</u-cell-group>
</view>
</view>
<PaginationBar :current="dbPageParams.page_no" :page-size="dbPageParams.page_size" :total="dbTotal" @prev="dbLoadPrev" @next="dbLoadNext" />
</template>
</template>
<!-- Preview Popup -->
<u-popup :show="showPreview" mode="bottom" custom-style="max-height:80vh;overflow-y:auto;" :round="10" @close="showPreview = false">
<view class="p-xl">
<u-navbar title="代码预览" left-icon="arrow-left" @left-click="showPreview = false" />
<view class="flex items-center justify-between mt-lg mb-md">
<text class="font-bold text-md">
预览 ID: {{ previewId }}
</text>
<view class="flex gap-sm">
<u-button size="mini" type="primary" text="生成下载" @click="batchGenerate([previewId])" />
<u-button size="mini" type="success" text="同步数据库" @click="syncDb(bizTables.find(t => t.id === previewId)?.table_name || '')" />
</view>
</view>
<view class="code-block" style="background:var(--bg-color-3);border-radius:12rpx;padding:24rpx;font-family:monospace;font-size:22rpx;white-space:pre-wrap;overflow-x:auto;max-height:50vh;">
<text>{{ previewContent || '暂无预览内容' }}</text>
</view>
</view>
</u-popup>
<!-- Import DB Table Picker -->
<u-popup :show="showDbPicker" mode="bottom" custom-style="max-height:70vh;overflow-y:auto;" :round="10" @close="showDbPicker = false">
<view class="p-xl">
<u-navbar title="选择数据库表" left-icon="arrow-left" @left-click="showDbPicker = false" />
<view class="mt-lg">
<u-cell-group :border="true">
<u-cell v-for="item in dbTables" :key="item.table_name" :title="item.table_name || item.name" :label="item.table_comment || item.comment || ''" center>
<template #right>
<u-button size="mini" type="success" :plain="true" text="导入" @click="importTable(item.table_name || '')" />
</template>
</u-cell>
</u-cell-group>
<view v-if="dbTables.length === 0" class="empty-state mt-xl">
<text class="empty-state__text">
点击"数据库表"Tab 刷新表列表
</text>
</view>
</view>
</view>
</u-popup>
</view>
</template>
@@ -0,0 +1,524 @@
<script setup lang="ts">
import type { WorkflowItem } from '@/api/module_task/workflow/flow'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { WorkflowAPI } from '@/api/module_task/workflow/flow'
definePage({
name: 'work-workflow',
style: { navigationBarTitleText: '工作流管理' },
})
const toast = useToast()
const activeTab = ref<'flows' | 'nodes'>('flows')
// Flow form
const showFlowForm = ref(false)
const flowFormTitle = ref('新建工作流')
const flowForm = reactive({ name: '', description: '' })
const editingFlowId = ref<number | null>(null)
const { list: flowList, total: flowTotal, loading, pageParams, loadData, loadPrev, loadNext } = useListPage<WorkflowItem>({
fetcher: p => WorkflowAPI.getPage(p),
onError: e => toast.error(getErrorMessage(e, '加载工作流列表失败')),
})
function openCreateFlow() {
flowFormTitle.value = '新建工作流'
editingFlowId.value = null
flowForm.name = ''
flowForm.description = ''
showFlowForm.value = true
}
async function openEditFlow(id: number) {
flowFormTitle.value = '编辑工作流'
editingFlowId.value = id
try {
const detail = await WorkflowAPI.getDetail(id)
flowForm.name = detail.name || ''
flowForm.description = detail.description || ''
showFlowForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取工作流详情失败')) }
}
async function submitFlow() {
if (!flowForm.name.trim())
return toast.warning('请输入工作流名称')
try {
if (editingFlowId.value) {
await WorkflowAPI.update(editingFlowId.value, { ...flowForm })
toast.success('更新成功')
}
else {
await WorkflowAPI.create({ ...flowForm })
toast.success('创建成功')
}
showFlowForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '保存失败')) }
}
async function handlePublish(id: number, name: string) {
uni.showModal({
title: '提示',
content: `确认发布工作流「${name}」?发布后将生成最新版本。`,
success: async (res) => {
if (!res.confirm)
return
try {
await WorkflowAPI.publish(id)
toast.success('发布成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '发布失败')) }
},
})
}
async function handleExecute(id: number, name: string) {
uni.showModal({
title: '提示',
content: `确认立即执行工作流「${name}」?`,
success: async (res) => {
if (!res.confirm)
return
try {
const result = await WorkflowAPI.execute({ workflow_id: id })
toast.success(`执行完成:${JSON.stringify(result)}`)
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '执行失败')) }
},
})
}
async function handleDeleteFlow(ids: number[]) {
uni.showModal({
title: '提示',
content: '确认删除选中的工作流?此操作不可恢复。',
success: async (res) => {
if (!res.confirm)
return
try {
await WorkflowAPI.remove(ids)
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
},
})
}
// Status helpers
function getFlowStatusColor(status?: string) {
const map: Record<string, string> = { published: '#10B981', draft: '#6B7280', running: '#4F8CFF', completed: '#8B5CF6', failed: '#EF4444' }
return map[status || ''] || '#6B7280'
}
function getFlowStatusLabel(status?: string) {
const map: Record<string, string> = { published: '已发布', draft: '草稿', running: '运行中', completed: '已完成', failed: '失败' }
return map[status || ''] || status || '未知'
}
onReachBottom(() => {
if (activeTab.value === 'flows' && !loading.value)
loadNext()
})
onPullDownRefresh(() => {
if (activeTab.value === 'flows')
loadData()
})
onLoad(() => {
loadData()
})
</script>
<template>
<view class="list-page">
<!-- Tab switcher -->
<view class="tab-bar fade-in-up">
<view
class="tab-item" :class="[activeTab === 'flows' && 'tab-item--active']"
@click="activeTab = 'flows'"
>
<text class="tab-item__text">
工作流列表
</text>
</view>
<view
class="tab-item" :class="[activeTab === 'nodes' && 'tab-item--active']"
@click="activeTab = 'nodes'"
>
<text class="tab-item__text">
节点类型
</text>
</view>
</view>
<!-- Flow List Tab -->
<template v-if="activeTab === 'flows'">
<!-- Create button -->
<view class="create-btn fade-in-up-1" @click="openCreateFlow">
<text class="create-btn__icon">
+
</text>
<text class="create-btn__text">
新建工作流
</text>
</view>
<SkeletonPage v-if="loading && flowList.length === 0" :rows="4" />
<ListEmpty v-else-if="!loading && flowList.length === 0" text="暂无工作流" />
<view v-else class="flows-list">
<view v-for="(flow, idx) in flowList" :key="flow.id" class="flow-item" :class="[`fade-in-up-${Math.min(idx % 5 + 1, 5)}`]">
<view class="flow-item__header" @click="openEditFlow(flow.id)">
<view class="flow-item__info">
<text class="flow-item__name">
{{ flow.name || '未命名' }}
</text>
<text v-if="flow.version" class="flow-item__version">
v{{ flow.version }}
</text>
</view>
<view class="flow-item__badge" :style="{ background: `${getFlowStatusColor(flow.status)}18`, color: getFlowStatusColor(flow.status) }">
{{ getFlowStatusLabel(flow.status) }}
</view>
</view>
<text v-if="flow.description" class="flow-item__desc">
{{ flow.description }}
</text>
<view class="flow-item__meta">
<text v-if="flow.nodes" class="flow-item__count">
{{ flow.nodes.length }} 个节点
</text>
<text v-if="flow.updated_time" class="flow-item__time">
{{ flow.updated_time }}
</text>
</view>
<view class="flow-item__actions">
<view class="flow-action flow-action--primary btn-press" @click="handlePublish(flow.id, flow.name)">
<text class="flow-action__text">
⬆ 发布
</text>
</view>
<view class="flow-action flow-action--success btn-press" @click="handleExecute(flow.id, flow.name)">
<text class="flow-action__text">
▶ 执行
</text>
</view>
<view class="flow-action btn-press" @click="openEditFlow(flow.id)">
<text class="flow-action__text">
✎ 编辑
</text>
</view>
<view class="flow-action flow-action--danger btn-press" @click="handleDeleteFlow([flow.id])">
<text class="flow-action__text">
✕ 删除
</text>
</view>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="flowTotal" @prev="loadPrev" @next="loadNext" />
</view>
</template>
<!-- Node Types Tab (placeholder for future) -->
<template v-else>
<ListEmpty text="节点类型管理即将上线" />
</template>
<view style="height: 120rpx;" />
<!-- Flow Form Popup -->
<u-popup
:show="showFlowForm"
mode="bottom"
custom-style="border-radius: 32rpx 32rpx 0 0; padding-bottom: 40rpx;"
@close="showFlowForm = false"
>
<view class="form-panel">
<view class="form-panel__header">
<text class="form-panel__title">
{{ flowFormTitle }}
</text>
<text class="form-panel__close" @click="showFlowForm = false">
✕
</text>
</view>
<view class="form-group">
<text class="form-group__label">
工作流名称 *
</text>
<u-input v-model="flowForm.name" placeholder="请输入工作流名称" clearable border="surround" />
</view>
<view class="form-group">
<text class="form-group__label">
描述
</text>
<u-textarea
v-model="flowForm.description"
placeholder="请输入工作流描述"
:maxlength="500"
:autosize="{ minRows: 3, maxRows: 6 }"
border="surround"
/>
</view>
<view class="form-actions">
<u-button :block="true" type="primary" :text="editingFlowId ? '更新' : '创建'" @click="submitFlow" />
</view>
</view>
</u-popup>
</view>
</template>
<style lang="scss" scoped>
.list-page {
padding: 0 32rpx;
padding-bottom: 40rpx;
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Tab bar ===== */
.tab-bar {
display: flex;
gap: 0;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 8rpx;
margin: 24rpx 0;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 72rpx;
border-radius: 18rpx;
transition: all 0.2s ease;
&--active {
background: var(--primary-color, #4F8CFF);
.tab-item__text { color: #FFFFFF; font-weight: 600; }
}
&__text {
font-size: 28rpx;
color: var(--text-color-3, #6B7280);
font-weight: 500;
}
}
/* ===== Create button ===== */
.create-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 80rpx;
background: var(--primary-color, #4F8CFF);
border-radius: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(1, 77, 178, 0.2);
transition: all 0.15s ease;
&:active { opacity: 0.85; transform: scale(0.98); }
&__icon {
font-size: 36rpx;
color: #FFFFFF;
font-weight: 300;
}
&__text {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 600;
}
}
/* ===== Flows list ===== */
.flows-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.flow-item {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
cursor: pointer;
}
&__info {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
&__name {
font-size: 30rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__version {
font-size: 22rpx;
color: var(--primary-color, #4F8CFF);
background: var(--primary-color-light, rgba(1,77,178,0.08));
padding: 2rpx 10rpx;
border-radius: 8rpx;
font-family: 'SF Mono', 'Consolas', monospace;
flex-shrink: 0;
}
&__badge {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
flex-shrink: 0;
}
&__desc {
display: block;
font-size: 24rpx;
color: var(--text-color-2, #4B5563);
margin-bottom: 10rpx;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__meta {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
&__count {
font-size: 22rpx;
color: var(--text-color-3, #6B7280);
background: var(--bg-color-2, #F5F6F8);
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
&__time {
font-size: 22rpx;
color: var(--text-color-4, #B0B0B0);
}
&__actions {
display: flex;
gap: 12rpx;
padding-top: 12rpx;
border-top: 1rpx solid var(--border-color, #F0F0F0);
}
}
.flow-action {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 56rpx;
border-radius: 12rpx;
background: var(--bg-color-2, #F5F6F8);
transition: all 0.15s ease;
&--primary { background: var(--primary-color-light, rgba(1,77,178,0.08)); }
&--success { background: rgba(16, 185, 129, 0.08); }
&--danger { background: var(--danger-color-light, #FEF2F2); }
&--primary .flow-action__text { color: var(--primary-color, #4F8CFF); }
&--success .flow-action__text { color: #10B981; }
&--danger .flow-action__text { color: var(--danger-color, #EF4444); }
&__text {
font-size: 22rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
white-space: nowrap;
}
&:active { opacity: 0.7; transform: scale(0.95); }
}
/* ===== Form Panel ===== */
.form-panel {
padding: 32rpx;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
&__title {
font-size: 34rpx;
font-weight: 700;
color: var(--text-color, #0A1628);
}
&__close {
font-size: 32rpx;
color: var(--text-color-3, #6B7280);
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-color-2, #F5F6F8);
&:active { opacity: 0.6; }
}
}
.form-group {
margin-bottom: 24rpx;
&__label {
display: block;
font-size: 26rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
margin-bottom: 12rpx;
}
}
.form-actions {
margin-top: 32rpx;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { CacheInfo } from '@/api/module_monitor/cache'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { CacheAPI } from '@/api/module_monitor/cache'
definePage({ name: 'work-cache', style: { navigationBarTitleText: '缓存监控' } })
const toast = useToast()
const loading = ref(false)
const info = ref<CacheInfo>()
async function loadData() {
loading.value = true
try {
info.value = await CacheAPI.getInfo()
}
catch (e) { toast.error(getErrorMessage(e, '加载失败')) }
finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
function clearCache() {
uni.showModal({ title: '提示', content: '确认清除所有缓存?', success: async (res) => {
if (res.confirm) {
try {
await CacheAPI.clear()
toast.success('缓存已清除')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
}
} })
}
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view v-if="loading" class="flex justify-center items-center py-4xl">
<wd-loading />
</view>
<view v-else-if="info" class="p-md">
<!-- Stats row -->
<view class="stats-grid cache-stats">
<view class="stat-card">
<text class="stat-card__label">
键数量
</text><text class="stat-card__value" style="color:var(--primary-color);">
{{ info.keys_count }}
</text>
</view>
<view class="stat-card">
<text class="stat-card__label">
命中率
</text><text class="stat-card__value" style="color:var(--success-color);">
{{ info.hit_rate || '-' }}
</text>
</view>
<view class="stat-card">
<text class="stat-card__label">
内存使用
</text><text class="stat-card__value" style="color:var(--warning-color);font-size:var(--font-xl);">
{{ info.used_memory || '-' }}
</text>
</view>
<view class="stat-card">
<text class="stat-card__label">
数据库大小
</text><text class="stat-card__value" style="color:var(--info-color);font-size:var(--font-xl);">
{{ info.db_size ?? '-' }}
</text>
</view>
</view>
<view class="action-bar justify-center">
<wd-button size="small" type="danger" plain @click="clearCache">清除缓存</wd-button>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-state__icon">
⚠️
</text>
<text class="empty-state__text">
加载失败,请下拉刷新重试
</text>
</view>
</view>
</template>
@@ -0,0 +1,986 @@
<script setup lang="ts">
import type { JobLogItem, SchedulerJob } from '@/api/module_task/cronjob/job'
import type { NodeForm, NodeItem } from '@/api/module_task/cronjob/node'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { JobAPI, JobLogAPI, SchedulerAPI } from '@/api/module_task/cronjob/job'
import { NodeAPI } from '@/api/module_task/cronjob/node'
definePage({
name: 'work-cronjob',
style: { navigationBarTitleText: '定时任务' },
})
const toast = useToast()
const jobsLoading = ref(false)
const schedulerRunning = ref(false)
const schedulerJobs = ref<SchedulerJob[]>([])
const activeTab = ref<'jobs' | 'logs' | 'nodes'>('jobs')
// ---- Node management ----
const showNodeForm = ref(false)
const nodeFormTitle = ref('新建节点')
const editingNodeId = ref<number | null>(null)
const nodeForm = reactive<NodeForm & { id?: number }>({
name: '',
host: '',
port: 0,
type: 'default',
description: '',
})
const {
list: nodeList,
total: nodeTotal,
loading: nodeLoading,
pageParams: nodePageParams,
loadData: loadNodes,
loadPrev: nodeLoadPrev,
loadNext: nodeLoadNext,
} = useListPage<NodeItem>({
fetcher: p => NodeAPI.getPage(p),
onError: e => toast.error(getErrorMessage(e, '加载节点列表失败')),
})
function openCreateNode() {
nodeFormTitle.value = '新建节点'
editingNodeId.value = null
Object.assign(nodeForm, { name: '', host: '', port: 0, type: 'default', description: '' })
showNodeForm.value = true
}
async function openEditNode(id: number) {
nodeFormTitle.value = '编辑节点'
editingNodeId.value = id
try {
const detail = await NodeAPI.getDetail(id)
Object.assign(nodeForm, { name: detail.name, host: detail.host, port: detail.port, type: detail.type, description: detail.description || '' })
showNodeForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取节点详情失败')) }
}
async function submitNode() {
if (!nodeForm.name.trim())
return toast.warning('请输入节点名称')
if (!nodeForm.host.trim())
return toast.warning('请输入节点地址')
try {
if (editingNodeId.value) {
await NodeAPI.update(editingNodeId.value, { ...nodeForm })
toast.success('更新成功')
}
else {
await NodeAPI.create({ ...nodeForm })
toast.success('创建成功')
}
showNodeForm.value = false
loadNodes()
}
catch (e) { toast.error(getErrorMessage(e, '保存失败')) }
}
async function handleDeleteNode(ids: number[]) {
uni.showModal({
title: '提示',
content: '确认删除选中的节点?',
success: async (res) => {
if (!res.confirm)
return
try {
await NodeAPI.remove(ids)
toast.success('删除成功')
loadNodes()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
},
})
}
async function handleClearNode(id: number) {
uni.showModal({
title: '提示',
content: '确认清空该节点上的所有任务?',
success: async (res) => {
if (!res.confirm)
return
try {
await NodeAPI.clear(id)
toast.success('清空成功')
loadNodes()
}
catch (e) { toast.error(getErrorMessage(e, '清空失败')) }
},
})
}
async function handleExecuteNode(id: number) {
try {
await NodeAPI.execute(id)
toast.success('节点调试任务已触发')
}
catch (e) { toast.error(getErrorMessage(e, '执行失败')) }
}
async function refreshScheduler() {
jobsLoading.value = true
try {
const [status, jobs] = await Promise.all([
SchedulerAPI.getStatus(),
SchedulerAPI.getJobs(),
])
schedulerRunning.value = status.running
schedulerJobs.value = jobs || []
}
catch (e) {
toast.error(getErrorMessage(e, '获取调度器状态失败'))
}
finally {
jobsLoading.value = false
}
}
async function handleScheduler(action: 'start' | 'pause' | 'resume' | 'shutdown') {
const labels: Record<string, string> = { start: '启动', pause: '暂停', resume: '恢复', shutdown: '关闭' }
uni.showModal({
title: '提示',
content: `确认${labels[action]}调度器?`,
success: async (res) => {
if (!res.confirm)
return
try {
if (action === 'start')
await SchedulerAPI.start()
else if (action === 'pause')
await SchedulerAPI.pause()
else if (action === 'resume')
await SchedulerAPI.resume()
else await SchedulerAPI.shutdown()
toast.success(`${labels[action]}成功`)
refreshScheduler()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
},
})
}
async function handleTask(action: 'pause' | 'resume' | 'run' | 'remove', jobId: string, jobName: string) {
const labels: Record<string, string> = { pause: '暂停', resume: '恢复', run: '执行', remove: '移除' }
const content = action === 'remove' ? `确认移除任务「${jobName}」?` : `确认${labels[action]}任务「${jobName}」?`
uni.showModal({
title: '提示',
content,
success: async (res) => {
if (!res.confirm)
return
try {
if (action === 'pause') {
await JobAPI.pause(jobId)
}
else if (action === 'resume') {
await JobAPI.resume(jobId)
}
else if (action === 'run') {
await JobAPI.run(jobId)
toast.success('任务已触发执行')
}
else {
await JobAPI.remove(jobId)
}
if (action !== 'run')
toast.success(`${labels[action]}成功`)
refreshScheduler()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
},
})
}
const {
list: logList,
total: logTotal,
loading: logLoading,
pageParams: logPageParams,
loadData: loadLogs,
loadPrev: logLoadPrev,
loadNext: logLoadNext,
} = useListPage<JobLogItem>({
fetcher: p => JobLogAPI.getPage(p),
onError: e => toast.error(getErrorMessage(e, '加载日志失败')),
})
async function handleClearLogs(ids: number[]) {
uni.showModal({
title: '提示',
content: '确认删除这些执行日志?',
success: async (res) => {
if (!res.confirm)
return
try {
await JobLogAPI.remove(ids)
toast.success('删除成功')
loadLogs()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
},
})
}
onPullDownRefresh(() => {
if (activeTab.value === 'jobs')
refreshScheduler()
else if (activeTab.value === 'logs')
loadLogs()
else loadNodes()
})
onLoad(() => {
refreshScheduler()
loadLogs()
loadNodes()
})
// Status helpers
function getJobStatusColor(status?: string) {
const map: Record<string, string> = { running: '#10B981', paused: '#F59E0B', error: '#EF4444' }
return map[status || ''] || '#6B7280'
}
function getJobStatusLabel(status?: string) {
const map: Record<string, string> = { running: '运行中', paused: '已暂停', error: '异常' }
return map[status || ''] || status || '未知'
}
function getLogColor(status?: string) {
const map: Record<string, string> = { success: '#10B981', failed: '#EF4444', running: '#4F8CFF' }
return map[status || ''] || '#6B7280'
}
</script>
<template>
<view class="list-page">
<!-- Scheduler status bar -->
<view class="scheduler-bar">
<view class="scheduler-bar__status">
<view class="scheduler-dot" :class="schedulerRunning ? 'scheduler-dot--on' : 'scheduler-dot--off'" />
<text class="scheduler-bar__label">
{{ schedulerRunning ? '调度器运行中' : '调度器已停止' }}
</text>
<text class="scheduler-bar__count">
{{ schedulerJobs.length }} 个任务
</text>
</view>
<view class="scheduler-bar__actions">
<template v-if="schedulerRunning">
<view class="scheduler-btn btn-press" @click="handleScheduler('pause')">
<text class="scheduler-btn__text">
⏸ 暂停
</text>
</view>
<view class="scheduler-btn scheduler-btn--danger btn-press" @click="handleScheduler('shutdown')">
<text class="scheduler-btn__text">
⏹ 关闭
</text>
</view>
</template>
<template v-else>
<view class="scheduler-btn scheduler-btn--primary btn-press" @click="handleScheduler('start')">
<text class="scheduler-btn__text">
▶ 启动
</text>
</view>
<view class="scheduler-btn btn-press" @click="handleScheduler('resume')">
<text class="scheduler-btn__text">
↻ 恢复
</text>
</view>
</template>
</view>
</view>
<!-- Tab switcher -->
<view class="tab-bar">
<view
class="tab-item" :class="[activeTab === 'jobs' && 'tab-item--active']"
@click="activeTab = 'jobs'"
>
<text class="tab-item__text">
任务列表
</text>
</view>
<view
class="tab-item" :class="[activeTab === 'logs' && 'tab-item--active']"
@click="activeTab = 'logs'"
>
<text class="tab-item__text">
执行日志
</text>
</view>
<view
class="tab-item" :class="[activeTab === 'nodes' && 'tab-item--active']"
@click="activeTab = 'nodes'"
>
<text class="tab-item__text">
节点管理
</text>
</view>
</view>
<!-- Jobs tab -->
<template v-if="activeTab === 'jobs'">
<SkeletonPage v-if="jobsLoading && schedulerJobs.length === 0" :rows="3" />
<ListEmpty v-else-if="!jobsLoading && schedulerJobs.length === 0" text="暂无任务" />
<view v-else class="jobs-list">
<view v-for="job in schedulerJobs" :key="job.id" class="job-item">
<view class="job-item__header">
<text class="job-item__name">
{{ job.name }}
</text>
<view class="job-item__actions">
<text class="job-action" @click="handleTask('pause', job.id, job.name)">
⏸
</text>
<text class="job-action" @click="handleTask('run', job.id, job.name)">
▶
</text>
<text class="job-action job-action--danger" @click="handleTask('remove', job.id, job.name)">
✕
</text>
</view>
</view>
<view class="job-item__meta">
<text class="job-item__func">
{{ job.func || '—' }}
</text>
<view class="job-item__badge" :style="{ background: `${getJobStatusColor(job.status)}18`, color: getJobStatusColor(job.status) }">
{{ getJobStatusLabel(job.status) }}
</view>
</view>
<text v-if="job.next_run_time" class="job-item__next">
下次执行: {{ job.next_run_time }}
</text>
</view>
</view>
</template>
<!-- Logs tab -->
<template v-else-if="activeTab === 'logs'">
<SkeletonPage v-if="logLoading && logList.length === 0" :rows="5" />
<ListEmpty v-else-if="!logLoading && logList.length === 0" text="暂无执行日志" />
<view v-else class="logs-list">
<view v-for="log in logList" :key="log.id" class="log-item">
<view class="log-item__header">
<text class="log-item__name">
{{ log.job_name || '—' }}
</text>
<view class="log-item__badge" :style="{ background: `${getLogColor(log.status)}18`, color: getLogColor(log.status) }">
{{ log.status || '—' }}
</view>
</view>
<text class="log-item__time">
{{ log.start_time || '—' }}
</text>
<text v-if="log.error_msg" class="log-item__error">
{{ log.error_msg }}
</text>
<view class="log-item__delete" @click="handleClearLogs([log.id])">
<text class="log-item__delete-icon">
🗑
</text>
</view>
</view>
<PaginationBar :current="logPageParams.page_no" :page-size="logPageParams.page_size" :total="logTotal" @prev="logLoadPrev" @next="logLoadNext" />
</view>
</template>
<!-- Nodes tab -->
<template v-else-if="activeTab === 'nodes'">
<view class="create-btn" @click="openCreateNode">
<text class="create-btn__icon">
+
</text>
<text class="create-btn__text">
新建节点
</text>
</view>
<view v-if="nodeLoading && nodeList.length === 0">
<SkeletonPage :rows="3" />
</view>
<ListEmpty v-else-if="!nodeLoading && nodeList.length === 0" text="暂无节点" />
<view v-else class="nodes-list">
<view v-for="node in nodeList" :key="node.id" class="node-item">
<view class="node-item__header">
<view class="node-item__info">
<text class="node-item__name">
{{ node.name || '未命名' }}
</text>
<text class="node-item__host">
{{ node.host }}{{ node.port ? `:${node.port}` : '' }}
</text>
</view>
<view class="node-item__badge" :style="{ background: node.type === 'master' ? '#8B5CF618' : '#6B728018', color: node.type === 'master' ? '#8B5CF6' : '#6B7280' }">
{{ node.type === 'master' ? '主节点' : node.type || '默认' }}
</view>
</view>
<text v-if="node.description" class="node-item__desc">
{{ node.description }}
</text>
<view v-if="node.status" class="node-item__meta">
<text class="node-item__meta-text">
状态: {{ node.status }}
</text>
<text v-if="node.task_count !== undefined" class="node-item__meta-text">
{{ node.task_count }} 个任务
</text>
</view>
<view class="node-item__actions">
<view class="node-action node-action--primary btn-press" @click="handleExecuteNode(node.id)">
<text class="node-action__text">
▶ 调试
</text>
</view>
<view class="node-action btn-press" @click="openEditNode(node.id)">
<text class="node-action__text">
✎ 编辑
</text>
</view>
<view class="node-action btn-press" @click="handleClearNode(node.id)">
<text class="node-action__text">
↻ 清空
</text>
</view>
<view class="node-action node-action--danger btn-press" @click="handleDeleteNode([node.id])">
<text class="node-action__text">
✕ 删除
</text>
</view>
</view>
</view>
<PaginationBar :current="nodePageParams.page_no" :page-size="nodePageParams.page_size" :total="nodeTotal" @prev="nodeLoadPrev" @next="nodeLoadNext" />
</view>
</template>
<!-- ========== Node Form Popup ========== -->
<wd-popup
v-model="showNodeForm"
position="bottom"
custom-style="border-radius: 32rpx 32rpx 0 0; padding-bottom: 40rpx;"
@close="showNodeForm = false"
>
<view class="form-panel">
<view class="form-panel__header">
<text class="form-panel__title">
{{ nodeFormTitle }}
</text>
<text class="form-panel__close" @click="showNodeForm = false">
✕
</text>
</view>
<view class="form-group">
<text class="form-group__label">
节点名称 *
</text>
<wd-input v-model="nodeForm.name" placeholder="请输入节点名称" clearable />
</view>
<view class="form-group">
<text class="form-group__label">
主机地址 *
</text>
<wd-input v-model="nodeForm.host" placeholder="请输入主机地址 (IP/域名)" clearable />
</view>
<view class="form-row">
<view class="form-group form-group--half">
<text class="form-group__label">
端口
</text>
<wd-input v-model="nodeForm.port" type="number" placeholder="端口号" />
</view>
<view class="form-group form-group--half">
<text class="form-group__label">
类型
</text>
<wd-input v-model="nodeForm.type" placeholder="default / master" />
</view>
</view>
<view class="form-group">
<text class="form-group__label">
描述
</text>
<wd-textarea v-model="nodeForm.description" placeholder="节点描述" :maxlength="200" auto-height />
</view>
<view class="form-actions">
<wd-button block type="primary" @click="submitNode">{{ editingNodeId ? '更新' : '创建' }}</wd-button>
</view>
</view>
</wd-popup>
<view style="height: 100rpx;" />
</view>
</template>
<style lang="scss" scoped>
.list-page {
padding: 0 32rpx;
padding-bottom: 40rpx;
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Scheduler bar ===== */
.scheduler-bar {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin: 24rpx 0;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__status {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
&__label {
font-size: 30rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
flex: 1;
}
&__count {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
background: var(--bg-color-2, #F5F6F8);
padding: 4rpx 16rpx;
border-radius: 16rpx;
}
&__actions {
display: flex;
gap: 16rpx;
}
}
.scheduler-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
&--on { background: #10B981; box-shadow: 0 0 8rpx rgba(16, 185, 129, 0.4); }
&--off { background: #9CA3AF; }
}
.scheduler-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 64rpx;
border-radius: 16rpx;
background: var(--bg-color-2, #F5F6F8);
transition: all 0.15s ease;
&--primary {
background: var(--primary-color, #4F8CFF);
.scheduler-btn__text { color: #FFFFFF; }
}
&--danger {
background: var(--danger-color-light, #FEF2F2);
.scheduler-btn__text { color: var(--danger-color, #EF4444); }
}
&__text {
font-size: 26rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
}
}
/* ===== Tab bar ===== */
.tab-bar {
display: flex;
gap: 0;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 8rpx;
margin-bottom: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 72rpx;
border-radius: 18rpx;
transition: all 0.2s ease;
&--active {
background: var(--primary-color, #4F8CFF);
.tab-item__text { color: #FFFFFF; font-weight: 600; }
}
&__text {
font-size: 28rpx;
color: var(--text-color-3, #6B7280);
font-weight: 500;
}
}
/* ===== Jobs list ===== */
.jobs-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.job-item {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
&__name {
font-size: 30rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
flex: 1;
}
&__actions {
display: flex;
gap: 12rpx;
}
&__meta {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
&__func {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
font-family: 'SF Mono', 'Consolas', monospace;
}
&__badge {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
}
&__next {
font-size: 22rpx;
color: var(--text-color-4, #B0B0B0);
}
}
.job-action {
font-size: 24rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-color-2, #F5F6F8);
transition: all 0.15s ease;
&--danger {
color: var(--danger-color, #EF4444);
}
&:active {
opacity: 0.7;
transform: scale(0.9);
}
}
/* ===== Logs list ===== */
.logs-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.log-item {
position: relative;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx 72rpx 24rpx 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
&__name {
font-size: 28rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
flex: 1;
}
&__badge {
font-size: 22rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
}
&__time {
font-size: 24rpx;
color: var(--text-color-4, #B0B0B0);
}
&__error {
display: block;
font-size: 22rpx;
color: var(--danger-color, #EF4444);
background: var(--danger-color-light, #FEF2F2);
padding: 8rpx 16rpx;
border-radius: 8rpx;
margin-top: 8rpx;
}
&__delete {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: var(--bg-color-2, #F5F6F8);
display: flex;
align-items: center;
justify-content: center;
&-icon { font-size: 24rpx; opacity: 0.4; }
}
}
/* ===== Load more ===== */
.load-more {
display: flex;
align-items: center;
justify-content: center;
height: 80rpx;
background: var(--bg-color-2, #F5F6F8);
border-radius: 16rpx;
margin-top: 8rpx;
&__text {
font-size: 26rpx;
color: var(--text-color-3, #6B7280);
}
}
/* ===== Create button ===== */
.create-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 80rpx;
background: var(--primary-color, #4F8CFF);
border-radius: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(1, 77, 178, 0.2);
transition: all 0.15s ease;
&:active { opacity: 0.85; transform: scale(0.98); }
&__icon {
font-size: 36rpx;
color: #FFFFFF;
font-weight: 300;
}
&__text {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 600;
}
}
/* ===== Nodes list ===== */
.nodes-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.node-item {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12rpx;
}
&__info {
flex: 1;
min-width: 0;
}
&__name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
margin-bottom: 4rpx;
}
&__host {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
font-family: 'SF Mono', 'Consolas', monospace;
}
&__badge {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
flex-shrink: 0;
}
&__desc {
display: block;
font-size: 24rpx;
color: var(--text-color-2, #4B5563);
margin-bottom: 10rpx;
}
&__meta {
display: flex;
gap: 16rpx;
margin-bottom: 12rpx;
}
&__meta-text {
font-size: 22rpx;
color: var(--text-color-4, #B0B0B0);
}
&__actions {
display: flex;
gap: 12rpx;
padding-top: 12rpx;
border-top: 1rpx solid var(--border-color, #F0F0F0);
}
}
.node-action {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 56rpx;
border-radius: 12rpx;
background: var(--bg-color-2, #F5F6F8);
transition: all 0.15s ease;
&--primary { background: var(--primary-color-light, rgba(1,77,178,0.08)); }
&--danger { background: var(--danger-color-light, #FEF2F2); }
&--primary .node-action__text { color: var(--primary-color, #4F8CFF); }
&--danger .node-action__text { color: var(--danger-color, #EF4444); }
&__text {
font-size: 22rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
white-space: nowrap;
}
&:active { opacity: 0.7; transform: scale(0.95); }
}
/* ===== Form Panel ===== */
.form-panel {
padding: 32rpx;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
&__title {
font-size: 34rpx;
font-weight: 700;
color: var(--text-color, #0A1628);
}
&__close {
font-size: 32rpx;
color: var(--text-color-3, #6B7280);
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-color-2, #F5F6F8);
&:active { opacity: 0.6; }
}
}
.form-group {
margin-bottom: 24rpx;
&__label {
display: block;
font-size: 26rpx;
font-weight: 500;
color: var(--text-color-2, #4B5563);
margin-bottom: 12rpx;
}
&--half {
flex: 1;
}
}
.form-row {
display: flex;
gap: 16rpx;
}
.form-actions {
margin-top: 32rpx;
}
</style>
@@ -0,0 +1,373 @@
<script setup lang="ts">
import type { OnlineUserItem } from '@/api/module_monitor/online'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { OnlineAPI } from '@/api/module_monitor/online'
import SkeletonPage from '@/components/SkeletonPage.vue'
definePage({
name: 'work-devices',
style: { navigationBarTitleText: '设备管理' },
})
const toast = useToast()
const loading = ref(false)
const sessions = ref<OnlineUserItem[]>([])
async function loadSessions() {
loading.value = true
try {
const res = await OnlineAPI.getCurrent()
sessions.value = res || []
}
catch (e) {
toast.error(getErrorMessage(e, '加载设备列表失败'))
}
finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
async function handleForceLogout(sessionId: string) {
uni.showModal({
title: '提示',
content: '确认强制下线此设备?该设备的登录状态将被立即清除。',
success: async (res) => {
if (!res.confirm)
return
try {
await OnlineAPI.forceLogout(sessionId)
toast.success('已强制下线')
loadSessions()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
},
})
}
function getDeviceIcon(os?: string) {
if (!os)
return '📱'
const lower = os.toLowerCase()
if (lower.includes('ios') || lower.includes('iphone') || lower.includes('ipad'))
return '📱'
if (lower.includes('android'))
return '🤖'
if (lower.includes('windows'))
return '💻'
if (lower.includes('mac'))
return '🖥'
if (lower.includes('linux'))
return '🐧'
return '📱'
}
function getBrowserLabel(browser?: string) {
if (!browser)
return '未知浏览器'
const lower = browser.toLowerCase()
if (lower.includes('wechat') || lower.includes('weixin'))
return '微信内置'
if (lower.includes('chrome'))
return 'Chrome'
if (lower.includes('safari'))
return 'Safari'
if (lower.includes('firefox'))
return 'Firefox'
if (lower.includes('edge'))
return 'Edge'
return browser
}
onPullDownRefresh(() => {
loadSessions()
})
onLoad(() => {
loadSessions()
})
</script>
<template>
<view class="list-page">
<!-- Summary Card -->
<view class="summary-card fade-in-up">
<view class="summary-card__icon">
<text class="summary-card__icon-text">
🔐
</text>
</view>
<view class="summary-card__body">
<text class="summary-card__title">
登录设备管理
</text>
<text class="summary-card__desc">
当前共有 {{ sessions.length }} 个活跃会话
</text>
</view>
</view>
<!-- Sessions List -->
<SkeletonPage v-if="loading && sessions.length === 0" :rows="3" />
<ListEmpty v-else-if="!loading && sessions.length === 0" text="暂无活跃会话" />
<view v-else class="sessions-list">
<view
v-for="(session, idx) in sessions"
:key="(session.session_id || '') + idx"
class="session-item" :class="[`fade-in-up-${Math.min(idx % 5 + 1, 5)}`]"
>
<view class="session-item__header">
<view class="session-item__icon">
<text class="session-item__icon-text">
{{ getDeviceIcon(session.os) }}
</text>
</view>
<view class="session-item__info">
<text class="session-item__os">
{{ session.os || '未知设备' }}
</text>
<text class="session-item__browser">
{{ getBrowserLabel(session.browser) }}
</text>
</view>
<view
v-if="idx === 0"
class="session-item__current-tag"
>
<text class="session-item__current-text">
当前
</text>
</view>
</view>
<view class="session-item__details">
<view class="detail-row">
<text class="detail-row__label">
IP 地址
</text>
<text class="detail-row__value">
{{ session.ipaddr || '—' }}
</text>
</view>
<view class="detail-row">
<text class="detail-row__label">
登录位置
</text>
<text class="detail-row__value">
{{ session.login_location || '—' }}
</text>
</view>
<view class="detail-row">
<text class="detail-row__label">
登录时间
</text>
<text class="detail-row__value">
{{ session.login_time || '—' }}
</text>
</view>
</view>
<view v-if="idx !== 0" class="session-item__action">
<view class="logout-btn btn-press" @click="handleForceLogout(session.session_id || '')">
<text class="logout-btn__text">
强制下线
</text>
</view>
</view>
</view>
</view>
<!-- Tips -->
<view class="tips fade-in-up-3">
<text class="tips__text">
💡 如发现异常登录设备,请立即强制下线并修改密码。
</text>
</view>
<view style="height: 80rpx;" />
</view>
</template>
<style lang="scss" scoped>
.list-page {
padding: 0 32rpx;
padding-bottom: 40rpx;
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Summary Card ===== */
.summary-card {
display: flex;
align-items: center;
gap: 24rpx;
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), #005CD6);
border-radius: 24rpx;
padding: 32rpx;
margin: 24rpx 0;
box-shadow: 0 8rpx 24rpx rgba(1, 77, 178, 0.2);
&__icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__icon-text {
font-size: 40rpx;
}
&__body {
flex: 1;
}
&__title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #FFFFFF;
margin-bottom: 4rpx;
}
&__desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
}
/* ===== Sessions List ===== */
.sessions-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.session-item {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
&__icon {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
background: var(--bg-color-2, #F5F6F8);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__icon-text {
font-size: 36rpx;
}
&__info {
flex: 1;
min-width: 0;
}
&__os {
display: block;
font-size: 28rpx;
font-weight: 600;
color: var(--text-color, #0A1628);
margin-bottom: 4rpx;
}
&__browser {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
}
&__current-tag {
background: rgba(16, 185, 129, 0.1);
padding: 4rpx 16rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
&__current-text {
font-size: 22rpx;
color: #10B981;
font-weight: 600;
}
&__details {
background: var(--page-bg-color, #F9F9F9);
border-radius: 16rpx;
padding: 16rpx;
}
&__action {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6rpx 0;
&__label {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
}
&__value {
font-size: 24rpx;
color: var(--text-color, #0A1628);
font-weight: 500;
font-family: 'SF Mono', 'Consolas', monospace;
}
}
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
height: 56rpx;
padding: 0 32rpx;
border-radius: 12rpx;
background: var(--danger-color-light, #FEF2F2);
transition: all 0.15s ease;
&:active { opacity: 0.7; transform: scale(0.95); }
&__text {
font-size: 24rpx;
font-weight: 500;
color: var(--danger-color, #EF4444);
}
}
/* ===== Tips ===== */
.tips {
margin-top: 32rpx;
padding: 24rpx;
background: var(--warning-color-light, rgba(245, 158, 11, 0.08));
border-radius: 16rpx;
&__text {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
line-height: 1.6;
}
}
</style>
@@ -0,0 +1,229 @@
<script setup lang="ts">
import type { ResourceItem } from '@/api/module_monitor/resource'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { ResourceAPI } from '@/api/module_monitor/resource'
definePage({ name: 'work-file-manager', style: { navigationBarTitleText: '文件管理' } })
const toast = useToast()
const currentPath = ref('')
const showMkdir = ref(false)
const newDirName = ref('')
const { list: files, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<ResourceItem>({
fetcher: p => ResourceAPI.getPage({ ...p, path: currentPath.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function enterDir(path: string) {
currentPath.value = path
toFirst()
loadData()
}
function goBack() {
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
currentPath.value = parts.length ? `/${parts.join('/')}` : ''
toFirst()
loadData()
}
function goHome() {
currentPath.value = ''
toFirst()
loadData()
}
// Upload
function handleUpload() {
uni.chooseImage({
count: 1,
success: async (res) => {
const filePath = res.tempFilePaths[0]
const fileName = filePath.split('/').pop() || 'unknown'
try {
await ResourceAPI.upload(currentPath.value || '/', filePath, fileName)
toast.success('上传成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '上传失败')) }
},
})
}
// Create directory
async function handleMkdir() {
if (!newDirName.value.trim()) {
toast.error('请输入目录名')
return
}
try {
await ResourceAPI.mkdir(currentPath.value || '/', newDirName.value.trim())
toast.success('目录创建成功')
showMkdir.value = false
newDirName.value = ''
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '创建失败')) }
}
// Delete
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除该文件/目录?',
success: async (r) => {
if (r.confirm) {
try {
await ResourceAPI.remove([id])
toast.success('已删除')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
// Download
async function handleDownload(id: number) {
try {
const res = await ResourceAPI.download(id)
if (res?.url) {
uni.downloadFile({
url: res.url,
success: () => { toast.success('下载成功') },
fail: () => { toast.error('下载失败') },
})
}
}
catch (e) { toast.error(getErrorMessage(e, '下载失败')) }
}
function getFileIcon(name: string, is_dir?: boolean): string {
if (is_dir)
return 'folder'
const ext = name?.split('.').pop()?.toLowerCase() || ''
const iconMap: Record<string, string> = {
png: 'file-image',
jpg: 'file-image',
jpeg: 'file-image',
gif: 'file-image',
svg: 'file-image',
webp: 'file-image',
mp4: 'file-video',
avi: 'file-video',
mov: 'file-video',
mkv: 'file-video',
mp3: 'file-audio',
wav: 'file-audio',
pdf: 'file-pdf',
}
return iconMap[ext] || 'file'
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => loadData())
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<!-- Breadcrumb navigation -->
<view class="breadcrumb-bar">
<view class="flex items-center gap-xs">
<wd-button size="small" plain :disabled="!currentPath" @click="goHome">
<wd-icon name="home" size="14px" />
</wd-button>
<text class="text-xs text-muted">
/
</text>
<template v-if="currentPath">
<wd-button size="small" plain @click="goBack">
<wd-icon name="arrow-left" size="14px" />
</wd-button>
<text class="text-sm text-muted truncate flex-1">
{{ currentPath }}
</text>
</template>
<text v-else class="text-sm text-muted flex-1">
根目录
</text>
</view>
</view>
<!-- Actions -->
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 项
</text>
<view class="flex gap-sm">
<wd-button size="small" plain @click="showMkdir = true">新建目录</wd-button>
<wd-button size="small" type="primary" @click="handleUpload">上传文件</wd-button>
</view>
</view>
<!-- File list -->
<SkeletonPage v-if="loading" :rows="5" />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && files.length === 0" text="当前目录为空" />
<wd-cell-group v-else>
<wd-cell
v-for="item in files"
:key="item.id"
:is-link="item.is_dir"
@click="item.is_dir ? enterDir(currentPath ? `${currentPath}/${item.name}` : `/${item.name}`) : null"
>
<template #title>
<view class="flex items-center gap-sm">
<wd-icon :name="getFileIcon(item.name, item.is_dir)" size="22px" :color="item.is_dir ? 'var(--warning-color)' : 'var(--primary-color)'" />
<view>
<text class="font-medium text-md">
{{ item.name }}
</text>
<text class="text-xs text-muted block mt-xs">
{{ item.size || '-' }}{{ item.created_time ? ` · ${item.created_time}` : '' }}
</text>
</view>
</view>
</template>
<template #default>
<view class="flex items-center gap-sm">
<template v-if="!item.is_dir">
<wd-icon name="download" size="18px" color="var(--primary-color)" @click.stop="handleDownload(item.id)" />
</template>
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id)" />
</view>
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
</template>
<!-- Mkdir Popup -->
<wd-popup v-model="showMkdir" position="bottom" round @close="showMkdir = false">
<view class="p-xl" style="max-height:40vh;">
<wd-navbar title="新建目录" left-arrow @click-left="showMkdir = false" />
<view class="mt-lg">
<view class="flex items-center gap-sm">
<text>目录名</text>
<wd-input v-model="newDirName" placeholder="请输入目录名称" class="flex-1" />
</view>
<view class="flex gap-md mt-lg">
<wd-button block plain @click="showMkdir = false">取消</wd-button>
<wd-button block type="primary" @click="handleMkdir">创建</wd-button>
</view>
</view>
</view>
</wd-popup>
</view>
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { LoginLogItem } from '@/api/module_system/log'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { LoginLogAPI } from '@/api/module_system/log'
definePage({ name: 'work-loginlogs', style: { navigationBarTitleText: '登录日志' } })
const toast = useToast()
const searchName = ref('')
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<LoginLogItem>({
fetcher: p => LoginLogAPI.getPage({ ...p, username: searchName.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchName.value = ''
toFirst()
loadData()
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await LoginLogAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchName" placeholder="搜索用户名" clearable class="flex-1" />
<wd-button size="small" type="primary" plain @click="onSearch">搜索</wd-button>
<wd-button size="small" plain @click="onReset">重置</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无登录日志" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.username || '-' }}
</text>
<text class="text-xs text-muted block mt-xs">
IP: {{ item.login_ip || '-' }} · {{ item.login_location || '-' }}
</text>
</view>
</template>
<template #label>
<text class="text-xs text-muted">
{{ item.request_browser || '' }}{{ item.request_browser && item.request_os ? ' / ' : '' }}{{ item.request_os || '' }}{{ item.msg ? ` · ${item.msg}` : '' }}
</text>
</template>
<template #default>
<view class="flex items-center gap-sm">
<StatusBadge :status="String(item.status) === '1' ? 'success' : 'failed'" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</view>
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
</template>
</view>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { OnlineUserItem } from '@/api/module_monitor/online'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { OnlineAPI } from '@/api/module_monitor/online'
definePage({ name: 'work-online', style: { navigationBarTitleText: '在线用户' } })
const toast = useToast()
const { list, total, loading, pageParams, loadData, loadPrev, loadNext } = useListPage<OnlineUserItem>({
fetcher: p => OnlineAPI.getPage(p),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function forceLogout(sessionId: string) {
uni.showModal({
title: '提示',
content: '确认强制下线该用户?',
success: async (res) => {
if (res.confirm) {
try {
await OnlineAPI.forceLogout(sessionId)
toast.success('已强制下线')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 在线
</text>
</view>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无在线用户" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.session_id || item.user_id">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.user_name || item.name || '-' }}
</text>
<text class="text-xs text-muted block mt-xs">
IP: {{ item.ipaddr || '-' }} · {{ item.login_location || '' }}
</text>
</view>
</template>
<template #label>
<text class="text-xs text-muted">
{{ item.browser || '' }}{{ item.browser && item.os ? ' / ' : '' }}{{ item.os || '' }}
</text>
</template>
<template #default>
<view class="flex items-center gap-sm">
<StatusBadge status="active" />
<wd-button size="small" type="danger" plain @click="forceLogout(item.session_id!)">下线</wd-button>
</view>
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
</view>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { OplogItem } from '@/api/module_system/log'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { OperationLogAPI } from '@/api/module_system/log'
definePage({ name: 'work-oplogs', style: { navigationBarTitleText: '操作日志' } })
const toast = useToast()
const searchPath = ref('')
const showDetail = ref(false)
const detailItem = ref<OplogItem>()
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<OplogItem>({
fetcher: p => OperationLogAPI.getPage({ ...p, request_path: searchPath.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchPath.value = ''
toFirst()
loadData()
}
function viewDetail(item: OplogItem) {
detailItem.value = item
showDetail.value = true
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await OperationLogAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<wd-input v-model="searchPath" placeholder="搜索请求路径" clearable class="flex-1" />
<wd-button size="small" type="primary" plain @click="onSearch">搜索</wd-button>
<wd-button size="small" plain @click="onReset">重置</wd-button>
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无日志" />
<wd-cell-group v-else border>
<wd-cell v-for="item in list" :key="item.id" is-link @click="viewDetail(item)">
<template #title>
<view class="flex items-center gap-sm flex-wrap">
<StatusBadge :status="(item.response_code || 0) < 300 ? 'success' : 'failed'" dot />
<text class="font-mono text-sm font-medium">
{{ item.request_method }}
</text>
<text class="text-sm text-muted truncate" style="max-width: 300rpx;">
{{ item.request_path }}
</text>
</view>
<text class="text-xs text-muted block">
{{ item.description || '-' }} · {{ item.request_ip }} · {{ item.process_time }}
</text>
</template>
<template #default>
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
<wd-popup v-model="showDetail" position="bottom" round closable @close="showDetail = false">
<view v-if="detailItem" class="p-xl">
<wd-navbar title="日志详情" left-arrow @click-left="showDetail = false" />
<view class="admin-card mt-lg">
<wd-cell-group border>
<wd-cell title="操作人" :value="detailItem.username || '-'" />
<wd-cell title="请求路径" :value="detailItem.request_path || '-'" />
<wd-cell title="请求方法" :value="detailItem.request_method || '-'" />
<wd-cell title="请求 IP" :value="detailItem.request_ip || '-'" />
<wd-cell title="状态码">
<StatusBadge :status="(detailItem.response_code || 0) < 300 ? 'success' : 'failed'" />
</wd-cell>
<wd-cell title="响应时间" :value="detailItem.process_time || '-'" />
<wd-cell title="操作说明" :value="detailItem.description || '-'" />
</wd-cell-group>
</view>
<view class="mt-sm px-sm py-sm bg-light rounded-md">
<text class="text-xs text-muted">
请求参数:{{ detailItem.request_payload || '无' }}
</text>
</view>
<view class="mt-lg">
<wd-button block @click="showDetail = false">关闭</wd-button>
</view>
</view>
</wd-popup>
</template>
</view>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { ServerInfo } from '@/api/module_monitor/server'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { ServerAPI } from '@/api/module_monitor/server'
definePage({ name: 'work-server', style: { navigationBarTitleText: '服务监控' } })
const toast = useToast()
const loading = ref(false)
const info = ref<ServerInfo>()
function pc(v = 0) {
return Math.round(v)
}
function pcColor(v = 0) {
if (v > 80)
return 'var(--danger-color)'
if (v > 60)
return 'var(--warning-color)'
return 'var(--success-color)'
}
async function loadData() {
loading.value = true
try {
info.value = await ServerAPI.getInfo()
}
catch (e) { toast.error(getErrorMessage(e, '加载失败')) }
finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper p-md">
<wd-loading v-if="loading && !info" />
<template v-else-if="info">
<view class="stats-grid server-stats">
<view class="stat-card">
<text class="stat-card__label">
CPU
</text><text class="stat-card__value" :style="{ color: pcColor(info.cpu?.used_percent) }">
{{ pc(info.cpu?.used_percent) }}%
</text><text class="stat-card__trend text-muted">
{{ info.cpu?.cores || '-' }} 核心
</text>
</view>
<view class="stat-card">
<text class="stat-card__label">
内存
</text><text class="stat-card__value" :style="{ color: pcColor(info.memory?.used_percent) }">
{{ pc(info.memory?.used_percent) }}%
</text><text class="stat-card__trend text-muted">
{{ info.memory?.used || '-' }}
</text>
</view>
<view class="stat-card">
<text class="stat-card__label">
磁盘
</text><text class="stat-card__value" :style="{ color: pcColor(info.disk?.used_percent) }">
{{ pc(info.disk?.used_percent) }}%
</text><text class="stat-card__trend text-muted">
{{ info.disk?.used || '-' }}
</text>
</view>
</view>
<view class="admin-card">
<wd-cell-group border>
<wd-cell title="操作系统" :value="info.os?.name || '-'" />
<wd-cell title="主机名" :value="info.os?.hostname || '-'" />
<wd-cell title="运行时间" :value="info.os?.uptime || '-'" />
<wd-cell title="CPU 型号" :value="info.cpu?.model || '-'" />
</wd-cell-group>
</view>
</template>
<view v-else class="empty-state">
<text class="empty-state__icon">
⚠️
</text>
<text class="empty-state__text">
加载失败,请下拉刷新重试
</text>
</view>
</view>
</template>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { DeptForm, DeptItem } from '@/api/module_system/dept'
import { onLoad } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { DeptAPI } from '@/api/module_system/dept'
definePage({ name: 'work-depts', style: { navigationBarTitleText: '部门管理' } })
const toast = useToast()
const loading = ref(false)
const treeList = ref<DeptItem[]>([])
const flatList = ref<(DeptItem & { _depth: number })[]>([])
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<DeptForm>({ name: '', code: '', order: 1, parent_id: undefined, leader: '', phone: '', email: '', status: 0, description: '' })
function flatten(items: DeptItem[], depth = 0): (DeptItem & { _depth: number })[] {
let r: (DeptItem & { _depth: number })[] = []
for (const item of items) {
r.push({ ...item, _depth: depth })
if (item.children?.length)
r = r.concat(flatten(item.children, depth + 1))
}
return r
}
async function loadTree() {
loading.value = true
try {
treeList.value = await DeptAPI.getTree() || []
flatList.value = flatten(treeList.value)
}
catch (e) { toast.error(getErrorMessage(e, '加载失败')) }
finally { loading.value = false }
}
function resetForm() {
formData.name = ''
formData.code = ''
formData.order = 1
formData.parent_id = undefined
formData.leader = ''
formData.phone = ''
formData.email = ''
formData.status = 0
formData.description = ''
}
function openCreate(parentId?: number) {
formTitle.value = '新增部门'
currentId.value = undefined
resetForm()
formData.parent_id = parentId
showForm.value = true
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await DeptAPI.update(currentId.value, formData)
toast.success('更新成功')
}
else {
await DeptAPI.create(formData)
toast.success('创建成功')
}
showForm.value = false
loadTree()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({ title: '提示', content: '确认删除该部门?', success: async (res) => {
if (res.confirm) {
try {
await DeptAPI.remove([id])
toast.success('删除成功')
loadTree()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
} })
}
onLoad(() => loadTree())
</script>
<template>
<view class="page-wraper">
<view class="action-bar">
<text class="font-bold text-lg">
部门列表
</text>
<u-button size="mini" type="primary" text="+ 新增" @click="openCreate()" />
</view>
<view class="px-sm">
<view class="admin-card">
<SkeletonPage v-if="loading" />
<ListEmpty v-else-if="flatList.length === 0" text="暂无部门" />
<u-cell-group v-else>
<u-cell v-for="item in flatList" :key="item.id">
<template #title>
<view class="flex items-center gap-xs" :style="{ marginLeft: `${(item._depth || 0) * 36}rpx` }">
<text v-if="item._depth > 0" class="text-muted" style="font-size: 20rpx;">
└─
</text>
<StatusBadge :status="item.status" dot />
<text class="font-medium text-md">
{{ item.name }}
</text>
<text class="text-xs text-muted">
[{{ item.code }}]
</text>
</view>
</template>
<template #right>
<u-icon name="plus" size="18" color="var(--success-color)" @click.stop="openCreate(item.id)" />
<u-icon name="trash" size="18" color="var(--danger-color)" @click.stop="handleDelete(item.id)" />
</template>
</u-cell>
</u-cell-group>
</view>
</view>
<u-popup :show="showForm" mode="bottom" :round="10" custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<u-navbar :title="formTitle" left-icon="arrow-left" @left-click="showForm = false" />
<u-form :model="formData" class="mt-lg">
<u-form-item label="部门名称" prop="name" :border-bottom="true">
<u-input v-model="formData.name" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="部门编码" prop="code" :border-bottom="true">
<u-input v-model="formData.code" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="排序" :border-bottom="true">
<u-input v-model="formData.order" border="none" type="number" placeholder="请输入" />
</u-form-item>
<u-form-item label="负责人" :border-bottom="true">
<u-input v-model="formData.leader" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="联系电话" :border-bottom="true">
<u-input v-model="formData.phone" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="邮箱" :border-bottom="true">
<u-input v-model="formData.email" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="备注" :border-bottom="true">
<u-textarea v-model="formData.description" border="none" placeholder="请输入" />
</u-form-item>
</u-form>
<view class="flex gap-md mt-xl">
<u-button :block="true" :plain="true" text="取消" @click="showForm = false" /><u-button :block="true" type="primary" :loading="loading" text="保存" @click="handleSubmit" />
</view>
</view>
</u-popup>
</view>
</template>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { MenuForm, MenuItem } from '@/api/module_system/menu'
import { onLoad } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { MenuAPI } from '@/api/module_system/menu'
definePage({ name: 'work-menus', style: { navigationBarTitleText: '菜单管理' } })
const toast = useToast()
const loading = ref(false)
const treeList = ref<MenuItem[]>([])
const flatList = ref<(MenuItem & { _depth: number })[]>([])
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<MenuForm>({ name: '', type: 2, order: 1, parent_id: undefined, permission: '', icon: '', route_name: '', route_path: '', component_path: '', redirect: '', hidden: false, keep_alive: true, always_show: false, title: '', affix: false, status: 0, description: '', client: 'pc' })
const typeLabels: Record<number, string> = { 1: '目录', 2: '菜单', 3: '按钮', 4: '外链' }
const typeColors: Record<number, string> = { 1: 'primary', 2: 'enabled', 3: 'draft', 4: 'failed' }
function flatten(items: MenuItem[], depth = 0): (MenuItem & { _depth: number })[] {
let r: (MenuItem & { _depth: number })[] = []
for (const item of items) {
r.push({ ...item, _depth: depth })
if (item.children?.length)
r = r.concat(flatten(item.children, depth + 1))
}
return r
}
async function loadTree() {
loading.value = true
try {
treeList.value = await MenuAPI.getTree() || []
flatList.value = flatten(treeList.value)
}
catch (e) { toast.error(getErrorMessage(e, '加载失败')) }
finally { loading.value = false }
}
function resetForm() {
Object.assign(formData, { name: '', type: 2, order: 1, parent_id: undefined, permission: '', icon: '', route_name: '', route_path: '', component_path: '', redirect: '', hidden: false, keep_alive: true, always_show: false, title: '', affix: false, status: 0, description: '', client: 'pc' })
}
function openCreate(parentId?: number) {
formTitle.value = '新增菜单'
currentId.value = undefined
resetForm()
formData.parent_id = parentId
showForm.value = true
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await MenuAPI.update(currentId.value, formData)
toast.success('更新成功')
}
else {
await MenuAPI.create(formData)
toast.success('创建成功')
}
showForm.value = false
loadTree()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({ title: '提示', content: '确认删除该菜单?', success: async (res) => {
if (res.confirm) {
try {
await MenuAPI.remove([id])
toast.success('删除成功')
loadTree()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
} })
}
onLoad(() => loadTree())
</script>
<template>
<view class="page-wraper">
<view class="action-bar">
<text class="font-bold text-lg">
菜单列表
</text>
<u-button size="mini" type="primary" text="+ 新增" @click="openCreate()" />
</view>
<view class="px-sm">
<view class="admin-card">
<SkeletonPage v-if="loading" />
<ListEmpty v-else-if="flatList.length === 0" text="暂无菜单" />
<u-cell-group v-else>
<u-cell v-for="item in flatList" :key="item.id">
<template #title>
<view class="flex items-center gap-xs" :style="{ marginLeft: `${(item._depth || 0) * 36}rpx` }">
<text v-if="item._depth > 0" class="text-muted" style="font-size: 20rpx;">
└─
</text>
<text v-if="item.icon" class="text-primary" style="font-size: 24rpx;">
{{ item.icon }}
</text>
<text class="font-medium text-md">
{{ item.name }}
</text>
<span class="status-badge" :class="`status-badge--${typeColors[item.type] || 'disabled'}`" style="font-size: 18rpx; padding: 0 10rpx;">
{{ typeLabels[item.type] || '未知' }}
</span>
</view>
</template>
<template #right-icon>
<StatusBadge :status="item.status" />
<u-icon name="plus" size="18" color="var(--success-color)" @click.stop="openCreate(item.id)" />
<u-icon name="trash" size="18" color="var(--danger-color)" @click.stop="handleDelete(item.id)" />
</template>
</u-cell>
</u-cell-group>
</view>
</view>
<u-popup :show="showForm" mode="bottom" :round="10" custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<u-navbar :title="formTitle" left-icon="arrow-left" @left-click="showForm = false" />
<u-form :model="formData" class="mt-lg">
<u-form-item label="菜单名称">
<u-input v-model="formData.name" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item label="类型(1-4)">
<u-input v-model="formData.type" type="number" placeholder="目录/菜单/按钮/外链" border="surround" />
</u-form-item>
<u-form-item label="排序">
<u-input v-model="formData.order" type="number" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item label="权限标识">
<u-input v-model="formData.permission" placeholder="如 sys:user:query" border="surround" />
</u-form-item>
<u-form-item label="路由地址">
<u-input v-model="formData.route_path" placeholder="如 /system/user" border="surround" />
</u-form-item>
<u-form-item label="组件路径">
<u-input v-model="formData.component_path" placeholder="system/user/index" border="surround" />
</u-form-item>
<u-form-item label="图标">
<u-input v-model="formData.icon" placeholder="图标名称" border="surround" />
</u-form-item>
<u-form-item label="备注">
<u-textarea v-model="formData.description" placeholder="请输入" />
</u-form-item>
</u-form>
<view class="flex gap-md mt-xl">
<u-button :block="true" :plain="true" text="取消" @click="showForm = false" /><u-button :block="true" type="primary" :loading="loading" text="保存" @click="handleSubmit" />
</view>
</view>
</u-popup>
</view>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import type { PositionForm, PositionItem } from '@/api/module_system/position'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { PositionAPI } from '@/api/module_system/position'
definePage({ name: 'work-positions', style: { navigationBarTitleText: '岗位管理' } })
const toast = useToast()
const searchName = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<PositionForm>({ name: '', code: '', order: 1, status: 0, description: '' })
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<PositionItem>({
fetcher: p => PositionAPI.getPage({ ...p, name: searchName.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchName.value = ''
toFirst()
loadData()
}
function resetForm() {
Object.assign(formData, { name: '', code: '', order: 1, status: 0, description: '' })
}
function openCreate() {
formTitle.value = '新增'
currentId.value = undefined
resetForm()
showForm.value = true
}
async function openEdit(id: number) {
formTitle.value = '编辑'
currentId.value = id
try {
const detail = await PositionAPI.getDetail(id)
Object.assign(formData, { name: detail.name, code: detail.code || '', order: detail.order || 1, status: detail.status ?? 0, description: detail.description || '' })
showForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取详情失败')) }
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await PositionAPI.update(currentId.value, formData)
toast.success('更新成功')
}
else {
await PositionAPI.create(formData)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除?',
success: async (res) => {
if (res.confirm) {
try {
await PositionAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<u-input v-model="searchName" placeholder="搜索名称" clearable border="surround" class="flex-1" />
<u-button size="mini" type="primary" :plain="true" text="搜索" @click="onSearch" />
<u-button size="mini" :plain="true" text="重置" @click="onReset" />
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
<u-button size="mini" type="primary" text="+ 新增" @click="openCreate" />
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无数据" />
<u-cell-group v-else>
<u-cell v-for="item in list" :key="item.id" :is-link="true" @click="openEdit(item.id!)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.name }}
</text><text class="text-xs text-muted block mt-xs">
{{ `编码: ${item.code || '-'}` }}
</text>
</view>
</template>
<template #value>
<StatusBadge :status="item.status" />
</template>
<template #right>
<u-icon name="trash" size="18" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</template>
</u-cell>
</u-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
<u-popup :show="showForm" mode="bottom" :round="10" custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<u-navbar :title="formTitle" left-icon="arrow-left" @left-click="showForm = false" />
<u-form :model="formData" class="mt-lg">
<u-form-item label="岗位名称" prop="name" :border-bottom="true">
<u-input v-model="formData.name" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="岗位编码" prop="code" :border-bottom="true">
<u-input v-model="formData.code" border="none" placeholder="请输入" />
</u-form-item>
<u-form-item label="排序" :border-bottom="true">
<u-input v-model="formData.order" border="none" type="number" placeholder="请输入" />
</u-form-item>
<u-form-item label="备注" :border-bottom="true">
<u-textarea v-model="formData.description" border="none" placeholder="请输入" />
</u-form-item>
</u-form>
<view class="flex gap-md mt-xl">
<u-button :block="true" :plain="true" text="取消" @click="showForm = false" />
<u-button :block="true" type="primary" :loading="loading" text="保存" @click="handleSubmit" />
</view>
</view>
</u-popup>
</template>
</view>
</template>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import type { RoleForm, RoleItem } from '@/api/module_system/role'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import { RoleAPI } from '@/api/module_system/role'
definePage({ name: 'work-roles', style: { navigationBarTitleText: '角色管理' } })
const toast = useToast()
const searchName = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formRef = ref()
const formData = reactive<RoleForm>({ name: '', code: '', order: 1, data_scope: 4, status: 0, description: '' })
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<RoleItem>({
fetcher: p => RoleAPI.getPage({ ...p, name: searchName.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
function onReset() {
searchName.value = ''
toFirst()
loadData()
}
function resetForm() {
formData.name = ''
formData.code = ''
formData.order = 1
formData.data_scope = 4
formData.status = 0
formData.description = ''
}
function openCreate() {
formTitle.value = '新增角色'
currentId.value = undefined
resetForm()
showForm.value = true
}
async function openEdit(id: number) {
formTitle.value = '编辑角色'
currentId.value = id
try {
const detail = await RoleAPI.getDetail(id)
Object.assign(formData, { name: detail.name, code: detail.code, order: detail.order || 1, data_scope: detail.data_scope || 4, status: detail.status ?? 0, description: detail.description || '' })
showForm.value = true
}
catch (e) { toast.error(getErrorMessage(e, '获取详情失败')) }
}
async function handleSubmit() {
loading.value = true
try {
if (currentId.value) {
await RoleAPI.update(currentId.value, formData)
toast.success('更新成功')
}
else {
await RoleAPI.create(formData)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确认删除该角色?',
success: async (res) => {
if (res.confirm) {
try {
await RoleAPI.remove([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
},
})
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="page-wraper">
<view class="search-bar">
<view class="flex items-center gap-sm">
<u-input v-model="searchName" placeholder="搜索角色名称" clearable border="surround" class="flex-1" />
<u-button size="mini" type="primary" :plain="true" text="搜索" @click="onSearch" />
<u-button size="mini" :plain="true" text="重置" @click="onReset" />
</view>
</view>
<view class="action-bar">
<text class="text-md font-bold text-muted">
共 {{ total }} 条
</text>
<wd-button size="small" type="primary" @click="openCreate">+ 新增</wd-button>
</view>
<SkeletonPage v-if="loading" :rows="5" search />
<template v-else>
<view class="px-sm">
<view class="admin-card">
<ListEmpty v-if="!loading && list.length === 0" text="暂无角色" />
<wd-cell-group v-else>
<wd-cell v-for="item in list" :key="item.id" is-link @click="openEdit(item.id!)">
<template #title>
<view>
<text class="font-medium text-md">
{{ item.name }}
</text>
<text class="text-xs text-muted block">
{{ item.code }}{{ item.description ? ` · ${item.description}` : '' }}
</text>
</view>
</template>
<template #default>
<StatusBadge :status="item.status" />
<wd-icon name="delete" size="18px" color="var(--danger-color)" @click.stop="handleDelete(item.id!)" />
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
<wd-popup v-model="showForm" position="bottom" round custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<wd-navbar :title="formTitle" left-arrow @click-left="showForm = false" />
<wd-form ref="formRef" :model="formData" class="mt-lg">
<wd-form-item label="角色名称" prop="name" border>
<wd-input v-model="formData.name" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="角色编码" prop="code" border>
<wd-input v-model="formData.code" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="排序" border>
<wd-input v-model="formData.order" type="number" placeholder="请输入" />
</wd-form-item>
<wd-form-item label="备注" border>
<wd-textarea v-model="formData.description" placeholder="请输入" />
</wd-form-item>
</wd-form>
<view class="flex gap-md mt-xl">
<wd-button block plain @click="showForm = false">取消</wd-button>
<wd-button block type="primary" :loading="loading" @click="handleSubmit">保存</wd-button>
</view>
</view>
</wd-popup>
</template>
</view>
</template>
@@ -0,0 +1,415 @@
<script setup lang="ts">
import type { DeptItem } from '@/api/module_system/dept'
import type { PositionItem } from '@/api/module_system/position'
import type { RoleItem } from '@/api/module_system/role'
import type { UserForm, UserInfo } from '@/api/module_system/user'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { DeptAPI } from '@/api/module_system/dept'
import { PositionAPI } from '@/api/module_system/position'
import { RoleAPI } from '@/api/module_system/role'
import UserAPI from '@/api/module_system/user'
definePage({ name: 'work-users', style: { navigationBarTitleText: '用户管理' } })
const router = useRouter()
const toast = useToast()
const searchName = ref('')
const showForm = ref(false)
const formTitle = ref('')
const currentId = ref<number>()
const formData = reactive<UserForm>({
username: '',
name: '',
password: '',
dept_id: undefined,
role_ids: [],
position_ids: [],
email: '',
mobile: '',
gender: 2,
status: 1,
description: '',
})
const GENDER_OPTIONS = [{ value: 0, label: '男' }, { value: 1, label: '女' }, { value: 2, label: '未知' }]
const STATUS_OPTIONS = [{ value: 0, label: '正常' }, { value: 1, label: '禁用' }]
const deptOptions = ref<{ value: number, label: string }[]>([])
const roleOptions = ref<{ value: number, label: string }[]>([])
const positionOptions = ref<{ value: number, label: string }[]>([])
const showPickerDept = ref(false)
const showPickerRole = ref(false)
const showPickerPosition = ref(false)
interface PickerConfirmEvent { indexs: number[] }
function handleDeptConfirm(e: PickerConfirmEvent) {
const item = deptOptions.value[e.indexs[0]]
if (item)
formData.dept_id = item.value
showPickerDept.value = false
}
function handleRoleConfirm(e: PickerConfirmEvent) {
const item = roleOptions.value[e.indexs[0]]
if (item)
formData.role_ids = [item.value]
showPickerRole.value = false
}
function handlePositionConfirm(e: PickerConfirmEvent) {
const item = positionOptions.value[e.indexs[0]]
if (item)
formData.position_ids = [item.value]
showPickerPosition.value = false
}
function flattenDept(items: DeptItem[], prefix = ''): { value: number, label: string }[] {
let r: { value: number, label: string }[] = []
for (const item of items) {
r.push({ value: item.id, label: prefix + item.name })
if (item.children?.length)
r = r.concat(flattenDept(item.children, `${prefix} `))
}
return r
}
async function loadOptions() {
try {
const [depts, roles, positions] = await Promise.all([
DeptAPI.getTree(),
RoleAPI.getPage({ page_no: 1, page_size: 1000 }),
PositionAPI.getPage({ page_no: 1, page_size: 1000 }),
])
deptOptions.value = flattenDept(depts || [])
roleOptions.value = (roles.list || []).map((r: RoleItem) => ({ value: r.id, label: r.name || '' }))
positionOptions.value = (positions.list || []).map((p: PositionItem) => ({ value: p.id, label: p.name || '' }))
}
catch {
deptOptions.value = []
roleOptions.value = []
positionOptions.value = []
}
}
const { list, total, loading, pageParams, loadData, toFirst, loadPrev, loadNext } = useListPage<UserInfo>({
fetcher: p => UserAPI.getUserPage({ ...p, name: searchName.value || undefined }),
onError: e => toast.error(getErrorMessage(e, '加载失败')),
})
function onSearch() {
toFirst()
loadData()
}
const initForm: UserForm = { username: '', name: '', password: '', dept_id: undefined, role_ids: [], position_ids: [], email: '', mobile: '', gender: 2, status: 1, description: '' }
function openCreate() {
formTitle.value = '新增用户'
currentId.value = undefined
Object.assign(formData, { ...initForm })
if (deptOptions.value.length === 0)
loadOptions()
showForm.value = true
}
function openEdit(id: number) {
router.push({ name: 'work-user-detail', query: { id } })
}
async function handleSubmit() {
loading.value = true
try {
const payload = { ...formData }
if (currentId.value) {
await UserAPI.updateUser({ ...payload, id: currentId.value })
toast.success('更新成功')
}
else {
await UserAPI.addUser(payload)
toast.success('创建成功')
}
showForm.value = false
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '操作失败')) }
finally { loading.value = false }
}
function handleDelete(id: number) {
uni.showModal({ title: '提示', content: '确认删除该用户?', success: async (res) => {
if (res.confirm) {
try {
await UserAPI.deleteUser([id])
toast.success('删除成功')
loadData()
}
catch (e) { toast.error(getErrorMessage(e, '删除失败')) }
}
} })
}
// Avatar color generator
const avatarColors = ['#014DB2', '#F59E0B', '#10B981', '#8B5CF6', '#EF4444', '#EC4899', '#06B6D4']
function getAvatarColor(index: number) {
return avatarColors[index % avatarColors.length]
}
function getAvatarInitial(name: string) {
return (name || '?').charAt(0)
}
onReachBottom(() => {
if (!loading.value)
loadNext()
})
onPullDownRefresh(() => {
loadData()
})
onLoad(() => loadData())
</script>
<template>
<view class="list-page">
<!-- Search bar -->
<view class="list-search">
<view class="list-search__box">
<text class="list-search__icon">
🔍
</text>
<u-input
v-model="searchName"
placeholder="搜索姓名或用户名"
clearable
border="none"
@confirm="onSearch"
/>
</view>
</view>
<!-- Meta bar -->
<view class="list-meta">
<text class="list-meta__count">
共 {{ total }} 条
</text>
<view class="list-meta__add" @click="openCreate">
<text class="list-meta__add-icon">
+
</text>
<text class="list-meta__add-text">
新增
</text>
</view>
</view>
<!-- List -->
<SkeletonPage v-if="loading && list.length === 0" :rows="5" search />
<template v-else>
<ListEmpty v-if="!loading && list.length === 0" text="暂无用户" />
<view v-for="(item, index) in list" :key="item.id" class="list-item" hover-class="list-item--hover" @click="openEdit(item.id!)">
<view class="list-item__avatar" :style="{ background: getAvatarColor(index) }">
<text class="list-item__avatar-text">
{{ getAvatarInitial(item.name || item.username || '') }}
</text>
</view>
<view class="list-item__info">
<text class="list-item__name">
{{ item.name || item.username }}
</text>
<text class="list-item__sub">
{{ item.username }} · {{ item.email || '-' }}
</text>
</view>
<StatusBadge :status="item.status" />
<view class="list-item__delete" @click.stop="handleDelete(item.id!)">
<text class="list-item__delete-icon">
🗑
</text>
</view>
</view>
<PaginationBar :current="pageParams.page_no" :page-size="pageParams.page_size" :total="total" @prev="loadPrev" @next="loadNext" />
</template>
<!-- Form popup -->
<u-popup :show="showForm" mode="bottom" :round="10" custom-style="max-height: 80vh; overflow-y: auto;" @close="showForm = false">
<view class="p-xl">
<u-navbar :title="formTitle" left-icon="arrow-left" @left-click="showForm = false" />
<u-form :model="formData" class="mt-lg">
<u-form-item label="用户名">
<u-input v-model="formData.username" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item label="姓名">
<u-input v-model="formData.name" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item v-if="!currentId" label="密码">
<u-input v-model="formData.password" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item label="部门">
<view @click="showPickerDept = true">
<u-cell title="部门" :value="deptOptions.find(o => o.value === formData.dept_id)?.label || '请选择部门'" :is-link="true" :border="false" />
</view>
<u-picker :show="showPickerDept" :columns="[deptOptions.map(o => o.label)]" key-name="label" @confirm="handleDeptConfirm" @cancel="showPickerDept = false" />
</u-form-item>
<u-form-item label="角色">
<view @click="showPickerRole = true">
<u-cell title="角色" :value="roleOptions.find(o => o.value === formData.role_ids?.[0])?.label || '请选择角色'" :is-link="true" :border="false" />
</view>
<u-picker :show="showPickerRole" :columns="[roleOptions.map(o => o.label)]" key-name="label" @confirm="handleRoleConfirm" @cancel="showPickerRole = false" />
</u-form-item>
<u-form-item label="岗位">
<view @click="showPickerPosition = true">
<u-cell title="岗位" :value="positionOptions.find(o => o.value === formData.position_ids?.[0])?.label || '请选择岗位'" :is-link="true" :border="false" />
</view>
<u-picker :show="showPickerPosition" :columns="[positionOptions.map(o => o.label)]" key-name="label" @confirm="handlePositionConfirm" @cancel="showPickerPosition = false" />
</u-form-item>
<u-form-item label="性别">
<u-radio-group v-model="formData.gender">
<u-radio v-for="opt in GENDER_OPTIONS" :key="opt.value" :name="opt.value">
{{ opt.label }}
</u-radio>
</u-radio-group>
</u-form-item>
<u-form-item label="状态">
<u-radio-group v-model="formData.status">
<u-radio v-for="opt in STATUS_OPTIONS" :key="opt.value" :name="opt.value">
{{ opt.label }}
</u-radio>
</u-radio-group>
</u-form-item>
<u-form-item label="邮箱">
<u-input v-model="formData.email" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item label="手机号">
<u-input v-model="formData.mobile" placeholder="请输入" border="surround" />
</u-form-item>
<u-form-item label="备注">
<u-textarea v-model="formData.description" placeholder="请输入" />
</u-form-item>
</u-form>
<view class="flex gap-md mt-xl">
<u-button :block="true" :plain="true" text="取消" @click="showForm = false" />
<u-button :block="true" type="primary" :loading="loading" text="保存" @click="handleSubmit" />
</view>
</view>
</u-popup>
</view>
</template>
<style lang="scss" scoped>
.list-page {
padding: 0 32rpx;
padding-bottom: 40rpx;
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Search ===== */
.list-search {
padding: 24rpx 0;
&__box {
display: flex;
align-items: center;
gap: 16rpx;
background: var(--bg-color-2, #F5F6F8);
border-radius: 16rpx;
padding: 0 24rpx;
height: 80rpx;
}
&__icon {
font-size: 28rpx;
opacity: 0.4;
}
}
/* ===== Meta ===== */
.list-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8rpx 0 24rpx;
&__count {
font-size: 26rpx;
color: var(--text-color-3, #6B7280);
}
&__add {
display: flex;
align-items: center;
gap: 8rpx;
background: var(--primary-color, #4F8CFF);
color: #FFFFFF;
padding: 8rpx 24rpx;
border-radius: 32rpx;
&-icon { font-size: 36rpx; font-weight: 300; line-height: 1; }
&-text { font-size: 24rpx; font-weight: 500; }
}
}
/* ===== List item ===== */
.list-item {
display: flex;
align-items: center;
gap: 20rpx;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
transition: all 0.15s ease;
&--hover {
opacity: 0.9;
box-shadow: var(--shadow-md, 0 4rpx 12rpx rgba(1, 77, 178,0.10));
}
&__avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&-text {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
}
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
overflow: hidden;
}
&__name {
font-size: 30rpx;
font-weight: 500;
color: var(--text-color, #0A1628);
}
&__sub {
font-size: 24rpx;
color: var(--text-color-3, #6B7280);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__delete {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&-icon { font-size: 28rpx; opacity: 0.4; }
}
}
</style>
@@ -0,0 +1,428 @@
<script setup lang="ts">
definePage({
name: 'ci',
style: {
navigationBarTitleText: 'CI 持续集成',
},
})
const { success: showSuccess } = useGlobalToast()
// 配置示例数据
const configExample = ref(`{
"weixin": {
"appid": "微信小程序appid",
"privateKeyPath": "key/private.appid.key",
"projectPath": "dist/build/mp-weixin"
},
"alipay": {
"appid": "支付宝小程序appid",
"toolId": "支付宝开放平台工具ID",
"privateKey": "私钥文本内容",
"projectPath": "dist/build/mp-alipay",
"autoincrement": true
},
"dd": {
"appid": "钉钉小程序appid",
"token": "钉钉开发者后台的 API Token",
"projectPath": "dist/build/mp-alipay",
"autoincrement": true
},
"version": "0.0.1",
"desc": "版本描述"
}`)
const packageScripts = ref(`{
"scripts": {
"upload:weixin": "minici --platform weixin",
"upload:alipay": "minici --platform alipay",
"upload:dd": "minici --platform dd",
"upload:mp-weixin": "uni build -p mp-weixin && minici --platform weixin",
"upload:mp-alipay": "uni build -p mp-alipay && minici --platform alipay",
"upload:mp-dingtalk": "uni build -p mp-dingtalk && minici --platform dd"
}
}`)
// 特性列表
const features = ref([
{
icon: '📱',
title: '多平台支持',
desc: '支持微信、支付宝、钉钉小程序的上传',
},
{
icon: '🔢',
title: '版本管理',
desc: '支持指定版本号和版本号自增',
},
{
icon: '⚙️',
title: '简单配置',
desc: '通过配置文件轻松管理多平台参数',
},
{
icon: '🔄',
title: 'CI/CD集成',
desc: '完美集成持续集成和持续部署流程',
},
{
icon: '🛠️',
title: '命令行工具',
desc: '支持全局和局部安装,灵活使用',
},
{
icon: '📦',
title: '打包上传',
desc: '支持打包和上传命令组合使用',
},
])
// 平台配置
const platforms = ref([
{
name: 'weixin',
title: '微信小程序',
icon: '💚',
color: 'text-green-600',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
features: ['支持企业微信', 'CI机器人选择', '编译设置', '代码压缩保护'],
},
{
name: 'alipay',
title: '支付宝小程序',
icon: '💙',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
features: ['多终端支持', '版本号自增', '私钥认证', '开放平台集成'],
},
{
name: 'dd',
title: '钉钉小程序',
icon: '💜',
color: 'text-purple-600',
bgColor: 'bg-purple-50',
borderColor: 'border-purple-200',
features: ['企业应用', '版本号自增', 'Token认证', '多应用类型'],
},
])
// 复制代码到剪贴板
function copyCode(code: string) {
uni.setClipboardData({
data: code,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: '代码已复制到剪贴板' })
},
})
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: `${url} 已复制到剪贴板` })
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🚀
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
uni-mini-ci
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
小程序持续集成的插件
</view>
<view class="text-3 wot-text-text-secondary">
支持上传微信、企业微信、钉钉、支付宝平台的小程序
</view>
</view>
</view>
<!-- 核心特性 -->
<demo-block title="核心特性" transparent>
<view class="grid grid-cols-2 gap-3">
<view
v-for="feature in features"
:key="feature.title"
class="rounded-2 p-4 text-center wot-bg-filled-oppo"
>
<view class="mb-2 text-6">
{{ feature.icon }}
</view>
<view class="mb-1 text-3.5 font-bold wot-text-text-main">
{{ feature.title }}
</view>
<view class="text-3 leading-snug wot-text-text-secondary">
{{ feature.desc }}
</view>
</view>
</view>
</demo-block>
<!-- 安装方式 -->
<demo-block title="安装方式" transparent>
<view class="space-y-4">
<!-- 全局安装 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
全局安装
</view>
<view class="mb-3 text-3 wot-text-text-secondary">
推荐在开发环境中全局安装,方便在任何项目中使用
</view>
<view class="space-y-2">
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('npm i uni-mini-ci -g')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
npm i uni-mini-ci -g
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
</view>
<!-- 局部安装 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
局部安装
</view>
<view class="mb-3 text-3 wot-text-text-secondary">
在项目中作为开发依赖安装,适合团队协作
</view>
<view class="space-y-2">
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('npm i uni-mini-ci --save-dev')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
npm i uni-mini-ci --save-dev
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('pnpm add uni-mini-ci -D')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
pnpm add uni-mini-ci -D
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
</view>
</view>
</demo-block>
<!-- 配置文件 -->
<demo-block title="配置文件" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
.minicirc 配置文件
</view>
<view class="mb-3 text-3 leading-relaxed wot-text-text-secondary">
在项目根目录创建 <text class="rounded px-1 font-mono">
.minicirc
</text> 文件,配置各平台的上传参数
</view>
<view class="wot-bg-bg mb-3 border rounded-2 p-3 wot-border-border-main" @click="copyCode(configExample)">
<view class="mb-2 flex items-center justify-between">
<text class="text-3 font-bold wot-text-text-secondary">
配置示例:
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="max-h-60 overflow-y-auto">
<pre class="whitespace-pre-wrap text-2.5 leading-relaxed font-mono wot-text-text-secondary">{{ configExample }}</pre>
</view>
</view>
<view class="border border-orange-200 rounded-2 bg-orange-50 p-3 dark:bg-orange-900/20">
<view class="mb-2 text-3.5 text-orange-700 font-bold dark:text-orange-300">
💡 配置说明
</view>
<view class="text-3 text-orange-600 leading-relaxed space-y-1 dark:text-orange-200">
<view>• version: 上传版本号,不传时默认读取 package.json 的 version 字段</view>
<view>• desc: 上传时的描述信息</view>
<view>• autoincrement: 版本号自增,建议测试环境开启</view>
</view>
</view>
</view>
</demo-block>
<!-- 支持平台 -->
<demo-block title="支持平台" transparent>
<view class="space-y-3">
<view
v-for="platform in platforms"
:key="platform.name"
class="rounded-2 p-4 wot-bg-filled-oppo"
>
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
{{ platform.icon }}
</view>
<view class="text-4 font-bold wot-text-text-main">
{{ platform.title }}
</view>
</view>
<view class="grid grid-cols-2 mb-3 gap-2">
<view
v-for="feature in platform.features"
:key="feature"
class="rounded px-2 py-1 text-center text-2.5 wot-text-text-secondary"
>
{{ feature }}
</view>
</view>
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode(`minici --platform ${platform.name}`)">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
minici --platform {{ platform.name }}
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
</view>
</demo-block>
<!-- 添加命令 -->
<demo-block title="添加命令" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
package.json 脚本配置
</view>
<view class="mb-3 text-3 leading-relaxed wot-text-text-secondary">
在 package.json 中添加上传脚本,支持单独上传和打包上传组合
</view>
<view class="wot-bg-bg mb-3 border rounded-2 p-3 wot-border-border-main" @click="copyCode(packageScripts)">
<view class="mb-2 flex items-center justify-between">
<text class="text-3 font-bold wot-text-text-secondary">
脚本配置:
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="max-h-60 overflow-y-auto">
<pre class="whitespace-pre-wrap text-2.5 leading-relaxed font-mono wot-text-text-secondary">{{ packageScripts }}</pre>
</view>
</view>
<view class="space-y-2">
<view class="text-3.5 font-bold wot-text-text-main">
使用命令:
</view>
<view class="grid grid-cols-1 gap-2">
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('pnpm upload:weixin')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
pnpm upload:weixin
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('pnpm upload:mp-weixin')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
pnpm upload:mp-weixin
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
</view>
</view>
</demo-block>
<!-- 使用步骤 -->
<demo-block title="使用步骤" transparent>
<view class="space-y-3">
<view
v-for="(step, index) in [
{ title: '安装插件', desc: '选择全局或局部安装方式', icon: '📦' },
{ title: '创建配置', desc: '在根目录创建 .minicirc 配置文件', icon: '⚙️' },
{ title: '配置平台', desc: '填写各平台的 appid、密钥等信息', icon: '🔑' },
{ title: '添加脚本', desc: '在 package.json 中添加上传命令', icon: '📝' },
{ title: '执行上传', desc: '运行命令上传到对应平台', icon: '🚀' },
]"
:key="index"
class="rounded-2 p-4 wot-bg-filled-oppo"
>
<view class="flex items-center">
<view class="mr-3 h-8 w-8 flex items-center justify-center rounded-full bg-blue-100 text-3.5 text-blue-600 font-bold dark:bg-blue-900/20 dark:text-blue-400">
{{ index + 1 }}
</view>
<view class="mr-2 text-5">
{{ step.icon }}
</view>
<view class="flex-1">
<view class="text-3.5 font-bold wot-text-text-main">
{{ step.title }}
</view>
<view class="text-3 wot-text-text-secondary">
{{ step.desc }}
</view>
</view>
</view>
</view>
</view>
</demo-block>
<!-- 注意事项 -->
<demo-block title="注意事项" transparent>
<view class="space-y-3">
<view class="border border-red-200 rounded-2 bg-red-50 p-4 dark:bg-red-900/20">
<view class="mb-2 text-3.5 text-red-700 font-bold dark:text-red-300">
⚠️ 版本号限制
</view>
<view class="text-3 text-red-600 leading-relaxed dark:text-red-200">
支付宝和钉钉小程序不支持上传的版本号小于或等于现有版本号。建议测试环境开启 autoincrement 字段,生产环境手动指定版本号。
</view>
</view>
<view class="border border-blue-200 rounded-2 bg-blue-50 p-4 dark:bg-blue-900/20">
<view class="mb-2 text-3.5 text-blue-700 font-bold dark:text-blue-300">
💡 最佳实践
</view>
<view class="text-3 text-blue-600 leading-relaxed space-y-1 dark:text-blue-200">
<view>• 测试环境:开启版本号自增,频繁调试更方便</view>
<view>• 生产环境:手动指定版本号,便于版本管理</view>
<view>• 密钥安全:不要将私钥文件提交到代码仓库</view>
<view>• CI/CD:结合打包命令,实现自动化部署</view>
</view>
</view>
<view class="border border-green-200 rounded-2 bg-green-50 p-4 dark:bg-green-900/20">
<view class="mb-2 text-3.5 text-green-700 font-bold dark:text-green-300">
🔒 安全建议
</view>
<view class="text-3 text-green-600 leading-relaxed space-y-1 dark:text-green-200">
<view>• 使用环境变量存储敏感信息</view>
<view>• 将配置文件添加到 .gitignore</view>
<view>• 定期更换密钥和 Token</view>
<view>• 限制 CI 机器人权限范围</view>
</view>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📦 NPM 包" value="uni-mini-ci" is-link @click="handleNavigate('https://www.npmjs.com/package/uni-mini-ci')" />
<wd-cell title="🐙 GitHub 仓库" value="源码仓库" is-link @click="handleNavigate('https://github.com/Moonofweisheng/uni-mini-ci')" />
<wd-cell title="📱 微信小程序 CI" value="官方文档" is-link @click="handleNavigate('https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html')" />
<wd-cell title="💙 支付宝小程序 CI" value="官方文档" is-link @click="handleNavigate('https://opendocs.alipay.com/mini/02q29z')" />
<wd-cell title="💜 钉钉小程序 CI" value="官方文档" is-link @click="handleNavigate('https://github.com/open-dingtalk/dingtalk-design-cli')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,252 @@
<script setup lang="ts">
definePage({
name: 'create-uni',
style: {
navigationBarTitleText: 'CreateUni 脚手架',
},
})
const { success: showSuccess } = useGlobalToast()
// 核心优势
const advantages = ref([
{
icon: '🚀',
title: '简单易用',
desc: '一行命令即可创建项目,无需手动配置开发环境',
},
{
icon: '🔧',
title: '功能强大',
desc: '内置多种实用工具和插件,支持一键集成 WotUI',
},
{
icon: '🔄',
title: '持续更新',
desc: '由 uni-helper 团队积极维护,及时跟进最新技术栈',
},
{
icon: '👥',
title: '社区活跃',
desc: 'GitHub 获得众多 star,活跃的开发者社区',
},
{
icon: '🌐',
title: '网络稳定',
desc: '解决官方 CLI 网络不稳定问题,支持国内环境',
},
{
icon: '⚡️',
title: '开发效率',
desc: '提升开发效率 100%,快速搭建现代化项目',
},
])
// 解决的问题
const problems = ref([
{
problem: '网络问题',
desc: 'degit 命令只支持 GitHub,国内网络不稳定',
},
{
problem: '维护问题',
desc: '官方模板维护不够积极,版本往往落后',
},
{
problem: '工程化问题',
desc: '项目配置过于基础,缺少现代工程化支持',
},
{
problem: '集成问题',
desc: '需要手动集成组件库,配置过程繁琐',
},
])
// WotUI 快速创建
const wotQuickStart = ref({
title: '快速创建 WotUI 项目',
command: 'pnpm create uni@latest -t wot-starter-v2 <你的项目名称>',
desc: '一键创建集成了 WotUI 的完整项目',
features: [
'✅ TypeScript 项目',
'✅ 集成 Pinia 状态管理',
'✅ 自动配置 WotUI 组件库',
'✅ 添加 ESLint 支持',
],
})
// 其他使用方式
const quickMethods = ref([
{
title: 'GUI 模式',
command: 'pnpm create uni --gui',
desc: '使用图形界面创建项目',
},
{
title: '环境信息',
command: 'npx @create-uni/info@latest',
desc: '获取当前项目环境信息',
},
])
// 复制命令到剪贴板
function copyCommand(command: string) {
uni.setClipboardData({
data: command,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({
msg: `${command}已成功复制到剪贴板`,
})
},
})
}
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
copyCommand(url)
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🌱
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
CreateUni 脚手架
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
由 uni-helper 团队开发的轻量级脚手架工具
</view>
<view class="text-3 wot-text-text-secondary">
提升开发效率 100%,告别繁琐配置,一键集成 WotUI 组件库
</view>
</view>
</view>
<!-- 为什么选择 create-uni -->
<demo-block title="为什么选择 create-uni?" transparent>
<view class="mb-3 rounded-3 p-3 wot-bg-filled-oppo">
<text class="text-3.5 leading-relaxed wot-text-text-secondary">
在使用 uni-app 官方 CLI 工具时,我们经常会遇到以下问题:
</text>
</view>
<view class="mb-3">
<view
v-for="item in problems"
:key="item.problem"
class="mb-2 rounded-3 p-3 wot-bg-filled-oppo"
>
<view class="mb-1 text-3.5 text-red-600 font-bold dark:text-red-400">
❌ {{ item.problem }}
</view>
<view class="text-3 leading-snug wot-text-text-secondary">
{{ item.desc }}
</view>
</view>
</view>
<view class="rounded-3 p-3 wot-bg-filled-oppo">
<text class="text-3.5 text-green-600 font-bold dark:text-green-400">
✅ 而 create-uni 的出现,完美解决了这些问题!
</text>
</view>
</demo-block>
<!-- 核心优势 -->
<demo-block title="核心优势" transparent>
<view class="grid grid-cols-2 gap-3">
<view
v-for="advantage in advantages"
:key="advantage.title"
class="rounded-2 p-4 text-center wot-bg-filled-oppo"
>
<view class="mb-2 text-6">
{{ advantage.icon }}
</view>
<view class="mb-1 text-3.5 font-bold wot-text-text-main">
{{ advantage.title }}
</view>
<view class="text-3 leading-snug wot-text-text-secondary">
{{ advantage.desc }}
</view>
</view>
</view>
</demo-block>
<!-- 快速创建 WotUI 项目 -->
<demo-block title="快速创建 WotUI 项目" transparent>
<view class="rounded-3 p-5 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-7">
🎨
</view>
<view class="text-4.5 font-bold wot-text-text-main">
{{ wotQuickStart.title }}
</view>
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
{{ wotQuickStart.desc }}
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCommand(wotQuickStart.command)">
<text class="flex-1 break-all text-3 font-mono wot-text-text-secondary">
{{ wotQuickStart.command }}
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view>
<view class="mb-2 text-3.5 font-bold wot-text-text-main">
这个命令会:
</view>
<view
v-for="feature in wotQuickStart.features"
:key="feature"
class="mb-1 text-3 leading-snug wot-text-text-secondary"
>
{{ feature }}
</view>
</view>
</view>
</demo-block>
<!-- 其他使用方式 -->
<demo-block title="其他使用方式" transparent>
<view
v-for="method in quickMethods"
:key="method.title"
class="mb-3 rounded-2 p-4 wot-bg-filled-oppo last:mb-0"
>
<view class="mb-2 text-4 font-bold wot-text-text-main">
{{ method.title }}
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
{{ method.desc }}
</view>
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-2 wot-border-border-main" @click="copyCommand(method.command)">
<text class="flex-1 break-all text-3 font-mono wot-text-text-secondary">
{{ method.command }}
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📦 NPM 包" value="create uni" is-link @click="handleNavigate('https://www.npmjs.com/package/create-uni')" />
<wd-cell title="🐙 GitHub 仓库" value="create uni" is-link @click="handleNavigate('https://github.com/uni-helper/create-uni')" />
<wd-cell title="🎨 Wot UI" value="UI 组件库" is-link @click="handleNavigate('https://wot-ui.cn/')" />
<wd-cell title="🛠️ Uni Helper" value="先进工具集合" is-link @click="handleNavigate('https://github.com/uni-helper')" />
<wd-cell title="💝 赞助CreateUni" value="支持开发者" is-link @click="handleNavigate('https://github.com/uni-helper/create-uni')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,581 @@
<script setup lang="ts">
definePage({
name: 'feedback',
style: {
navigationBarTitleText: '全局反馈',
},
})
const { success: showSuccess } = useGlobalToast()
const { show: showToast, success, error, warning, info } = useGlobalToast()
const { loading, close: closeLoading } = useGlobalLoading()
const { confirm, alert, prompt } = useGlobalDialog()
// 表单数据
const customMessage = ref('这是一个自定义消息')
const loadingText = ref('正在处理中...')
// Toast 演示方法
function demoToastSuccess() {
success({ msg: '操作成功!' })
}
function demoToastError() {
error({ msg: '操作失败!' })
}
function demoToastWarning() {
warning({ msg: '警告提示!' })
}
function demoToastInfo() {
info({ msg: '信息提示!' })
}
function demoCustomToast() {
if (!customMessage.value.trim()) {
warning({ msg: '请输入自定义消息' })
return
}
showToast({
msg: customMessage.value,
duration: 3000,
position: 'middle',
})
}
function demoToastPosition(position: 'top' | 'middle' | 'bottom') {
showToast({
msg: `显示在${position === 'top' ? '顶部' : position === 'middle' ? '中间' : '底部'}的提示`,
position,
duration: 2000,
})
}
// Loading 演示方法
function demoBasicLoading() {
loading('加载中...')
setTimeout(() => {
closeLoading()
success({ msg: '加载完成!' })
}, 3000)
}
function demoCustomLoading() {
if (!loadingText.value.trim()) {
warning({ msg: '请输入加载文本' })
return
}
loading({
msg: loadingText.value,
cover: true,
})
setTimeout(() => {
closeLoading()
success({ msg: '自定义加载完成!' })
}, 2500)
}
function demoLoadingNoCover() {
loading({
msg: '无遮罩加载',
cover: false,
})
setTimeout(() => {
closeLoading()
}, 2000)
}
// Message 演示方法
function demoConfirm() {
confirm({
title: '确认操作',
msg: '您确定要执行此操作吗?',
success: (res) => {
if (res.action === 'confirm') {
success({ msg: '用户确认了操作' })
}
else {
info({ msg: '用户取消了操作' })
}
},
})
}
function demoAlert() {
alert({
title: '重要提醒',
msg: '这是一个重要的提醒信息,请仔细阅读!',
success: () => {
success({ msg: '用户已阅读提醒' })
},
})
}
function demoPrompt() {
prompt({
title: '输入信息',
msg: '请输入您的姓名:',
success: (res) => {
if (res.action === 'confirm') {
if (res.value && String(res.value).trim()) {
success({ msg: `您好,${res.value}!` })
}
else {
warning({ msg: '输入不能为空' })
}
}
else {
info({ msg: '用户取消了输入' })
}
},
})
}
function demoCustomConfirm() {
confirm({
title: '自定义确认',
msg: '这是一个自定义的确认弹窗,可以配置按钮文字和样式',
confirmButtonText: '好的',
cancelButtonText: '算了',
success: (res) => {
if (res.action === 'confirm') {
success({ msg: '点击了"好的"' })
}
else {
info({ msg: '点击了"算了"' })
}
},
})
}
// 组合演示
function demoComplex() {
loading('准备演示...')
setTimeout(() => {
closeLoading()
confirm({
title: '组合演示',
msg: '这是一个组合演示,将依次展示不同的反馈效果。是否继续?',
success: (res) => {
if (res.action === 'confirm') {
success({ msg: '开始演示' })
setTimeout(() => {
loading('演示进行中...')
setTimeout(() => {
closeLoading()
info({ msg: '演示完成!' })
}, 2000)
}, 1000)
}
else {
info({ msg: '已取消演示' })
}
},
})
}, 1000)
}
// 代码示例常量
const codeExamples = {
basicToast: `const { success, error, warning, info } = useGlobalToast()
// 使用方法
success({ msg: '操作成功!' })
error({ msg: '操作失败!' })
warning({ msg: '警告提示!' })
info({ msg: '信息提示!' })`,
customToast: `const { show } = useGlobalToast()
show({
msg: '自定义消息',
duration: 3000,
position: 'middle' // 'top' | 'middle' | 'bottom'
})`,
basicLoading: `const { loading, close } = useGlobalLoading()
// 基础用法
loading('加载中...')
// 手动关闭
close()`,
customLoading: `loading({
msg: '正在处理中...',
cover: true // 是否显示遮罩
})`,
basicMessage: `const { confirm, alert, prompt } = useGlobalDialog()
// 确认弹窗
confirm({
title: '确认操作',
msg: '您确定要执行此操作吗?',
success: (res) => {
if (res.action === 'confirm') {
// 用户确认
}
}
})`,
customMessage: `confirm({
title: '自定义确认',
msg: '自定义的确认弹窗',
confirmButtonText: '好的',
cancelButtonText: '算了',
success: (res) => {
// res.action: 'confirm' | 'cancel'
}
})`,
}
// 复制代码到剪贴板
function copyCode(code: string) {
uni.setClipboardData({
data: code,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: '代码已复制到剪贴板' })
},
})
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: `${url} 已复制到剪贴板` })
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
💬
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
全局反馈组件
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
GlobalToast / GlobalLoading / GlobalDialog
</view>
<view class="text-3 wot-text-text-secondary">
统一的用户反馈交互体验,支持在路由导航守卫和网络请求拦截器中使用,扩展 WotUI 交互组件能力。
</view>
</view>
</view>
<!-- GlobalToast 演示 -->
<demo-block title="GlobalToast 轻提示" transparent>
<view class="space-y-4">
<!-- 基础用法 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
基础用法
</view>
<view class="grid grid-cols-2 mb-3 gap-2">
<wd-button type="success" @click="demoToastSuccess">
成功提示
</wd-button>
<wd-button type="danger" @click="demoToastError">
错误提示
</wd-button>
<wd-button type="warning" @click="demoToastWarning">
警告提示
</wd-button>
<wd-button type="info" @click="demoToastInfo">
信息提示
</wd-button>
</view>
<view
class="rounded p-3" @click="copyCode(codeExamples.basicToast)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
代码示例:
</view>
<view class="whitespace-pre-line text-2.5 leading-relaxed font-mono wot-text-text-secondary">
const { success, error, warning, info } = useGlobalToast()
success({ msg: '操作成功!' })
error({ msg: '操作失败!' })
</view>
</view>
</view>
<!-- 自定义用法 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
自定义配置
</view>
<view class="mb-3">
<wd-input v-model="customMessage" placeholder="请输入自定义消息" />
</view>
<view class="grid grid-cols-2 mb-3 gap-2">
<wd-button type="primary" @click="demoCustomToast">
自定义消息
</wd-button>
<wd-button type="success" @click="demoToastPosition('top')">
顶部显示
</wd-button>
<wd-button type="warning" @click="demoToastPosition('middle')">
中间显示
</wd-button>
<wd-button type="info" @click="demoToastPosition('bottom')">
底部显示
</wd-button>
</view>
<view
class="rounded p-3" @click="copyCode(codeExamples.customToast)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
自定义配置:
</view>
<view class="whitespace-pre-line text-2.5 leading-relaxed font-mono wot-text-text-secondary">
show({
msg: '自定义消息',
duration: 3000,
position: 'middle'
})
</view>
</view>
</view>
</view>
</demo-block>
<!-- GlobalLoading 演示 -->
<demo-block title="GlobalLoading 加载提示" transparent>
<view class="space-y-4">
<!-- 基础用法 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
基础用法
</view>
<view class="grid grid-cols-2 mb-3 gap-2">
<wd-button type="primary" @click="demoBasicLoading">
显示加载(3秒)
</wd-button>
<wd-button type="warning" @click="closeLoading">
手动关闭
</wd-button>
<wd-button type="success" @click="demoLoadingNoCover">
无遮罩加载
</wd-button>
<wd-button type="info" @click="demoComplex">
组合演示
</wd-button>
</view>
<view
class="rounded p-3" @click="copyCode(codeExamples.basicLoading)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
代码示例:
</view>
<view class="whitespace-pre-line text-2.5 leading-relaxed font-mono wot-text-text-secondary">
const { loading, close } = useGlobalLoading()
loading('加载中...')
close()
</view>
</view>
</view>
<!-- 自定义用法 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
自定义配置
</view>
<view class="mb-3">
<wd-input v-model="loadingText" placeholder="请输入加载文本" />
</view>
<view class="mb-3">
<wd-button type="primary" block @click="demoCustomLoading">
自定义加载文本
</wd-button>
</view>
<view
class="rounded p-3" @click="copyCode(codeExamples.customLoading)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
自定义配置:
</view>
<view class="whitespace-pre-line text-2.5 leading-relaxed font-mono wot-text-text-secondary">
loading({
msg: '正在处理中...',
cover: true // 是否显示遮罩
})
</view>
</view>
</view>
</view>
</demo-block>
<!-- GlobalDialog 演示 -->
<demo-block title="GlobalDialog 弹窗提示" transparent>
<view class="space-y-4">
<!-- 基础用法 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
基础用法
</view>
<view class="grid grid-cols-3 mb-3 gap-2">
<wd-button type="primary" @click="demoConfirm">
确认弹窗
</wd-button>
<wd-button type="warning" @click="demoAlert">
警告弹窗
</wd-button>
<wd-button type="success" @click="demoPrompt">
输入弹窗
</wd-button>
</view>
<view
class="rounded p-3" @click="copyCode(codeExamples.basicMessage)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
代码示例:
</view>
<view class="whitespace-pre-line text-2.5 leading-relaxed font-mono wot-text-text-secondary">
confirm({
title: '确认操作',
msg: '您确定要执行此操作吗?',
success: (res) => { /* 处理结果 */ }
})
</view>
</view>
</view>
<!-- 自定义用法 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
自定义配置
</view>
<view class="mb-3">
<wd-button type="primary" block @click="demoCustomConfirm">
自定义按钮文字
</wd-button>
</view>
<view
class="rounded p-3" @click="copyCode(codeExamples.customMessage)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
自定义按钮:
</view>
<view class="whitespace-pre-line text-2.5 leading-relaxed font-mono wot-text-text-secondary">
confirm({
confirmButtonText: '好的',
cancelButtonText: '算了'
})
</view>
</view>
</view>
</view>
</demo-block>
<!-- 组件特性 -->
<demo-block title="组件特性" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
🎯
</view>
<view class="text-4 font-bold wot-text-text-main">
设计理念
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>
• <text class="text-blue-600 font-bold dark:text-blue-400">
全局单例
</text>:整个应用只有一个实例,避免重复创建
</view>
<view>
• <text class="text-green-600 font-bold dark:text-green-400">
状态管理
</text>:基于 Pinia 管理组件状态,响应式更新
</view>
<view>
• <text class="text-purple-600 font-bold dark:text-purple-400">
类型安全
</text>:完整的 TypeScript 类型定义
</view>
<view>
• <text class="text-orange-600 font-bold dark:text-orange-400">
易于使用
</text>:简洁的 API 设计,开箱即用
</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
⚡
</view>
<view class="text-4 font-bold wot-text-text-main">
使用优势
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• 统一的用户反馈体验</view>
<view>• 避免组件重复创建和销毁</view>
<view>• 支持深色模式自动适配</view>
<view>• 可在任意组件中调用</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
📝
</view>
<view class="text-4 font-bold wot-text-text-main">
最佳实践
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• Toast:用于简短的状态反馈,不打断用户操作</view>
<view>• Loading:用于异步操作的等待提示</view>
<view>• Message:用于重要信息确认和用户输入</view>
<view>• 合理控制显示时长,避免信息过载</view>
</view>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📚 WotUI Toast" value="轻提示组件" is-link @click="handleNavigate('https://wot-ui.cn/component/toast.html')" />
<wd-cell title="🔄 WotUI Loading" value="加载组件" is-link @click="handleNavigate('https://wot-ui.cn/component/loading.html')" />
<wd-cell title="💬 WotUI MessageBox" value="弹窗组件" is-link @click="handleNavigate('https://wot-ui.cn/component/message-box.html')" />
<wd-cell title="🍍 Pinia 状态管理" value="Vue 状态库" is-link @click="handleNavigate('https://pinia.vuejs.org/zh/')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,483 @@
<script setup lang="ts">
definePage({
name: 'icon',
style: {
navigationBarTitleText: '图标演示',
},
})
const { success: showSuccess } = useGlobalToast()
// WotUI 内置图标列表(精选9个)
const wotIcons = ref([
{ name: 'add', desc: '添加' },
{ name: 'close', desc: '关闭' },
{ name: 'check', desc: '勾选' },
{ name: 'arrow-right', desc: '右箭头' },
{ name: 'bags', desc: '购物袋' },
{ name: 'search', desc: '搜索' },
{ name: 'star', desc: '星标' },
{ name: 'heart', desc: '喜欢' },
{ name: 'setting', desc: '设置' },
])
// Carbon 图标是编译时的,不能动态渲染,所以直接在模板中静态写入
// 图标大小选项
const iconSizes = ref([
{ name: '小', value: '16px' },
{ name: '中', value: '20px' },
{ name: '大', value: '24px' },
{ name: '超大', value: '32px' },
])
const currentIconSize = ref('20px')
// 复制图标代码
function copyIconCode(iconName: string, isCarbon = false) {
const code = isCarbon
? `<div class="i-carbon:${iconName} text-xl"></div>`
: `<wd-icon name="${iconName}" size="20px" />`
uni.setClipboardData({
data: code,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: '代码已复制到剪贴板' })
},
})
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: `${url} 已复制到剪贴板` })
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🎯
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
图标使用演示
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
WotUI 图标 & Iconify 图标集
</view>
<view class="text-3 wot-text-text-secondary">
丰富的图标资源,满足各种使用场景
</view>
</view>
</view>
<!-- 图标大小选择 -->
<demo-block title="图标大小" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
选择图标大小
</view>
<view class="mb-4 flex flex-wrap gap-2">
<view
v-for="size in iconSizes"
:key="size.value"
class="rounded-2 px-3 py-2 text-3 transition-all duration-200"
:class="[
currentIconSize === size.value
? 'bg-blue-500 text-white'
: 'wot-text-text-secondary hover:bg-gray-200 dark:hover:bg-[var(--wot-dark-border)]',
]"
@click="currentIconSize = size.value"
>
{{ size.name }} ({{ size.value }})
</view>
</view>
<view class="wot-bg-bg flex items-center justify-center rounded-2 p-4 space-x-4">
<wd-icon name="star" :size="currentIconSize" color="#f59e0b" />
<view class="i-carbon:star text-yellow-500" :style="{ fontSize: currentIconSize }" />
<text class="text-3 wot-text-text-secondary">
当前大小: {{ currentIconSize }}
</text>
</view>
</view>
</demo-block>
<!-- WotUI 图标 -->
<demo-block title="WotUI 内置图标(精选示例)" transparent>
<!-- 说明文字 -->
<view class="mb-4 border border-blue-200 rounded-2 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<view class="mb-1 text-3.5 text-blue-700 font-bold dark:text-blue-300">
💡 说明
</view>
<view class="text-3 text-blue-600 leading-relaxed dark:text-blue-200">
这里只展示了部分常用图标作为示例。完整的图标列表请查看
<text class="text-blue-700 underline dark:text-blue-300" @click="handleNavigate('https://wot-ui.cn/component/icon.html')">
WotUI 官方文档
</text>
</view>
</view>
<!-- 图标网格 -->
<view class="grid grid-cols-3 gap-3 md:grid-cols-6 sm:grid-cols-4">
<view
v-for="icon in wotIcons"
:key="icon.name"
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode(icon.name)"
>
<view class="mb-2 flex justify-center">
<wd-icon :name="icon.name" :size="currentIconSize" color="#666" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
{{ icon.name }}
</view>
<view class="text-2 wot-text-text-secondary">
{{ icon.desc }}
</view>
</view>
</view>
<!-- 使用示例 -->
<view class="mt-4 rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
使用示例
</view>
<view class="space-y-3">
<view class="rounded p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
基础用法:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
&lt;wd-icon name="star" size="20px" color="#f59e0b" /&gt;
</view>
</view>
<view class="rounded p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
自定义样式:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
&lt;wd-icon name="home" size="24px" color="var(--wot-color-theme)" /&gt;
</view>
</view>
</view>
</view>
</demo-block>
<!-- Carbon 图标 -->
<demo-block title="Carbon 图标集" transparent>
<view class="mb-4 border border-blue-200 rounded-2 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<view class="mb-1 text-3.5 text-blue-700 font-bold dark:text-blue-300">
💡 说明
</view>
<view class="text-3 text-blue-600 leading-relaxed dark:text-blue-200">
Iconify 图标集资源异常丰富,这里我们以 Carbon 图标集为例,展示如何使用图标。
<text class="text-blue-700 underline dark:text-blue-300" @click="handleNavigate('https://icones.js.org/collection/carbon')">
Carbon 图标集
</text>
</view>
</view>
<!-- 图标网格 - 静态渲染 -->
<view class="grid grid-cols-3 gap-3 md:grid-cols-6 sm:grid-cols-4">
<!-- add -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('add', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:add wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
add
</view>
<view class="text-2 wot-text-text-secondary">
添加
</view>
</view>
<!-- close -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('close', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:close wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
close
</view>
<view class="text-2 wot-text-text-secondary">
关闭
</view>
</view>
<!-- checkmark -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('checkmark', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:checkmark wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
checkmark
</view>
<view class="text-2 wot-text-text-secondary">
确认
</view>
</view>
<!-- arrow-right -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('arrow-right', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:arrow-right wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
arrow-right
</view>
<view class="text-2 wot-text-text-secondary">
右箭头
</view>
</view>
<!-- home -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('home', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:home wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
home
</view>
<view class="text-2 wot-text-text-secondary">
首页
</view>
</view>
<!-- search -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('search', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:search wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
search
</view>
<view class="text-2 wot-text-text-secondary">
搜索
</view>
</view>
<!-- star -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('star', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:star wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
star
</view>
<view class="text-2 wot-text-text-secondary">
星级
</view>
</view>
<!-- favorite -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('favorite', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:favorite wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
favorite
</view>
<view class="text-2 wot-text-text-secondary">
收藏
</view>
</view>
<!-- settings -->
<view
class="rounded-2 p-4 text-center transition-all duration-200 wot-bg-filled-oppo active:scale-95 hover:shadow-md"
@click="copyIconCode('settings', true)"
>
<view class="mb-2 flex justify-center">
<view class="i-carbon:settings wot-text-text-secondary" :style="{ fontSize: currentIconSize }" />
</view>
<view class="mb-1 text-2.5 font-mono wot-text-text-main">
settings
</view>
<view class="text-2 wot-text-text-secondary">
设置
</view>
</view>
</view>
<!-- 使用示例 -->
<view class="mt-4 rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
使用示例
</view>
<view class="space-y-3">
<view class="rounded p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
UnoCSS 方式:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
&lt;div class="i-carbon:star text-xl text-yellow-500"&gt;&lt;/div&gt;
</view>
</view>
<view class="rounded p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
自定义大小和颜色:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
&lt;div class="i-carbon:home" style="font-size: 24px; color: #3b82f6;"&gt;&lt;/div&gt;
</view>
</view>
</view>
</view>
</demo-block>
<!-- 对比演示 -->
<demo-block title="图标对比" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-4 text-4 font-bold wot-text-text-main">
相同功能图标对比
</view>
<view class="space-y-4">
<view class="grid grid-cols-2 gap-4">
<view class="rounded-2 bg-blue-50 p-4 text-center dark:bg-blue-900/20">
<view class="mb-2 text-3.5 text-blue-700 font-bold dark:text-blue-300">
WotUI 图标
</view>
<view class="mb-3 flex justify-center space-x-4">
<wd-icon name="home" :size="20" color="#3b82f6" />
<wd-icon name="search1" :size="20" color="#3b82f6" />
<wd-icon name="star" :size="20" color="#3b82f6" />
<wd-icon name="heart" :size="20" color="#3b82f6" />
</view>
<view class="text-2.5 text-blue-600 dark:text-blue-200">
组件化使用,API统一
</view>
</view>
<view class="rounded-2 bg-green-50 p-4 text-center dark:bg-green-900/20">
<view class="mb-2 text-3.5 text-green-700 font-bold dark:text-green-300">
Iconify 图标
</view>
<view class="mb-3 flex justify-center space-x-4">
<view class="i-carbon:home text-20px text-green-600" />
<view class="i-carbon:search text-20px text-green-600" />
<view class="i-carbon:star text-20px text-green-600" />
<view class="i-carbon:favorite text-20px text-green-600" />
</view>
<view class="text-2.5 text-green-600 dark:text-green-200">
原子化CSS,更加灵活
</view>
</view>
</view>
</view>
</view>
</demo-block>
<!-- 使用建议 -->
<demo-block title="使用建议" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
💡
</view>
<view class="text-4 font-bold wot-text-text-main">
WotUI 图标适用场景
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• 与WotUI组件配合使用时</view>
<view>• 需要统一的图标API时</view>
<view>• 快速开发,不需要过多自定义时</view>
<view>• 移动端应用,需要保证兼容性时</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
🎨
</view>
<view class="text-4 font-bold wot-text-text-main">
Iconify 图标适用场景
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• 需要更多图标选择时</view>
<view>• 使用UnoCSS原子化CSS时</view>
<view>• 需要灵活自定义样式时</view>
<view>• 追求更小的包体积时</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
🤝
</view>
<view class="text-4 font-bold wot-text-text-main">
结合使用建议
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>
• <text class="text-blue-600 font-bold dark:text-blue-400">
WotUI图标
</text>:用于组件内置图标,如按钮、表单等
</view>
<view>
• <text class="text-green-600 font-bold dark:text-green-400">
Iconify图标
</text>:用于装饰性图标,如页面图标、状态图标等
</view>
<view>• 两者可以在同一个项目中混用,各取所长</view>
<view>• 建议制定团队规范,明确使用场景</view>
</view>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📚 WotUI 图标文档" value="组件库文档" is-link @click="handleNavigate('https://wot-ui.cn/component/icon.html')" />
<wd-cell title="🎯 Carbon 图标集" value="IBM设计系统" is-link @click="handleNavigate('https://icones.js.org/collection/carbon')" />
<wd-cell title="🔧 Iconify 图标集" value="巨量图标合集" is-link @click="handleNavigate('https://iconify.design/')" />
<wd-cell title="⚡ UnoCSS 图标" value="原子化CSS图标" is-link @click="handleNavigate('https://unocss.dev/presets/icons')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
definePage({
name: 'home-demo',
layout: 'default',
style: {
navigationBarTitleText: 'wot示例',
},
})
const router = useRouter()
const {
theme,
toggleTheme,
currentThemeColor,
showThemeColorSheet,
themeColorOptions,
openThemeColorPicker,
closeThemeColorPicker,
selectThemeColor,
setFollowSystem,
} = useManualTheme()
const isDark = computed({
get() {
return theme.value === 'dark'
},
set() {
toggleTheme()
},
})
// 页面跳转方法
function navigateTo(name: string) {
router.push({
name,
})
}
// 处理主题色选择
function handleThemeColorSelect(option: any) {
selectThemeColor(option)
}
function openUrl(url: string) {
window.open(url, '_blank')
}
</script>
<template>
<view class="box-border py-3">
<view class="mx-3 box-border rounded-3 px-4 py-6 text-center wot-bg-filled-oppo">
<text class="mb-3 block text-left text-5 font-bold wot-text-text-main">
Wot Starter
</text>
<text class="mb-3 block text-left text-30rpx leading-relaxed wot-text-text-secondary">
⚡️ 基于 vitesse-uni-app 由 vite & uni-app 驱动的、深度整合 Wot UI 组件库的快速启动模板
</text>
<text class="block text-left text-3 leading-relaxed wot-text-text-auxiliary">
背靠 Uni Helper、Wot UI 团队,告别 HBuilderX ,拥抱现代前端开发工具链
</text>
</view>
<demo-block title="基础设置" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="暗黑模式">
<wd-switch v-model="isDark" size="18px" />
</wd-cell>
<wd-cell title="跟随系统">
<wd-button size="small" @click="setFollowSystem(true)">
跟随系统
</wd-button>
</wd-cell>
<wd-cell title="选择主题色" is-link @click="openThemeColorPicker">
<view class="flex items-center justify-end gap-2">
<view
class="h-4 w-4 rounded-full"
:style="{ backgroundColor: currentThemeColor.primary }"
/>
<text>{{ currentThemeColor.name }}</text>
</view>
</wd-cell>
</wd-cell-group>
</demo-block>
<demo-block title="工具链介绍" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="🧩 WotUI组件库" is-link @click="openUrl('https://wot-ui.cn/')" />
<!-- <wd-cell title="🧠 Agent Skills" is-link @click="navigateTo('skills')" /> -->
<wd-cell title="🚦 Router 路由管理" is-link @click="navigateTo('router')" />
<wd-cell title="🌐 Alova 网络请求" is-link @click="navigateTo('request')" />
<wd-cell title="🎨 Icon 图标" is-link @click="navigateTo('icon')" />
<wd-cell title="✨ Unocss 原子化" is-link @click="navigateTo('styles')" />
<wd-cell title="🍍 Pinia 持久化" is-link @click="navigateTo('pinia')" />
<wd-cell title="💬 Fedback 反馈组件" is-link @click="navigateTo('feedback')" />
<!-- <wd-cell title="🌱 CreateUni 脚手架" is-link @click="navigateTo('create-uni') " /> -->
<wd-cell title="🔄 CI/CD 持续集成" is-link @click="navigateTo('ci')" />
<wd-cell title="🦾 uni-ku/root" is-link @click="navigateTo('root')" />
<wd-cell title="📊 uni-echarts" is-link @click="navigateTo('echarts')" />
</wd-cell-group>
</demo-block>
<!-- 主题色选择 ActionSheet -->
<wd-action-sheet
v-model="showThemeColorSheet"
title="选择主题色"
:close-on-click-action="true"
@cancel="closeThemeColorPicker"
>
<view class="px-4 pb-4">
<view
v-for="option in themeColorOptions"
:key="option.value"
class="flex items-center justify-between border-b py-3 wot-border-border-main last:border-b-0"
@click="handleThemeColorSelect(option)"
>
<view class="flex items-center gap-3">
<view
class="h-6 w-6 border-2 rounded-full wot-border-border-main"
:style="{ backgroundColor: option.primary }"
/>
<text class="text-4 wot-text-text-main">
{{ option.name }}
</text>
</view>
<wd-icon
v-if="currentThemeColor.value === option.value"
name="check"
:color="option.primary"
size="20px"
/>
</view>
</view>
<wd-gap :height="50" />
</wd-action-sheet>
</view>
</template>
@@ -0,0 +1,547 @@
<script setup lang="ts">
import { defineStore } from 'pinia'
definePage({
name: 'pinia',
style: {
navigationBarTitleText: 'Pinia 演示',
},
})
const { success: showSuccess } = useGlobalToast()
// 定义一个基础的计数器store
const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = 0
}
return {
count,
doubleCount,
increment,
decrement,
reset,
}
})
// 定义一个用户信息store(演示持久化)
const useUserStore = defineStore('user', () => {
const userInfo = ref({
name: '',
email: '',
avatar: '',
theme: 'light',
})
const isLoggedIn = computed(() => !!userInfo.value.name)
function setUserInfo(info: Partial<typeof userInfo.value>) {
userInfo.value = { ...userInfo.value, ...info }
}
function clearUserInfo() {
userInfo.value = {
name: '',
email: '',
avatar: '',
theme: 'light',
}
}
function toggleTheme() {
userInfo.value.theme = userInfo.value.theme === 'light' ? 'dark' : 'light'
}
return {
userInfo,
isLoggedIn,
setUserInfo,
clearUserInfo,
toggleTheme,
}
})
// 定义一个临时store(不持久化)
const useTempStore = defineStore('temp', () => {
const tempData = ref({
message: '',
timestamp: Date.now(),
})
function setMessage(msg: string) {
tempData.value.message = msg
tempData.value.timestamp = Date.now()
}
function clearMessage() {
tempData.value.message = ''
tempData.value.timestamp = Date.now()
}
return {
tempData,
setMessage,
clearMessage,
}
})
// 使用stores
const counterStore = useCounterStore()
const userStore = useUserStore()
const tempStore = useTempStore()
// 表单数据
const userForm = ref({
name: '',
email: '',
})
const tempMessage = ref('')
// 复制代码到剪贴板
function copyCode(code: string) {
uni.setClipboardData({
data: code,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: '代码已复制到剪贴板' })
},
})
}
// 保存用户信息
function saveUserInfo() {
if (!userForm.value.name) {
uni.showToast({
title: '请输入用户名',
icon: 'none',
})
return
}
userStore.setUserInfo({
name: userForm.value.name,
email: userForm.value.email,
avatar: '😆',
})
showSuccess({ msg: '用户信息已保存(会持久化)' })
}
// 设置临时消息
function setTempMessage() {
if (!tempMessage.value) {
uni.showToast({
title: '请输入消息内容',
icon: 'none',
})
return
}
tempStore.setMessage(tempMessage.value)
tempMessage.value = ''
showSuccess({ msg: '临时消息已设置(不会持久化)' })
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: `${url} 已复制到剪贴板` })
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🍍
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
Pinia 状态管理
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
Vue 3 的直观状态管理库
</view>
<view class="mb-2 text-3 wot-text-text-secondary">
轻量、类型安全、支持组合式API
</view>
<view class="text-3 wot-text-text-secondary">
本项目默认持久化所有 Store 数据,开发者可以配置排除列表
</view>
</view>
</view>
<!-- 基础计数器演示 -->
<demo-block title="基础使用 - 计数器" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-4 text-center">
<view class="mb-2 text-8 text-blue-500">
{{ counterStore.count }}
</view>
<view class="text-3 wot-text-text-secondary">
双倍值: {{ counterStore.doubleCount }}
</view>
</view>
<view class="grid grid-cols-3 mb-4 gap-2">
<wd-button type="success" size="small" @click="counterStore.increment">
+1
</wd-button>
<wd-button type="warning" size="small" @click="counterStore.decrement">
-1
</wd-button>
<wd-button type="info" size="small" @click="counterStore.reset">
重置
</wd-button>
</view>
<!-- 代码示例 -->
<view class="space-y-3">
<view class="rounded p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
定义 Store:
</view>
<view
class="text-2.5 leading-relaxed font-mono wot-text-text-secondary" @click="copyCode(`const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})`)"
>
const useCounterStore = defineStore('counter', () => {\n
&nbsp;&nbsp;const count = ref(0)\n
&nbsp;&nbsp;const doubleCount = computed(() => count.value * 2)\n
&nbsp;&nbsp;function increment() { count.value++ }\n
&nbsp;&nbsp;return { count, doubleCount, increment }\n
})
</view>
</view>
<view class="rounded p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
使用 Store:
</view>
<view
class="text-2.5 leading-relaxed font-mono wot-text-text-secondary" @click="copyCode(`const counterStore = useCounterStore()
// 直接使用
counterStore.increment()
// 响应式访问
console.log(counterStore.count)`)"
>
const counterStore = useCounterStore()\n
counterStore.increment() // 调用方法\n
console.log(counterStore.count) // 访问状态
</view>
</view>
</view>
</view>
</demo-block>
<!-- 用户信息演示(持久化) -->
<demo-block title="持久化演示 - 用户信息" transparent>
<view class="mb-3 border border-green-200 rounded-2 bg-green-50 p-3 dark:border-green-800 dark:bg-green-900/20">
<view class="mb-1 text-3.5 text-green-700 font-bold dark:text-green-300">
✅ 持久化存储
</view>
<view class="text-3 text-green-600 leading-relaxed dark:text-green-200">
这个 Store 的数据会自动保存到本地存储,刷新页面后数据不会丢失
</view>
</view>
<view class="space-y-4">
<!-- 当前用户信息 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
当前用户信息
</view>
<view v-if="userStore.isLoggedIn" class="flex items-center space-x-3">
<view class="rounded-full text-12">
{{ userStore.userInfo.avatar }}
</view>
<view class="flex-1">
<view class="text-4 font-bold wot-text-text-main">
{{ userStore.userInfo.name }}
</view>
<view class="text-3 wot-text-text-secondary">
{{ userStore.userInfo.email }}
</view>
</view>
</view>
<view v-else class="text-center wot-text-text-secondary">
暂无用户信息
</view>
</view>
<!-- 用户信息表单 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
设置用户信息
</view>
<view class="space-y-3">
<view>
<view class="mb-1 text-3 wot-text-text-secondary">
用户名:
</view>
<wd-input v-model="userForm.name" placeholder="请输入用户名" />
</view>
<view>
<view class="mb-1 text-3 wot-text-text-secondary">
邮箱:
</view>
<wd-input v-model="userForm.email" placeholder="请输入邮箱" />
</view>
<view class="grid grid-cols-2 gap-2">
<wd-button type="primary" @click="saveUserInfo">
保存信息
</wd-button>
<wd-button type="danger" @click="userStore.clearUserInfo">
清除信息
</wd-button>
</view>
</view>
</view>
</view>
</demo-block>
<!-- 临时数据演示(不持久化) -->
<demo-block title="非持久化演示 - 临时数据" transparent>
<view class="mb-3 border border-orange-200 rounded-2 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
<view class="mb-1 text-3.5 text-orange-700 font-bold dark:text-orange-300">
⚠️ 临时存储
</view>
<view class="text-3 text-orange-600 leading-relaxed dark:text-orange-200">
这个 Store 的数据不会持久化,刷新页面后数据会丢失
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
临时消息
</view>
<view v-if="tempStore.tempData.message" class="mb-4 rounded-2 bg-blue-50 p-3 dark:bg-blue-900/20">
<view class="text-3.5 text-blue-700 font-bold dark:text-blue-300">
{{ tempStore.tempData.message }}
</view>
<view class="text-2.5 text-blue-600 dark:text-blue-200">
时间戳: {{ new Date(tempStore.tempData.timestamp).toLocaleString() }}
</view>
</view>
<view v-else class="mb-4 text-center wot-text-text-secondary">
暂无临时消息
</view>
<view class="space-y-3">
<wd-input v-model="tempMessage" placeholder="请输入临时消息" />
<view class="grid grid-cols-2 gap-2">
<wd-button type="primary" size="small" @click="setTempMessage">
设置消息
</wd-button>
<wd-button type="warning" size="small" @click="tempStore.clearMessage">
清除消息
</wd-button>
</view>
</view>
</view>
</demo-block>
<!-- 持久化原理 -->
<demo-block title="持久化实现原理" transparent>
<view class="space-y-4">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
持久化插件
</view>
<view class="mb-3 text-3 leading-relaxed wot-text-text-secondary">
项目使用自定义的 Pinia 插件实现持久化,位于 <text class="text-blue-600 font-mono dark:text-blue-400">
src/store/persist.ts
</text>
</view>
<view
class="rounded p-3" @click="copyCode(`function persist({ store }, excludedIds) {
// 检查是否需要排除
if (excludedIds.includes(store.$id)) return
// 从本地存储读取数据
const storageState = uni.getStorageSync(store.$id)
if (storageState) {
store.$state = storageState
}
// 监听状态变化并保存
store.$subscribe(() => {
uni.setStorageSync(store.$id, store.$state)
})
}`)"
>
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
核心实现:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
function persist({ store }, excludedIds) {\n
&nbsp;&nbsp;// 检查是否需要排除\n
&nbsp;&nbsp;if (excludedIds.includes(store.$id)) return\n
&nbsp;&nbsp;// 从本地存储读取数据\n
&nbsp;&nbsp;const storageState = uni.getStorageSync(store.$id)\n
&nbsp;&nbsp;// 监听状态变化并保存\n
&nbsp;&nbsp;store.$subscribe(() => {\n
&nbsp;&nbsp;&nbsp;&nbsp;uni.setStorageSync(store.$id, store.$state)\n
&nbsp;&nbsp;})\n
}
</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
排除列表
</view>
<view class="mb-3 text-3 leading-relaxed wot-text-text-secondary">
可以通过排除列表控制哪些 Store 不需要持久化
</view>
<view
class="rounded p-3" @click="copyCode(`// 在 persist.ts 中配置排除列表
persist(context, ['global-register', 'temp'])`)"
>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
// 当前排除的 Store ID:\n
['global-register', 'temp']
</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
使用说明
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>
• <text class="text-green-600 font-bold dark:text-green-400">
自动持久化
</text>:默认情况下,所有 Store 都会自动持久化
</view>
<view>
• <text class="text-orange-600 font-bold dark:text-orange-400">
排除机制
</text>:通过 Store ID 控制哪些不需要持久化
</view>
<view>
• <text class="text-blue-600 font-bold dark:text-blue-400">
深拷贝保护
</text>:使用深拷贝避免引用问题
</view>
<view>
• <text class="text-purple-600 font-bold dark:text-purple-400">
自动恢复
</text>:页面刷新时自动从本地存储恢复数据
</view>
</view>
</view>
</view>
</demo-block>
<!-- 最佳实践 -->
<demo-block title="最佳实践" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
💡
</view>
<view class="text-4 font-bold wot-text-text-main">
Store 设计建议
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• 使用组合式 API 风格定义 Store</view>
<view>• 合理拆分 Store,避免单个 Store 过于庞大</view>
<view>• 使用 TypeScript 获得更好的类型支持</view>
<view>• 为 Store 提供清晰的命名和注释</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
🔒
</view>
<view class="text-4 font-bold wot-text-text-main">
持久化建议
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• 敏感数据(如密码)不要持久化存储</view>
<view>• 大量数据考虑使用数据库而非本地存储</view>
<view>• 临时状态(如加载状态)不需要持久化</view>
<view>• 定期清理无用的持久化数据</view>
</view>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-6">
⚡
</view>
<view class="text-4 font-bold wot-text-text-main">
性能优化
</view>
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary space-y-2">
<view>• 避免在 Store 中存储大量数据</view>
<view>• 使用 computed 缓存复杂计算结果</view>
<view>• 合理使用 $subscribe 监听状态变化</view>
<view>• 考虑使用 $patch 批量更新状态</view>
</view>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📚 Pinia 官方文档" value="状态管理库" is-link @click="handleNavigate('https://pinia.vuejs.org/zh/')" />
<wd-cell title="🎯 Vue 3 文档" value="组合式 API" is-link @click="handleNavigate('https://cn.vuejs.org/guide/extras/composition-api-faq.html')" />
<wd-cell title="🔧 uni-app 存储" value="本地存储 API" is-link @click="handleNavigate('https://uniapp.dcloud.net.cn/api/storage/storage.html')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,290 @@
<script setup lang="ts">
definePage({
name: 'request',
style: {
navigationBarTitleText: '网络请求',
},
})
const GlobalToast = useGlobalToast()
// Alova 核心特性
const alovaFeatures = [
{
icon: '⚡',
title: '简单易用',
description: '观看视频5分钟上手',
details: '极简的API设计,直观的使用方式,让开发者快速掌握并投入使用',
color: 'blue',
},
{
icon: '🔧',
title: '完美兼容',
description: '兼容你最喜欢的技术栈',
details: '支持Vue、React、Angular等主流框架,以及各种HTTP客户端库',
color: 'green',
},
{
icon: '🚀',
title: '高性能模块',
description: '20+ 高性能的业务模块',
details: '提供分页、实时数据、文件上传下载等开箱即用的高性能解决方案',
color: 'purple',
},
{
icon: '🔗',
title: 'OpenAPI 解决方案',
description: '更先进的 openAPI 解决方案',
details: '在代码中和API信息高效交互,自动生成类型定义和请求函数',
color: 'orange',
},
{
icon: '📦',
title: '请求共享缓存',
description: '请求共享和响应缓存',
details: '智能的请求去重和响应缓存机制,显著提升应用性能',
color: 'cyan',
},
{
icon: '🛡️',
title: '类型安全',
description: '完整的 TypeScript 支持',
details: '从请求到响应的全链路类型安全,减少运行时错误',
color: 'red',
},
]
// 使用真正的 useRequest hook - 获取宠物列表
const {
data: petData,
loading: petLoading,
error: petError,
send: loadPetData,
} = useRequest(
(status: 'available' | 'pending' | 'sold' = 'available') => Apis.pet.findPetsByStatus({
params: { status },
}),
{
immediate: false,
},
).onError((error) => {
GlobalToast.error(error.error?.message || '获取宠物列表失败')
})
// 使用真正的 useRequest hook - 用户登录演示
const {
data: loginData,
loading: loginLoading,
error: loginError,
send: performLogin,
} = useRequest(
(username: string, password: string) => Apis.user.loginUser({
params: {
username,
password,
},
}),
{
immediate: false,
},
).onError((error) => {
GlobalToast.error(error.error?.message || '用户登录失败')
})
// useRequest 演示函数
function demoLoadPets() {
loadPetData('available')
}
function demoLogin() {
performLogin('testuser', 'testpass')
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
GlobalToast.success(`${url} 已复制到剪贴板`)
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🌐
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
Alova 网络请求
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
极致高效的请求工具集
</view>
<view class="text-3 leading-relaxed wot-text-text-secondary">
alova完美兼容你最喜欢的HTTP client和UI框架,快速开发客户端和服务的应用的业务逻辑,同时让API信息与代码进行交互,像虫洞一样拉近后端协作距离,极致高效地集成你的APIs
</view>
</view>
</view>
<!-- Alova 核心特性 -->
<demo-block title="Alova 核心特性" transparent>
<view class="grid grid-cols-2 gap-3">
<view
v-for="feature in alovaFeatures"
:key="feature.title"
class="rounded-2 p-3 wot-bg-filled-oppo"
>
<view class="mb-2 flex items-center">
<view class="mr-2 text-5">
{{ feature.icon }}
</view>
<view class="flex-1">
<view class="text-3.5 font-bold wot-text-text-main">
{{ feature.title }}
</view>
<view class="text-2.5 wot-text-text-secondary">
{{ feature.description }}
</view>
</view>
</view>
</view>
</view>
</demo-block>
<!-- Alova useRequest 演示 -->
<demo-block title="useRequest Hook 演示" transparent>
<view class="space-y-3">
<!-- 宠物列表请求 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-5">
🐾
</view>
<view class="text-4 font-bold wot-text-text-main">
宠物列表请求
</view>
</view>
<view class="mb-3 text-3 leading-relaxed wot-text-text-secondary">
使用 useRequest 获取宠物列表,支持参数传递和错误处理
</view>
<view class="mb-3">
<wd-button
type="primary"
block
:loading="petLoading"
@click="demoLoadPets"
>
获取宠物列表
</wd-button>
</view>
<!-- 请求状态显示 -->
<view class="space-y-2">
<view v-if="petLoading" class="flex items-center text-3 text-blue-600">
<wd-icon name="loading" size="14px" class="mr-1" />
正在加载宠物数据...
</view>
<view v-if="petError" class="text-3 text-red-600">
❌ 请求失败: {{ petError.message }}
</view>
<view v-if="petData && !petLoading" class="text-3 text-green-600">
✅ 成功获取 {{ petData.length }} 只宠物数据
</view>
</view>
<!-- 代码示例 -->
<view class="wot-bg-bg mt-3 rounded-2 p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
代码示例:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
const { data, loading, send } = useRequest(\n
&nbsp;&nbsp;(status) => Apis.pet.findPetsByStatus({ params: { status } }),\n
&nbsp;&nbsp;{ immediate: false }\n
).onError((error) => { ... })
</view>
</view>
</view>
<!-- 用户登录请求 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 flex items-center">
<view class="mr-2 text-5">
👤
</view>
<view class="text-4 font-bold wot-text-text-main">
用户登录请求
</view>
</view>
<view class="mb-3 text-3 leading-relaxed wot-text-text-secondary">
演示多参数传递和POST请求处理
</view>
<view class="mb-3">
<wd-button
type="warning"
block
:loading="loginLoading"
@click="demoLogin"
>
模拟用户登录
</wd-button>
</view>
<!-- 请求状态显示 -->
<view class="space-y-2">
<view v-if="loginLoading" class="flex items-center text-3 text-blue-600">
<wd-icon name="loading" size="14px" class="mr-1" />
正在登录中...
</view>
<view v-if="loginError" class="text-3 text-red-600">
❌ 登录失败: {{ loginError.message }}
</view>
<view v-if="loginData && !loginLoading" class="text-3 text-green-600">
✅ 登录成功!
</view>
</view>
<!-- 代码示例 -->
<view class="wot-bg-bg mt-3 rounded-2 p-3">
<view class="mb-2 text-3 font-bold wot-text-text-secondary">
代码示例:
</view>
<view class="text-2.5 leading-relaxed font-mono wot-text-text-secondary">
const { data, loading, send } = useRequest(\n
&nbsp;&nbsp;(username, password) => Apis.user.loginUser({\n
&nbsp;&nbsp;&nbsp;&nbsp;params: { username, password }\n
&nbsp;&nbsp;}),\n
&nbsp;&nbsp;{ immediate: false }\n
).onError((error) => { ... })
</view>
</view>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📚 Alova 官方文档" value="alova.js.org" is-link @click="handleNavigate('https://alova.js.org/')" />
<wd-cell title="🐙 GitHub 仓库" value="alovajs/alova" is-link @click="handleNavigate('https://github.com/alovajs/alova')" />
<wd-cell title="📖 uni-app 文档" value="网络请求" is-link @click="handleNavigate('https://uniapp.dcloud.net.cn/api/request/request.html')" />
<wd-cell title="🎯 Alova Hooks" value="请求策略" is-link @click="handleNavigate('https://alova.js.org/zh-CN/tutorial/client/strategy/')" />
<wd-cell title="💡 最佳实践" value="使用指南" is-link @click="handleNavigate('https://alova.js.org/zh-CN/tutorial/project/best-practice/')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
definePage({
name: 'demo-aftereach',
style: {
navigationBarTitleText: 'afterEach 钩子演示',
},
})
const router = useRouter()
function goBack() {
router.back()
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
📊
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
afterEach 钩子演示
</view>
<view class="text-3.5 wot-text-text-secondary">
这个页面用于演示 afterEach 钩子的触发
</view>
</view>
</view>
<!-- 内容 -->
<demo-block title="afterEach 演示成功" transparent>
<view class="border border-green-200 rounded-2 bg-green-50 p-4 dark:bg-green-900/20">
<view class="mb-2 text-4 text-green-700 font-bold dark:text-green-300">
🎉 恭喜!
</view>
<view class="text-3.5 text-green-600 leading-relaxed dark:text-green-200">
如果你能看到这个页面,说明 afterEach 钩子已经触发了!\n
打开浏览器控制台(F12 → Console)可以看到相关的日志输出。\n
afterEach 钩子常用于页面统计、埋点上报等导航后处理。
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="导航" transparent>
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</demo-block>
</view>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
definePage({
name: 'demo-guard',
style: {
navigationBarTitleText: '导航守卫演示',
},
})
const router = useRouter()
function goBack() {
router.back()
}
// 演示权限拦截
function handleBeforeEach() {
router.push({
name: 'demo-protected',
})
}
// 跳转到afterEach演示页面
function demoAfterEachPage() {
router.push('/subPages/router/demo-aftereach')
}
</script>
<template>
<view class="py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
🛡️
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
导航守卫演示
</view>
<view class="text-3.5 wot-text-text-secondary">
演示 beforeEach 和 afterEach 守卫功能
</view>
</view>
</view>
<!-- 守卫演示 -->
<demo-block title="守卫演示" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
前置导航守卫拦截
</view>
<view class="mb-3 text-3.5 wot-text-text-secondary">
跳转前交互,可以拦截导航
</view>
<wd-button type="danger" block @click="handleBeforeEach">
📊 beforeEach 演示
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
后置导航钩子演示
</view>
<view class="mb-3 text-3.5 wot-text-text-secondary">
跳转到专门的 afterEach 演示页面,体验页面统计、埋点上报等功能
</view>
<wd-button type="success" block @click="demoAfterEachPage">
📊 afterEach 演示
</wd-button>
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="导航" transparent>
<view class="px-3">
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</view>
</demo-block>
</view>
</template>
@@ -0,0 +1,123 @@
<script setup lang="ts">
definePage({
name: 'demo-object',
style: {
navigationBarTitleText: '对象路径跳转',
},
})
const router = useRouter()
const route = useRoute()
function goBack() {
router.back()
}
function pushToParams() {
router.push({ name: 'demo-params', params: { username: 'eduardo' } })
}
function pushToQuery() {
router.push({ path: '/subPages/router/demo-query', query: { keyword: 'vue', type: 'framework' } })
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
🎯
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
对象路径跳转演示
</view>
<view class="text-3.5 wot-text-text-secondary">
使用 router.push({ path: '/path' }) 进行跳转
</view>
</view>
</view>
<!-- 跳转信息 -->
<demo-block title="跳转信息" transparent>
<view class="rounded-3 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
当前页面信息
</view>
<view class="space-y-2">
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
路径:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.path }}
</text>
</view>
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
跳转方式:
</text>
<text class="text-3.5 wot-text-text-main">
对象路径
</text>
</view>
<view class="flex items-center justify-between py-2">
<text class="text-3.5 wot-text-text-secondary">
代码:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
router.push({ path: '/subPages/router/demo-object' })
</text>
</view>
</view>
</view>
</demo-block>
<!-- 参数传递演示 -->
<demo-block title="参数传递演示" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
params 参数传递
</view>
<view class="mb-3 text-3.5 wot-text-text-secondary">
使用 name + params 组合传递参数
</view>
<view class="wot-bg-bg mb-3 border rounded-2 p-3 wot-border-border-main">
<text class="text-3 font-mono wot-text-text-secondary">
router.push({ name: 'demo-params', params: { username: 'eduardo' } })
</text>
</view>
<wd-button type="primary" block @click="pushToParams">
跳转并传递 params
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
query 参数传递
</view>
<view class="mb-3 text-3.5 wot-text-text-secondary">
使用 path + query 组合传递参数
</view>
<view class="wot-bg-bg mb-3 border rounded-2 p-3 wot-border-border-main">
<text class="text-3 font-mono wot-text-text-secondary">
router.push({ path: '/demo-query', query: { keyword: 'vue' } })
</text>
</view>
<wd-button type="success" block @click="pushToQuery">
跳转并传递 query
</wd-button>
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="导航" transparent>
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</demo-block>
</view>
</template>
@@ -0,0 +1,182 @@
<script setup lang="ts">
definePage({
name: 'demo-params',
style: {
navigationBarTitleText: '参数接收演示',
},
})
const router = useRouter()
const route = useRoute()
// 接收参数
const receivedParams = ref({})
const decodedUser = ref(null)
onLoad((option) => {
console.log('onLoad option:', option)
receivedParams.value = option || {}
// 如果有user参数,尝试解码对象
if (option && option.user) {
try {
decodedUser.value = JSON.parse(decodeURIComponent(decodeURIComponent(option.user)))
}
catch (e) {
console.error('解码user参数失败:', e)
}
}
})
function goBack() {
router.back()
}
function pushToGuard() {
router.push('/subPages/router/demo-guard')
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
📥
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
params 参数接收演示
</view>
<view class="text-3.5 wot-text-text-secondary">
演示如何接收和处理路由参数
</view>
</view>
</view>
<!-- 路由信息 -->
<demo-block title="当前路由信息" transparent>
<view class="rounded-3 p-4 wot-bg-filled-oppo">
<view class="space-y-2">
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
路径:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.path }}
</text>
</view>
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
名称:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.name }}
</text>
</view>
<view class="flex items-center justify-between py-2">
<text class="text-3.5 wot-text-text-secondary">
接收方式:
</text>
<text class="text-3.5 wot-text-text-main">
onLoad(option)
</text>
</view>
</view>
</view>
</demo-block>
<!-- 参数信息 -->
<demo-block title="接收到的参数" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
原始参数 (option)
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 font-mono wot-text-text-secondary">
{{ JSON.stringify(receivedParams, null, 2) }}
</text>
</view>
</view>
<view v-if="decodedUser" class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
解码后的对象参数
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 font-mono wot-text-text-secondary">
{{ JSON.stringify(decodedUser, null, 2) }}
</text>
</view>
<view class="mt-3 text-3.5 wot-text-text-secondary">
使用 JSON.parse(decodeURIComponent(decodeURIComponent(option.user))) 解码
</view>
</view>
</view>
</demo-block>
<!-- API 说明 -->
<demo-block title="API 说明" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
Params vs Query 的区别
</view>
<view class="mb-3 border border-orange-200 rounded-2 bg-orange-50 p-3 dark:bg-orange-900/20">
<view class="mb-2 text-3.5 text-orange-700 font-bold dark:text-orange-300">
⚠️ 重要说明
</view>
<view class="text-3 text-orange-600 leading-relaxed dark:text-orange-200">
在 @wot-ui/router 中,params 和 query 参数在实际效果上并无区别,都会以查询字符串形式放在 URL 中。这种 API 设计主要是为了与 vue-router 保持一致。
</view>
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 leading-relaxed font-mono wot-text-text-secondary">
// Params 写法 (当前演示)
router.push({ name: 'demo-params', params: { username: 'eduardo' } })
// 结果: /demo-params?username=eduardo
// Query 写法 (效果相同)
router.push({ path: '/demo-params', query: { username: 'eduardo' } })
// 结果: /demo-params?username=eduardo
</text>
</view>
</view>
</demo-block>
<!-- 代码示例 -->
<demo-block title="代码示例" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
接收参数的标准写法
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 leading-relaxed font-mono wot-text-text-secondary">
onLoad((option) => {
if (option && option.username) {
const username = option.username
}
// 对象参数需要解码
if (option && option.user) {
const user = JSON.parse(decodeURIComponent(decodeURIComponent(option.user)))
}
})
</text>
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="继续演示" transparent>
<view class="space-y-3">
<wd-button type="primary" block @click="pushToGuard">
跳转到导航守卫演示
</wd-button>
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</view>
</demo-block>
</view>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
definePage({
name: 'demo-protected',
style: {
navigationBarTitleText: '受保护页面',
},
})
const router = useRouter()
function goBack() {
router.back()
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
🔒
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
受保护的页面
</view>
<view class="text-3.5 wot-text-text-secondary">
这个页面被路由守卫保护,只有登录用户才能访问
</view>
</view>
</view>
<!-- 内容 -->
<demo-block title="守卫拦截成功" transparent>
<view class="border border-green-200 rounded-2 bg-green-50 p-4 dark:bg-green-900/20">
<view class="mb-2 text-4 text-green-700 font-bold dark:text-green-300">
🎉 恭喜!
</view>
<view class="text-3.5 text-green-600 leading-relaxed dark:text-green-200">
如果你能看到这个页面,说明路由守卫验证通过了!\n
但实际上,这个页面被配置为需要登录才能访问,\n
守卫应该会拦截并重定向你到首页。
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="导航" transparent>
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</demo-block>
</view>
</template>
@@ -0,0 +1,201 @@
<script setup lang="ts">
definePage({
name: 'demo-query',
style: {
navigationBarTitleText: '查询参数接收演示',
},
})
const router = useRouter()
const route = useRoute()
// 接收参数
const receivedQuery = ref({})
const decodedUser = ref(null)
onLoad((option) => {
console.log('onLoad option:', option)
receivedQuery.value = option || {}
// 如果有user参数,尝试解码对象
if (option && option.user) {
try {
decodedUser.value = JSON.parse(decodeURIComponent(decodeURIComponent(option.user)))
}
catch (e) {
console.error('解码user参数失败:', e)
}
}
})
function goBack() {
router.back()
}
function pushToGuard() {
router.push('/subPages/router/demo-guard')
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
📨
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
query 参数接收演示
</view>
<view class="text-3.5 wot-text-text-secondary">
演示如何接收和处理查询参数
</view>
</view>
</view>
<!-- 路由信息 -->
<demo-block title="当前路由信息" transparent>
<view class="rounded-3 p-4 wot-bg-filled-oppo">
<view class="space-y-2">
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
路径:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.path }}
</text>
</view>
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
查询字符串:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ JSON.stringify(route.query) }}
</text>
</view>
<view class="flex items-center justify-between py-2">
<text class="text-3.5 wot-text-text-secondary">
接收方式:
</text>
<text class="text-3.5 wot-text-text-main">
onLoad(option)
</text>
</view>
</view>
</view>
</demo-block>
<!-- 参数信息 -->
<demo-block title="接收到的查询参数" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
原始查询参数 (option)
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 font-mono wot-text-text-secondary">
{{ JSON.stringify(receivedQuery, null, 2) }}
</text>
</view>
<view class="mt-3 text-3.5 wot-text-text-secondary">
URL: /demo-query?keyword={{ (receivedQuery as any).keyword }}&type={{ (receivedQuery as any).type }}
</view>
</view>
<view v-if="decodedUser" class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
解码后的对象参数
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 font-mono wot-text-text-secondary">
{{ JSON.stringify(decodedUser, null, 2) }}
</text>
</view>
<view class="mt-3 text-3.5 wot-text-text-secondary">
使用 JSON.parse(decodeURIComponent(decodeURIComponent(option.user))) 解码
</view>
</view>
</view>
</demo-block>
<!-- Query vs Params 说明 -->
<demo-block title="Query vs Params 说明" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
两种参数传递方式说明
</view>
<view class="mb-3 border border-orange-200 rounded-2 bg-orange-50 p-3 dark:bg-orange-900/20">
<view class="mb-2 text-3.5 text-orange-700 font-bold dark:text-orange-300">
⚠️ 重要说明
</view>
<view class="text-3 text-orange-600 leading-relaxed dark:text-orange-200">
在 @wot-ui/router 中,query 和 params 参数都会放在 URL 中,两者在实际效果上并无区别。这种 API 设计主要是为了与 vue-router 保持一致。
</view>
</view>
<view class="space-y-3">
<view class="border border-blue-200 rounded-2 bg-blue-50 p-3 dark:bg-blue-900/20">
<view class="mb-2 text-3.5 text-blue-700 font-bold dark:text-blue-300">
Query 写法 (当前演示)
</view>
<view class="text-3 text-blue-600 leading-relaxed dark:text-blue-200">
• 使用 path + query 组合\n
• 参数以查询字符串形式出现\n
• 写法: router.push({ path: '/page', query: { key: 'value' } })\n
• 结果: /page?key=value
</view>
</view>
<view class="border border-green-200 rounded-2 bg-green-50 p-3 dark:bg-green-900/20">
<view class="mb-2 text-3.5 text-green-700 font-bold dark:text-green-300">
Params 写法
</view>
<view class="text-3 text-green-600 leading-relaxed dark:text-green-200">
• 使用 name + params 组合\n
• 参数同样以查询字符串形式出现\n
• 写法: router.push({ name: 'page', params: { key: 'value' } })\n
• 结果: /page?key=value (效果相同)
</view>
</view>
</view>
</view>
</demo-block>
<!-- 代码示例 -->
<demo-block title="代码示例" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
接收查询参数的标准写法
</view>
<view class="wot-bg-bg border rounded-2 p-3 wot-border-border-main">
<text class="text-3 leading-relaxed font-mono wot-text-text-secondary">
// 发送方
router.push({
path: '/demo-query',
query: { keyword: 'vue', type: 'framework' }
})
// 接收方
onLoad((option) => {
if (option && option.keyword) {
const keyword = option.keyword // 'vue'
const type = option.type // 'framework'
}
})
</text>
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="继续演示" transparent>
<view class="space-y-3">
<wd-button type="primary" block @click="pushToGuard">
跳转到导航守卫演示
</wd-button>
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</view>
</demo-block>
</view>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
definePage({
name: 'demo-string',
style: {
navigationBarTitleText: '字符串路径跳转',
},
})
const router = useRouter()
const route = useRoute()
function goBack() {
router.back()
}
function pushToObject() {
router.push({ path: '/subPages/router/demo-object' })
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-6 text-center wot-bg-filled-oppo">
<view class="mb-3 text-8">
📝
</view>
<view class="mb-2 text-5 font-bold wot-text-text-main">
字符串路径跳转演示
</view>
<view class="text-3.5 wot-text-text-secondary">
使用 router.push('/path') 进行跳转
</view>
</view>
</view>
<!-- 跳转信息 -->
<demo-block title="跳转信息" transparent>
<view class="rounded-3 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
当前页面信息
</view>
<view class="space-y-2">
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
路径:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.path }}
</text>
</view>
<view class="flex items-center justify-between border-b py-2 wot-border-border-main">
<text class="text-3.5 wot-text-text-secondary">
跳转方式:
</text>
<text class="text-3.5 wot-text-text-main">
字符串路径
</text>
</view>
<view class="flex items-center justify-between py-2">
<text class="text-3.5 wot-text-text-secondary">
代码:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
router.push('/subPages/router/demo-string')
</text>
</view>
</view>
</view>
</demo-block>
<!-- 操作按钮 -->
<demo-block title="继续演示" transparent>
<view class="space-y-3">
<wd-button type="primary" block @click="pushToObject">
跳转到对象路径演示
</wd-button>
<wd-button type="warning" block @click="goBack">
返回上一页
</wd-button>
</view>
</demo-block>
</view>
</template>
@@ -0,0 +1,469 @@
<script setup lang="ts">
definePage({
name: 'router',
style: {
navigationBarTitleText: '路由管理',
},
})
const { success: showSuccess } = useGlobalToast()
const router = useRouter()
const route = useRoute()
// 表单数据
const userId = ref('eduardo')
const searchKeyword = ref('vue')
const userName = ref('小星星')
const userLabel = ref('小熊熊')
// 基础导航方法
function pushByString() {
// 字符串路径
router.push('/subPages/router/demo-string')
showSuccess({ msg: '使用字符串路径跳转' })
}
function pushByPath() {
// 带有路径的对象
router.push({ path: '/subPages/router/demo-object' })
showSuccess({ msg: '使用path对象跳转' })
}
function pushByName() {
// 命名的路由
router.push({ name: 'demo-object' })
showSuccess({ msg: '使用name跳转' })
}
// 参数传递示例
function pushWithParams() {
if (!userId.value) {
uni.showToast({
title: '请输入用户ID',
icon: 'none',
})
return
}
// 命名的路由,并加上参数
router.push({ name: 'demo-params', params: { username: userId.value } })
showSuccess({ msg: `传递参数: ${userId.value}` })
}
function pushWithQuery() {
if (!searchKeyword.value) {
uni.showToast({
title: '请输入搜索关键词',
icon: 'none',
})
return
}
// 带查询参数
router.push({ path: '/subPages/router/demo-query', query: { username: searchKeyword.value } })
showSuccess({ msg: `传递查询参数: ${searchKeyword.value}` })
}
// 传递对象参数
function pushWithObjectParams() {
const user = {
name: userName.value,
label: userLabel.value,
}
// 命名路由传递对象参数
router.push({
name: 'demo-params',
params: { user: encodeURIComponent(JSON.stringify(user)) },
})
showSuccess({ msg: '传递对象参数(params)' })
}
function pushWithObjectQuery() {
const user = {
name: userName.value,
label: userLabel.value,
}
// path+query传递对象参数
router.push({
path: '/subPages/router/demo-query',
query: { user: encodeURIComponent(JSON.stringify(user)) },
})
showSuccess({ msg: '传递对象参数(query)' })
}
// 导航守卫演示
function demoNavigationGuards() {
showSuccess({ msg: '跳转到完整的导航守卫演示页面' })
router.push({
name: 'demo-guard',
})
}
// Router方法演示
function demoPush() {
router.push('/subPages/router/demo-string')
showSuccess({ msg: 'router.push() - 保留当前页面跳转' })
}
function demoPushTab() {
router.pushTab('/pages/index/index')
showSuccess({ msg: 'router.pushTab() - 跳转到tabBar页面' })
}
function demoReplace() {
router.replace('/subPages/router/demo-object')
showSuccess({ msg: 'router.replace() - 替换当前页面' })
}
function demoReplaceAll() {
router.replaceAll('/pages/index/index')
showSuccess({ msg: 'router.replaceAll() - 关闭所有页面重新开始' })
}
function demoBack() {
router.back()
showSuccess({ msg: 'router.back() - 返回上一页' })
}
// 复制代码到剪贴板
function copyCode(code: string) {
uni.setClipboardData({
data: code,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: '代码已复制到剪贴板' })
},
})
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: `${url} 已复制到剪贴板` })
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🚀
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
@wot-ui/router
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
轻量级路由管理解决方案
</view>
<view class="text-3 wot-text-text-secondary">
支持编程式导航、参数传递、导航守卫等功能
</view>
</view>
</view>
<!-- 当前路由信息 -->
<demo-block title="当前路由信息" transparent>
<view class="rounded-3 p-4 wot-bg-filled-oppo">
<view class="flex items-center justify-between border-b py-2 wot-border-border-main last:border-b-0">
<text class="text-3.5 wot-text-text-secondary">
路径:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.path }}
</text>
</view>
<view class="flex items-center justify-between border-b py-2 wot-border-border-main last:border-b-0">
<text class="text-3.5 wot-text-text-secondary">
名称:
</text>
<text class="text-3.5 font-mono wot-text-text-main">
{{ route.name || '未设置' }}
</text>
</view>
<view class="flex items-center justify-between border-b py-2 wot-border-border-main last:border-b-0">
<text class="text-3.5 wot-text-text-secondary">
参数:
</text>
<text class="break-all text-3.5 font-mono wot-text-text-main">
{{ JSON.stringify(route.params) }}
</text>
</view>
<view class="flex items-center justify-between py-2">
<text class="text-3.5 wot-text-text-secondary">
查询:
</text>
<text class="break-all text-3.5 font-mono wot-text-text-main">
{{ JSON.stringify(route.query) }}
</text>
</view>
</view>
</demo-block>
<!-- 编程式导航 -->
<demo-block title="编程式导航" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
基础用法
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.push(\'/user\')')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.push('/user')
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="grid grid-cols-1 gap-2">
<wd-button type="primary" size="small" @click="pushByString">
字符串路径跳转
</wd-button>
<wd-button type="success" size="small" @click="pushByPath">
对象路径跳转
</wd-button>
<wd-button type="warning" size="small" @click="pushByName">
命名路由跳转
</wd-button>
</view>
</view>
</view>
</demo-block>
<!-- Router方法演示 -->
<demo-block title="Router方法演示" transparent>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
push 方法
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
保留当前页面,跳转到应用内的某个页面,相当于 uni.navigateTo()
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.push(target)')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.push(target)
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<wd-button type="primary" block @click="demoPush">
演示 push 方法
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
pushTab 方法
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面,相当于 uni.switchTab()
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.pushTab(target)')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.pushTab(target)
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<wd-button type="success" block @click="demoPushTab">
演示 pushTab 方法
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
replace 方法
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
关闭当前页面,跳转到应用内的某个页面,相当于 uni.redirectTo()
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.replace(target)')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.replace(target)
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<wd-button type="warning" block @click="demoReplace">
演示 replace 方法
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
replaceAll 方法
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
关闭所有页面,打开到应用内的某个页面,相当于 uni.reLaunch()
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.replaceAll(target)')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.replaceAll(target)
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<wd-button type="danger" block @click="demoReplaceAll">
演示 replaceAll 方法
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
back 方法
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
关闭当前页面,返回上一页面或多级页面,相当于 uni.navigateBack()
</view>
<view class="mb-3 space-y-2">
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.back()')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.back()
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.back({ delta: 2 })')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.back({ delta: 2 })
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
<wd-button type="info" block @click="demoBack">
返回上一页
</wd-button>
</view>
</view>
</demo-block>
<!-- 参数传递 -->
<demo-block title="参数传递" transparent>
<view class="mb-3 border border-orange-200 rounded-2 bg-orange-50 p-3 dark:bg-orange-900/20">
<view class="mb-2 text-3.5 text-orange-700 font-bold dark:text-orange-300">
⚠️ 重要提示
</view>
<view class="text-3 text-orange-600 leading-relaxed dark:text-orange-200">
在 @wot-ui/router 中,params 和 query 参数都会以查询字符串形式放在 URL 中,两者在实际效果上并无区别。这种 API 设计主要是为了与 vue-router 保持一致。
</view>
</view>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
params 参数
</view>
<view class="mb-3 text-3.5 wot-text-text-secondary">
注意:name 和 params 搭配使用,与 query 效果相同
</view>
<view class="mb-3">
<wd-input v-model="userId" placeholder="请输入用户名" />
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.push({ name: \'user\', params: { username: \'eduardo\' } })')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.push({ name: 'user', params: { username: 'eduardo' } })
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<wd-button type="primary" block @click="pushWithParams">
传递 params 参数
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
query 参数
</view>
<view class="mb-3 text-3.5 wot-text-text-secondary">
注意:path 可以与 query 一起使用,与 params 效果相同
</view>
<view class="mb-3">
<wd-input v-model="searchKeyword" placeholder="请输入搜索关键词" />
</view>
<view class="wot-bg-bg mb-3 flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.push({ path: \'/user\', query: { username: \'eduardo\' } })')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.push({ path: '/user', query: { username: 'eduardo' } })
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<wd-button type="success" block @click="pushWithQuery">
传递 query 参数
</wd-button>
</view>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
传递对象参数
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
URL有长度限制,复杂对象需要使用 encodeURIComponent 编码
</view>
<view class="grid grid-cols-2 mb-3 gap-2">
<wd-input v-model="userName" placeholder="姓名" />
<wd-input v-model="userLabel" placeholder="标签" />
</view>
<view class="grid grid-cols-2 gap-2">
<wd-button type="primary" size="small" @click="pushWithObjectParams">
对象 params
</wd-button>
<wd-button type="success" size="small" @click="pushWithObjectQuery">
对象 query
</wd-button>
</view>
</view>
</view>
</demo-block>
<!-- 导航守卫 -->
<demo-block title="导航守卫" transparent>
<view class="mb-3 border border-blue-200 rounded-2 bg-blue-50 p-3 dark:bg-blue-900/20">
<view class="mb-2 text-3.5 text-blue-700 font-bold dark:text-blue-300">
💡 完整演示
</view>
<view class="text-3 text-blue-600 leading-relaxed dark:text-blue-200">
导航守卫功能比较复杂,我们准备了专门的演示页面,包含实时日志、权限验证、参数检查等完整功能演示。
</view>
</view>
<view class="space-y-3">
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
基础守卫 API
</view>
<view class="mb-3 text-3.5 leading-relaxed wot-text-text-secondary">
beforeEach 和 afterEach 是最常用的导航守卫
</view>
<view class="mb-3 space-y-2">
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.beforeEach((to, from, next) => { next() })')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.beforeEach((to, from, next) => { next() })
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
<view class="wot-bg-bg flex items-center justify-between border rounded-2 p-3 wot-border-border-main" @click="copyCode('router.afterEach((to, from) => { })')">
<text class="flex-1 text-3 font-mono wot-text-text-secondary">
router.afterEach((to, from) => { console.log(to) })
</text>
<wd-icon name="copy" size="16px" color="#666" />
</view>
</view>
<wd-button type="primary" block @click="demoNavigationGuards">
🛡️ 进入完整守卫演示
</wd-button>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📚 @wot-ui/router 文档" value="路由管理" is-link @click="handleNavigate('https://moonofweisheng.github.io/@wot-ui/router/')" />
<wd-cell title="🐙 GitHub 仓库" value="@wot-ui/router" is-link @click="handleNavigate('https://my-uni.wot-ui.cn/')" />
<wd-cell title="📖 uni-app 路由文档" value="页面路由" is-link @click="handleNavigate('https://uniapp.dcloud.net.cn/tutorial/page.html')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
definePage({
name: 'skills',
layout: 'default',
style: {
navigationBarTitleText: 'Agent Skills',
navigationBarBackgroundColor: '#f9fafb',
navigationBarTextStyle: 'black',
},
})
const toast = useToast()
const skills = [
{
name: 'alova-api-module',
desc: '快速创建 Alova 请求模块和 Mock 数据',
icon: 'swap',
color: '#007AFF',
},
{
name: 'global-feedback',
desc: '全局反馈组件(Toast/Message/Loading)使用指南',
icon: 'chat',
color: '#34C759',
},
{
name: 'pinia-store-generator',
desc: '创建符合项目规范的 Pinia Store',
icon: 'folder',
color: '#FFCC00',
},
{
name: 'uni-page-generator',
desc: '基于项目规范快速生成 uni-app 页面',
icon: 'file',
color: '#FF9500',
},
{
name: 'vue-composable-creator',
desc: '快速创建 Vue 3 组合式函数',
icon: 'setting',
color: '#5856D6',
},
{
name: 'wot-router-usage',
desc: '@wot-ui/router 轻量级路由库使用指南',
icon: 'location',
color: '#FF2D55',
},
{
name: 'wot-ui',
desc: 'wot-ui uni-app 组件库开发指南',
icon: 'app',
color: '#FE5900',
},
]
function handleClick(skill: string) {
toast.success(`已选择: ${skill}`)
}
function getIconBg(color: string) {
// Simple hex to rgba with low opacity
const r = Number.parseInt(color.slice(1, 3), 16)
const g = Number.parseInt(color.slice(3, 5), 16)
const b = Number.parseInt(color.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, 0.1)`
}
</script>
<template>
<view class="pb-safe box-border min-h-screen wot-bg-bg">
<!-- Header -->
<view class="px-6 pb-6 pt-8">
<view class="mb-2 text-3xl font-bold leading-tight wot-text-text-main">
Agent Skills
</view>
<view class="text-base leading-relaxed wot-text-text-secondary">
利用智能工具赋能开发效率,<br>打造极致开发体验。
</view>
</view>
<!-- Content -->
<view class="px-5 space-y-4">
<view
v-for="(skill, index) in skills"
:key="skill.name"
class="group relative animate-fade-in-up overflow-hidden rounded-[24px] p-5 shadow-sm transition-all duration-300 wot-bg-filled-oppo active:scale-[0.98]"
:style="{ animationDelay: `${index * 100}ms` }"
@click="handleClick(skill.name)"
>
<view class="flex items-start gap-4">
<!-- Icon Container -->
<view
class="h-14 w-14 flex shrink-0 items-center justify-center rounded-[18px] transition-transform duration-300 group-active:scale-110"
:style="{ backgroundColor: getIconBg(skill.color) }"
>
<wd-icon
:name="skill.icon"
size="28px"
:color="skill.color"
custom-style="display: block;"
/>
</view>
<!-- Text Content -->
<view class="min-w-0 flex-1 pt-0.5">
<view class="flex items-center justify-between">
<view class="truncate pr-2 text-lg font-bold wot-text-text-main">
{{ skill.name }}
</view>
<wd-icon name="arrow-right" color="#E5E7EB" size="20px" />
</view>
<view class="line-clamp-2 mt-1 text-sm leading-relaxed wot-text-text-secondary">
{{ skill.desc }}
</view>
</view>
</view>
<!-- Decoration (optional glow or highlight) -->
<view
class="pointer-events-none absolute h-24 w-24 rounded-full opacity-[0.03] -bottom-4 -right-4"
:style="{ backgroundColor: skill.color }"
/>
</view>
</view>
<wd-toast />
</view>
</template>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
opacity: 0; /* meaningful for delay */
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pb-safe {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
</style>
@@ -0,0 +1,493 @@
<script setup lang="ts">
definePage({
name: 'styles',
style: {
navigationBarTitleText: 'UnoCSS 演示',
},
})
const { success: showSuccess } = useGlobalToast()
// 演示状态
const animationState = ref(false)
// UnoCSS 核心特性
const unocssFeatures = ref([
{
icon: '⚡',
title: '即时按需',
desc: '只生成你使用的CSS',
example: 'text-red-500 bg-blue-100',
},
{
icon: '🎨',
title: '完全可定制',
desc: '灵活的配置和预设',
example: 'custom-color-brand',
},
{
icon: '🔧',
title: '预设丰富',
desc: '内置Tailwind、Windi等预设',
example: '@unocss/preset-wind',
},
{
icon: '🚀',
title: '性能优异',
desc: '极快的构建速度',
example: '< 200ms',
},
])
// 颜色系统演示
const colorCategories = ref([
{
name: '灰色系',
colors: [
{ name: 'gray-50', class: 'wot-bg-bg', value: '#f9fafb' },
{ name: 'gray-100', class: 'bg-gray-100', value: '#f3f4f6' },
{ name: 'gray-200', class: 'bg-gray-200', value: '#e5e7eb' },
{ name: 'gray-300', class: 'bg-gray-300', value: '#d1d5db' },
{ name: 'gray-400', class: 'bg-gray-400', value: '#9ca3af' },
{ name: 'gray-500', class: 'wot-bg-bg0', value: '#6b7280' },
],
},
{
name: '主题色',
colors: [
{ name: 'blue-500', class: 'bg-blue-500', value: '#3b82f6' },
{ name: 'green-500', class: 'bg-green-500', value: '#10b981' },
{ name: 'red-500', class: 'bg-red-500', value: '#ef4444' },
{ name: 'yellow-500', class: 'bg-yellow-500', value: '#f59e0b' },
{ name: 'purple-500', class: 'bg-purple-500', value: '#8b5cf6' },
{ name: 'pink-500', class: 'bg-pink-500', value: '#ec4899' },
],
},
])
// 布局演示数据
const layoutExamples = ref([
{
title: 'Flex 居中',
code: 'flex items-center justify-center',
class: 'flex items-center justify-center h-20 bg-blue-100 rounded-2 text-blue-800',
},
{
title: 'Grid 网格',
code: 'grid grid-cols-3 gap-4',
class: 'grid grid-cols-3 gap-2',
},
{
title: 'Space Between',
code: 'flex justify-between items-center',
class: 'flex justify-between items-center h-16 bg-green-100 rounded-2 px-4 text-green-800',
},
])
// 间距系统
const spacingExamples = ref([
{ name: 'p-1', value: '4px', class: 'p-1 bg-red-100 inline-block m-1 rounded' },
{ name: 'p-2', value: '8px', class: 'p-2 bg-blue-100 inline-block m-1 rounded' },
{ name: 'p-4', value: '16px', class: 'p-4 bg-green-100 inline-block m-1 rounded' },
{ name: 'p-6', value: '24px', class: 'p-6 bg-yellow-100 inline-block m-1 rounded' },
{ name: 'p-8', value: '32px', class: 'p-8 bg-purple-100 inline-block m-1 rounded' },
])
// 字体系统
const fontExamples = ref([
{ name: 'text-xs', size: '12px', class: 'text-xs' },
{ name: 'text-sm', size: '14px', class: 'text-sm' },
{ name: 'text-base', size: '16px', class: 'text-base' },
{ name: 'text-lg', size: '18px', class: 'text-lg' },
{ name: 'text-xl', size: '20px', class: 'text-xl' },
{ name: 'text-2xl', size: '24px', class: 'text-2xl' },
{ name: 'text-3xl', size: '30px', class: 'text-3xl' },
])
// 阴影和效果
const effectExamples = ref([
{ name: 'shadow-sm', class: 'shadow-sm wot-bg-filled-oppo p-4 rounded-2 mb-3' },
{ name: 'shadow', class: 'shadow wot-bg-filled-oppo p-4 rounded-2 mb-3' },
{ name: 'shadow-md', class: 'shadow-md wot-bg-filled-oppo p-4 rounded-2 mb-3' },
{ name: 'shadow-lg', class: 'shadow-lg wot-bg-filled-oppo p-4 rounded-2 mb-3' },
{ name: 'shadow-xl', class: 'shadow-xl wot-bg-filled-oppo p-4 rounded-2 mb-3' },
])
// 切换动画
function toggleAnimation() {
animationState.value = !animationState.value
showSuccess({ msg: `动画状态: ${animationState.value ? '开启' : '关闭'}` })
}
// 复制代码
function copyCode(code: string) {
uni.setClipboardData({
data: code,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: '代码已复制到剪贴板' })
},
})
}
// 链接导航处理
function handleNavigate(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.hideToast()
showSuccess({ msg: `${url} 已复制到剪贴板` })
},
})
// #endif
}
</script>
<template>
<view class="min-h-screen py-3">
<!-- 头部介绍 -->
<view class="mx-3 mb-3">
<view class="rounded-3 px-5 py-8 text-center wot-bg-filled-oppo">
<view class="mb-3 text-10">
🎨
</view>
<view class="mb-2 text-6 font-bold wot-text-text-main">
UnoCSS 原子化CSS
</view>
<view class="mb-2 text-3.5 leading-relaxed wot-text-text-secondary">
即时按需的原子化CSS引擎
</view>
<view class="text-3 wot-text-text-secondary">
具有高性能且极具灵活性的即时原子化CSS引擎
</view>
</view>
</view>
<!-- UnoCSS 核心特性 -->
<demo-block title="UnoCSS 核心特性" transparent>
<view class="grid grid-cols-2 gap-3">
<view
v-for="feature in unocssFeatures"
:key="feature.title"
class="rounded-2 p-4 wot-bg-filled-oppo"
>
<view class="mb-2 text-6">
{{ feature.icon }}
</view>
<view class="mb-1 text-3.5 font-bold wot-text-text-main">
{{ feature.title }}
</view>
<view class="mb-2 text-3 leading-snug wot-text-text-secondary">
{{ feature.desc }}
</view>
<view class="rounded px-2 py-1 text-2.5 font-mono wot-text-text-secondary">
{{ feature.example }}
</view>
</view>
</view>
</demo-block>
<!-- 颜色系统 -->
<demo-block title="颜色系统" transparent>
<view
v-for="category in colorCategories"
:key="category.name"
class="mb-4 last:mb-0"
>
<view class="mb-3 text-4 font-bold wot-text-text-main">
{{ category.name }}
</view>
<view class="grid grid-cols-3 gap-2">
<view
v-for="color in category.colors"
:key="color.name"
class="overflow-hidden rounded-2 shadow-sm wot-bg-filled-oppo"
@click="copyCode(color.class)"
>
<view :class="color.class" class="h-12 w-full" />
<view class="p-2">
<view class="text-2.5 font-mono wot-text-text-main">
{{ color.name }}
</view>
<view class="text-2 wot-text-text-secondary">
{{ color.value }}
</view>
</view>
</view>
</view>
</view>
</demo-block>
<!-- 布局系统 -->
<demo-block title="布局系统" transparent>
<view class="space-y-4">
<view
v-for="example in layoutExamples"
:key="example.title"
class="rounded-2 p-4 wot-bg-filled-oppo"
>
<view class="mb-3 flex items-center justify-between">
<view class="text-4 font-bold wot-text-text-main">
{{ example.title }}
</view>
<view class="rounded px-2 py-1 text-2.5 font-mono wot-text-text-secondary" @click="copyCode(example.code)">
{{ example.code }}
</view>
</view>
<view v-if="example.title === 'Flex 居中'" :class="example.class">
<text class="text-3.5">
居中内容
</text>
</view>
<view v-else-if="example.title === 'Grid 网格'" :class="example.class">
<view
v-for="n in 6"
:key="n"
class="rounded bg-blue-100 p-2 text-center text-3 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ n }}
</view>
</view>
<view v-else :class="example.class">
<text class="text-3.5">
左侧
</text>
<text class="text-3.5">
右侧
</text>
</view>
</view>
</view>
</demo-block>
<!-- 间距系统 -->
<demo-block title="间距系统" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
内边距 (Padding)
</view>
<view class="mb-4 text-3 leading-relaxed wot-text-text-secondary">
点击下方色块复制对应的 padding 类名
</view>
<view class="flex flex-wrap items-center gap-2">
<view
v-for="spacing in spacingExamples"
:key="spacing.name"
:class="spacing.class"
class="cursor-pointer"
@click="copyCode(spacing.name)"
>
<text class="text-2.5 font-mono wot-text-text-secondary">
{{ spacing.name }}
</text>
</view>
</view>
<view class="mt-4 text-2.5 wot-text-text-secondary">
💡 提示:p-1 = 4px, p-2 = 8px, p-4 = 16px, p-6 = 24px, p-8 = 32px
</view>
</view>
</demo-block>
<!-- 字体系统 -->
<demo-block title="字体系统" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
字体大小
</view>
<view class="space-y-3">
<view
v-for="font in fontExamples"
:key="font.name"
class="flex items-center justify-between border-b pb-2 wot-border-border-main last:border-b-0"
@click="copyCode(font.name)"
>
<text :class="font.class" class="wot-text-text-main">
字体演示文字
</text>
<view class="flex items-center space-x-2">
<text class="text-2.5 wot-text-text-secondary">
{{ font.size }}
</text>
<text class="rounded px-2 py-1 text-2.5 font-mono wot-text-text-secondary">
{{ font.name }}
</text>
</view>
</view>
</view>
</view>
</demo-block>
<!-- 阴影效果 -->
<demo-block title="阴影效果" transparent>
<view class="space-y-3">
<view
v-for="effect in effectExamples"
:key="effect.name"
:class="effect.class"
@click="copyCode(effect.name)"
>
<view class="flex items-center justify-between">
<text class="text-3.5 wot-text-text-main">
阴影效果演示
</text>
<text class="rounded px-2 py-1 text-2.5 font-mono wot-text-text-secondary">
{{ effect.name }}
</text>
</view>
</view>
</view>
</demo-block>
<!-- 交互效果 -->
<demo-block title="交互效果" transparent>
<view class="space-y-4">
<!-- 悬停效果 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
悬停效果
</view>
<view class="grid grid-cols-2 gap-3">
<view class="rounded-2 bg-blue-100 p-4 text-center transition-all duration-300 active:scale-95 hover:scale-105 hover:bg-blue-200">
<text class="text-3 text-blue-800">
悬停变色
</text>
</view>
<view class="rounded-2 bg-green-100 p-4 text-center transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<text class="text-3 text-green-800">
悬停上浮
</text>
</view>
</view>
<view class="mt-3 rounded p-2 text-2.5 font-mono wot-text-text-secondary" @click="copyCode('hover:bg-blue-200 hover:scale-105 transition-all duration-300')">
hover:bg-blue-200 hover:scale-105 transition-all duration-300
</view>
</view>
<!-- 动画效果 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
动画效果
</view>
<wd-button type="primary" block @click="toggleAnimation">
切换动画状态
</wd-button>
<view class="mt-4 flex justify-center">
<view
class="rounded-2 p-6 text-center transition-all duration-500 ease-in-out"
:class="[
animationState
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white transform rotate-12 scale-110 shadow-lg'
: 'wot-text-text-main transform rotate-0 scale-100 shadow-sm ',
]"
>
<text class="text-4 font-bold">
动画演示
</text>
</view>
</view>
<view class="mt-3 rounded p-2 text-2.5 leading-relaxed font-mono wot-text-text-secondary" @click="copyCode('transition-all duration-500 ease-in-out transform rotate-12 scale-110')">
transition-all duration-500 ease-in-out\n
transform rotate-12 scale-110
</view>
</view>
</view>
</demo-block>
<!-- 响应式设计 -->
<demo-block title="响应式设计" transparent>
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
响应式断点
</view>
<view class="mb-4 text-3 leading-relaxed wot-text-text-secondary">
UnoCSS 支持响应式断点,可以根据屏幕尺寸应用不同样式
</view>
<view class="mb-4 rounded-2 bg-red-100 p-4 text-center dark:bg-red-900/30 lg:bg-yellow-100 md:bg-blue-100 sm:bg-green-100 dark:lg:bg-yellow-900/30 dark:md:bg-blue-900/30 dark:sm:bg-green-900/30">
<text class="text-3.5 font-bold wot-text-text-main">
响应式背景色
</text>
</view>
<view class="grid grid-cols-1 gap-3 lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2">
<view
v-for="n in 8"
:key="n"
class="rounded bg-purple-100 p-3 text-center text-3 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
>
项目 {{ n }}
</view>
</view>
<view class="mt-4 rounded p-2 text-2.5 leading-relaxed font-mono wot-text-text-secondary" @click="copyCode('grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4')">
grid-cols-1 sm:grid-cols-2\n
md:grid-cols-3 lg:grid-cols-4
</view>
</view>
</demo-block>
<!-- 实用工具类 -->
<demo-block title="实用工具类" transparent>
<view class="space-y-4">
<!-- 显示隐藏 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
显示/隐藏
</view>
<view class="mb-3 flex flex-wrap gap-2">
<view class="rounded bg-green-100 px-3 py-2 text-3 text-green-800">
显示 (block)
</view>
<view class="rounded bg-blue-100 px-3 py-2 text-3 text-blue-800">
内联 (inline)
</view>
<view class="rounded bg-purple-100 px-3 py-2 text-3 text-purple-800">
弹性 (flex)
</view>
</view>
<view class="rounded p-2 text-2.5 font-mono wot-text-text-secondary" @click="copyCode('block inline flex hidden')">
block inline flex hidden
</view>
</view>
<!-- 定位 -->
<view class="rounded-2 p-4 wot-bg-filled-oppo">
<view class="mb-3 text-4 font-bold wot-text-text-main">
定位系统
</view>
<view class="relative h-24 rounded-2 bg-gray-100">
<view class="absolute left-2 top-2 rounded bg-red-500 px-2 py-1 text-2.5 text-white">
top-2 left-2
</view>
<view class="absolute right-2 top-2 rounded bg-blue-500 px-2 py-1 text-2.5 text-white">
top-2 right-2
</view>
<view class="absolute bottom-2 left-2 rounded bg-green-500 px-2 py-1 text-2.5 text-white">
bottom-2 left-2
</view>
<view class="absolute bottom-2 right-2 rounded bg-purple-500 px-2 py-1 text-2.5 text-white wot-bg-warning">
bottom-2 right-2
</view>
</view>
<view class="mt-3 rounded p-2 text-2.5 font-mono wot-text-text-secondary" @click="copyCode('absolute relative top-2 left-2 right-2 bottom-2')">
absolute relative top-2 left-2 right-2 bottom-2
</view>
</view>
</view>
</demo-block>
<!-- 相关链接 -->
<demo-block title="相关链接" transparent>
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
<wd-cell title="📚 UnoCSS 官方文档" value="unocss.dev" is-link @click="handleNavigate('https://unocss.dev/')" />
<wd-cell title="🐙 GitHub 仓库" value="unocss/unocss" is-link @click="handleNavigate('https://github.com/unocss/unocss')" />
<wd-cell title="🎮 在线演练场" value="在线体验" is-link @click="handleNavigate('https://unocss.dev/play/')" />
<wd-cell title="📖 Tailwind CSS" value="参考文档" is-link @click="handleNavigate('https://tailwindcss.com/docs')" />
<wd-cell title="💡 最佳实践" value="使用指南" is-link @click="handleNavigate('https://unocss.dev/guide/')" />
</wd-cell-group>
</demo-block>
</view>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
definePage({
name: 'root',
style: {
navigationBarTitleText: 'uni-ku/root',
},
})
const show = ref<boolean>(false)
</script>
<template>
<page-meta :page-style="`overflow:${show ? 'hidden' : 'visible'};`" />
<view class="min-h-200vh">
<demo-block title="锁定滚动" transparent>
<wd-cell-group border>
<wd-cell title="锁定滚动" is-link @click="show = !show" />
</wd-cell-group>
</demo-block>
<wd-popup
v-model="show"
lock-scroll
position="bottom"
closable
:safe-area-inset-bottom="true"
custom-style="height: 200px;"
@close="show = false"
/>
</view>
</template>