1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-09-12 16:40:40 +08:00

fix: 修复配置类接口缓存问题,统一添加cacheFor:0绕过alova内存缓存

1. 为所有获取应用配置、审核数据、爱心配置、评论列表、插件可用性的接口添加cacheFor:0参数,确保配置变更后即时生效
2. 修复home.vue和maintenance.vue的缩进格式问题,统一代码风格
3. 新增uni-halo项目开发规范文档
This commit is contained in:
小莫唐尼
2026-09-04 13:49:21 +08:00
parent 2fbd3fb19a
commit 570c394501
5 changed files with 1708 additions and 1007 deletions
+720
View File
@@ -0,0 +1,720 @@
---
name: uni-halo
description: 提供 uni-halo 项目开发规范,包括目录分层、类型定义、alova API 约定、页面/组件/样式规范、请求 hooks 与完整示例代码。
license: Complete terms in LICENSE.txt
---
# uni-halo 开发规范
> 本技能是 uni-halo 项目的「开发手册」:所有规范均提炼自本仓库真实代码(`src/` 目录),
> 写代码前先读本文,能减少返工、保持全库风格统一。
## 什么时候使用这个技能
- 在本项目**新建或修改页面**(判断放 `src/pages/` 还是 `src/pages-blog/` 等分包、命名、`definePage` 写法)
- 在本项目**开发业务或通用组件**`uh-` 前缀、组件内部结构、事件命名)
- **定义或修改 API**(约定使用 alova,格式、`meta` 传参、类型文件位置)
- **定义或修改类型**`src/api/types/` 下的接口与请求/响应类型)
- **使用通用配置**`src/config/` 的 appConfig / appSettings / haloGlobal / markdown
- **页面数据请求**`useDataLoading` / `useRequest` hooks + `uh-data-loading` 组件)
- **处理多平台差异**(H5 / 微信小程序 / APP,条件编译)
## 三条铁律(先记住)
1. **生成物不手改**`src/pages.json``src/manifest.json``src/types/*.d.ts` 均由
`*.config.ts` / `definePage` / 插件自动生成,手改会在下次构建被覆盖。
页面路由写在页面内 `definePage` 宏里,不写进 `pages.config.ts`
2. **平台差异只用条件编译**`#ifdef H5 / #ifdef MP-WEIXIN / #ifdef APP-PLUS`
编译期能确定的不留到运行时;运行时判断仅用于编译期无法确定的场景。
3. **UI 优先原子类**:先 UnoCSS 原子类,再自定义 CSS(`<style scoped lang="scss">`),
全局复用样式才放 `src/style/`
---
## 1. 目录分层(代码放哪里)
```
src/
├── pages/ # 主包页面(约定式路由,文件路径即路由,无需注册)
│ ├── index/ # 入口页(definePage type: 'home'
│ ├── tabbar/ # tabbar 四个主页面(home/category/gallery/moments/about
│ ├── auth/ # 登录/注册
│ └── maintenance/ # 维护页
├── pages-blog/ # 分包一:博客业务页面(对应旧版 pagesA,如文章、投票、图库、瞬间)
├── pages-demo/ # 分包二:demo 预留
├── components/ # 全局组件(uh- 前缀,easycom 自动注册,免 import
├── api/ # API 定义(alova),类型在 api/types/ 下
├── http/ # 请求封装(alova 实例、拦截器、错误工具)
├── hooks/ # 组合式函数(auto-import,页面免 import 直接调用)
├── config/ # 通用配置(appConfig/appSettings/haloGlobal/markdown 默认值)
├── store/ # Pinia store(持久化用 pinia-plugin-persistedstate
├── style/ # 全局样式(index.scss 在 main.ts 引入)
├── static/ # 静态资源(@img → src/static/imagesSVG 图标 → static/my-icons/
├── tabbar/ # 底部导航(config.ts 是唯一配置源)
├── utils/ # 通用工具(url/storage/merge/plugin/vote 等)
├── locale/ # i18nzh-Hans / en
└── layouts/ # 布局(default.vue
```
- 别名:`@``src/``@img``src/static/images`
- **分包判断**:页面是否首屏必需?不是 → 放分包(`pages-blog` / `pages-demo`)。
分包目录在 `vite.config.ts``UniPages({ subPackages: [...] })` 注册,**不能**是 `src/pages` 的子目录。
- **请求一律走 alova**`@/http/alova`),业务代码禁止直接 `uni.request`
- 服务端状态以接口为事实源,store 只放需要跨页共享/持久化的状态。
## 2. 类型定义(放哪、怎么命名)
**位置**`src/api/types/`,与 API 文件一一对应:
| API 文件 | 类型文件 | 内容 |
|---------|---------|------|
| `src/api/halo.ts` | `src/api/types/halo.ts` | Halo 官方接口(文章/分类/标签/评论/瞬间/图库/友链) |
| `src/api/uni-halo.ts` | `src/api/types/uni-halo.ts` | plugin-uni-halo 插件与三方插件接口 |
| `src/api/login.ts` | `src/api/types/login.ts` | 登录相关 |
**命名规则**
- 接口用 `I` 前缀 + PascalCase`IPost``IUser`
- 请求参数 `IXxxReq`,响应 `IXxxRes``IPostListReq` / `IPostListRes`
- 对象类型用 `interface`,联合类型用 `type`,导入类型用 `import type`
- 响应统一包在 `IResponse<T>` 里(见 §3.3
```ts
// src/api/types/halo.ts
/** 文章列表请求参数 */
export interface IPostListReq {
page: number
size: number
sort?: string[]
keyword?: string
[key: string]: unknown
}
/** 文章列表响应 */
export interface IPostListRes {
page: number
size: number
total: number
hasNext: boolean
items: IPost[]
}
```
## 3. API 定义(约定使用 alova
### 3.1 请求实例
`src/http/alova.ts` 导出 `http` 实例(alova + uniapp 适配器),统一处理:baseURL、
token、超时、错误归一化、Halo 响应包装(原始数据 → `{ code, data, message }`)。
**业务代码不手写请求头 token**
### 3.2 定义格式(GET / POST
```ts
// src/api/halo.ts
import { http } from '@/http/alova'
import { RequestFrom } from '@/http/tools/enum'
import type { IResponse } from '@/http/types'
import type { IPostListReq, IPostListRes, ISearchReq, ISearchRes } from './types/halo'
/** GETquery 参数走 params */
export function getPostList(params: IPostListReq) {
return http.Get<IResponse<IPostListRes>>('/apis/api.content.halo.run/v1alpha1/posts', {
params,
meta: { requestFrom: RequestFrom.Halo },
})
}
/** POST:第一参 url,第二参 body */
export function getPostListByKeyword(params: ISearchReq) {
return http.Post<IResponse<ISearchRes>>('/apis/api.halo.run/v1alpha1/indices/-/search', params, {
meta: { requestFrom: RequestFrom.Halo },
})
}
```
要点:
- 函数命名 `getXxx` / `submitXxx` / `checkXxx` / `unlockXxx`,见名知义
- URL 用 Halo 完整 API 路径:`/apis/api.content.halo.run/v1alpha1/posts`
- 每个函数顶部写 JSDoc 注释说明用途、特殊行为(如 `cacheFor: 0` 的原因)
**复杂 GET 参数用 `query: params`(不要用 `params`**
某些 GET 接口需要传复杂/嵌套参数(数组、对象、需要自定义序列化的参数)时,
`params` 无法满足;alova 类型中也**未定义 `query` 字段**(会报类型错误),
但它是 uni.request 原生的 query 参数(支持复杂序列化),项目约定**需要时直接传 `query: params`**
```ts
/** 文章列表(复杂参数序列化:用 query 而非 params */
export function getPostList(params: IPostListReq) {
return http.Get<IResponse<IPostListRes>>('/apis/api.content.halo.run/v1alpha1/posts', {
query: params, // 复杂/嵌套参数走 query;简单键值参数才用 params
meta: { requestFrom: RequestFrom.Halo },
})
}
```
- 简单键值参数(`{ page, size }` 等)用 `params` 即可
- 复杂/嵌套/需要特殊序列化的参数(数组、对象等)用 `query: params`
- alova 类型未声明 `query`,如遇类型报错需在调用处处理(如类型断言),不要改用 `params` 硬传
### 3.3 响应结构 `IResponse<T>` 与 `meta`
| 约定 | 说明 |
|------|------|
| `meta.requestFrom: RequestFrom.Halo` | **Halo 官方/插件接口必传**,拦截器会把原始数据归一化为 `{ code, data, message }`;标准接口为 `Standard` |
| `meta.domain` | 切换请求域名(如 `API_DOMAINS.SECONDARY`,见 `src/api/foo-alova.ts` |
| `meta.personalToken` | 带个人令牌的接口(非匿名投票等) |
| `cacheFor: 0` | 绕过 alova 内存缓存,**配置类接口必加**(维护模式/配置变更需即时生效,如 `getAppConfigs` |
响应拿到的是 `IResponse<T>`,业务里取 `res.data` 即为 `T`
```ts
const res = await getPostList({ page: 1, size: 5 })
const items = res.data.items // IPost[]
```
### 3.4 带特殊 header 的接口
在 alova 配置项里直接传 `headers`(如访客标识):
```ts
export function getPostByName(name: string) {
return http.Get<IResponse<IPost>>(`/apis/api.content.halo.run/v1alpha1/posts/${name}`, {
headers: {
'Wechat-Session-Id': getOpenid(),
'nologin-email': getNologinEmail(),
},
meta: { requestFrom: RequestFrom.Halo },
})
}
```
### 3.5 错误处理
- 拦截器已统一错误提示,业务层 `try/catch` 即可,catch 到的是 `UniHaloError`(含 `message` / `code` / `data`
- 需要静默的场景自行 `console.error` + 处理,不重复 toast
- 401 由拦截器统一处理(双 token 无感刷新),**业务不自行处理 401**
---
## 4. 通用配置(src/config/ 怎么用)
| 文件 | 导出 | 用途 |
|------|------|------|
| `src/config/appConfig.ts` | `DefaultAppConfigs` | 应用配置默认值(banner/布局/插件配置等),与 `getAppConfigs()` 返回 deepMerge |
| `src/config/appSettings.ts` | `DefaultAppSettings` + `IAppSettings` | 应用设置默认值与类型(头像圆角/布局/广告/图库瀑布流等) |
| `src/config/haloGlobal.ts` | `DefaultHaloGlobalConfigs` | Halo 全局配置默认值(匿名评论/注册开关等) |
| `src/config/markdown.ts` | `markdownConfig` | mp-html 富文本渲染配置(tagStyle / containerStyle |
**正确用法**
- **页面读配置**:优先从 store 读,不要直接 import 默认值
```ts
import { useAppConfigStore } from '@/store/appConfig'
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
// 取字段:haloConfigs.value.pageConfig?.homeConfig?.bannerConfig
```
- **拉取配置**`appConfigStore.bootstrap()` 统一并行拉取
getConfigs + audit-data + love-configTTL 5 分钟内直接返回缓存,`{ force: true }` 强制刷新。
- **富文本渲染**`<mp-html :content="html" :tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle" />`
- 默认值是「兜底」,服务端返回后 `deepMerge` 覆盖,**不要直接改默认值文件**。
---
## 5. 页面规范
### 5.1 页面放哪个文件夹
| 页面类型 | 位置 | 示例 |
|---------|------|------|
| 入口页(首页分流) | `src/pages/index/` | `index.vue``definePage({ type: 'home' })` |
| tabbar 主页面 | `src/pages/tabbar/模块/` | `tabbar/home/home.vue`、`tabbar/moments/moments.vue` |
| 登录/注册 | `src/pages/auth/` | `login.vue`、`register.vue` |
| 维护页 | `src/pages/maintenance/` | `maintenance.vue` |
| 博客业务页面 | `src/pages-blog/模块/`(分包) | `article-detail/`、`votes/`、`vote-detail/`、`gallery/`、`search/`、`setting/` |
| demo 示例 | `src/pages-demo/`(分包) | `ucharts/` |
**分包判断**:页面是否首屏必需?不是 → 放 `pages-blog/`(博客功能)或 `pages-demo/`(示例)。
`src/pages-blog` 对应旧版项目的 pagesA 子包。
### 5.2 命名规则
- **目录**kebab-case`article-detail/`、`vote-detail/`),页面文件与目录同名:`article-detail/article-detail.vue`
- **文件**kebab-case,见名知义(`moment-detail.vue`、`submit-link.vue`、`data-visual.vue`
- **tabbar 页面**`src/pages/tabbar/模块名/模块名.vue`
- 页面私有组件放页面同目录 `components/` 子目录(构建时已排除,不会被识别成页面)
### 5.3 SFC 结构(顺序是 eslint 强制的)
```
<script setup lang="ts"> ← 第一个(页面配置 definePage 放最上面)
<template> ← 第二个
<style scoped lang="scss"> ← 最后一个(没有可省略)
```
```vue
<script lang="ts" setup>
/**
* 页面职责注释(源自旧项目 pagesX/xxx,新建复刻 / 说明)
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getVoteList } from '@/api/uni-halo'
import type { IVoteItem } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票中心',
enablePullDownRefresh: true,
},
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const dataList = ref<IVoteItem[]>([])
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
// ...
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
handleGetData()
})
</script>
```
**约定**
- 区块用 `/* ---- 状态 ---- */` 分隔:状态 → 计算属性 → 数据加载 → 跳转 → 生命周期
- 事件处理函数 `handleXxx`,跳转函数 `handleToXxxPage`
- 页面根节点用 `app-page` 类 + 主题底色:`<view class="app-page min-h-screen w-screen flex flex-col bg-page">`
- 页面标题 `navigationBarTitleText` 写中文;下拉刷新 `enablePullDownRefresh: true`
### 5.4 页面数据请求(统一 hooks + uh-data-loading 组件)
**数据加载四态**`loading / error / empty / success`,由 `useDataLoading` hook 接管,
页面只提供请求函数,**取代「手工 ref + try/catch 逐个搬运状态」的写法**。
```vue
<script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app'
import { getMomentByName } from '@/api/halo'
import { useDataLoading } from '@/hooks/useDataLoading'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间详情',
enablePullDownRefresh: true,
backgroundColor: '#f6f3ee', // 下拉露出的窗口底色对齐页面底色
},
})
const { data: moment, status, run: loadMoment } = useDataLoading(
async (): Promise<IMoment> => {
const res = await getMomentByName(queryName.value)
return res.data
},
{
onSuccess: (data) => {
// 数据就绪后的处理
},
onError: () => {
// 失败处理(run 已捕获异常,不会向外抛出)
},
},
)
onLoad(() => {
loadMoment()
})
onPullDownRefresh(async () => {
await loadMoment()
uni.stopPullDownRefresh()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen bg-page px-4 pb-8 pt-4">
<!-- 状态区:loading/error/empty 由 uh-data-loading 展示,refresh 触发重新请求 -->
<uh-data-loading
v-if="status !== 'success'"
:loading-status="status"
min-height="60vh"
error-text="瞬间内容加载失败"
empty-text="瞬间不存在或已被删除"
@refresh="loadMoment"
/>
<!-- 成功态:渲染数据 -->
<template v-else>
<view>{{ moment?.spec.releaseTime }}</view>
</template>
</view>
</template>
```
**要点**
- `useDataLoading(fetcher, { isEmpty, onSuccess, onError })` 返回 `{ data, status, run }`
- `isEmpty` 自定义判空(默认:数组看长度、对象看键数、空值视为空)
- `run` 已捕获异常,失败置 `status='error'`,不会向外抛出
- 模板里 `v-if="status !== 'success'"` 显示 `<uh-data-loading>`,否则渲染数据
- `<uh-data-loading>` 常用 props`loading-status`(必传)、`min-height`、`loading-text`、
`error-text`、`empty-text`(留空显示默认文案);`@refresh` 绑重试函数
- **旧页面**用的 `useDataLoadingStatus``DataLoadingStatusEnum`)已标记 `@deprecated`
新代码一律用 `useDataLoading`
- 简单场景也可用 `useRequest(fn, { immediate })`:返回 `{ loading, error, data, run }`
### 5.5 列表页(分页加载)
列表页可用 z-pagingeasycom 已配置 `<z-paging>` 直接用)或手写分页。
手写分页的既有模式(参考 `src/pages/tabbar/home/home.vue`、`pages-blog/votes/votes.vue`):
```ts
const isLoadMore = ref(false)
const hasNext = ref(false)
const queryParams = ref({ page: 1, size: 10 })
async function handleGetData() {
if (!isLoadMore.value)
loading.value = 'loading'
try {
const res = await getVoteList({ ...queryParams.value })
hasNext.value = res.data.hasNext || false
dataList.value = isLoadMore.value
? dataList.value.concat(res.data.items)
: res.data.items
loading.value = 'success'
}
catch (err) {
console.error(err)
loading.value = 'error'
}
}
onReachBottom(() => {
if (!hasNext.value) {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
return
}
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
})
```
### 5.6 依赖插件的页面(插件可用性 + 维护拦截)
Halo 是插件化 CMS,页面可能依赖插件(投票 plugin-vote、瞬间 PluginMoments 等):
```ts
import { usePluginAvailable } from '@/utils/plugin'
const uniHaloPluginId = 'plugin-vote'
const uniHaloPluginAvailable = ref(true)
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value)
return
handleGetData()
})
```
```vue
<template>
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用,无法使用投票功能哦,请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<!-- 正常内容 -->
</template>
</view>
</template>
```
主插件可用性 + 维护模式拦截用统一 hook(入口页/首页已用):
```ts
const { interceptOrContinue } = useMaintenanceIntercept()
onLoad(async () => {
if (await interceptOrContinue())
return // 已跳转维护页,中断后续逻辑
})
```
---
## 6. 组件规范
### 6.1 命名与位置
- **全局组件**`src/components/uh-组件名/uh-组件名.vue`easycom 自动注册(`^uh-(.*)`),
页面直接 `<uh-xxx />`**无需 import**
- 现有:`uh-data-loading`、`uh-button`、`uh-navbar`、`uh-vote-card`、`uh-plugin-unavailable`、
`uh-comment-list`、`uh-album-photo-viewer` 等
- **页面私有组件**:放页面目录的 `components/` 子目录(如 `src/pages-blog/xx/components/`
- 组件名 kebab-case,目录与文件名一致
### 6.2 组件内部结构(标准模板)
```vue
<script lang="ts" setup>
/**
* 组件职责注释(源自旧项目 components/xxx,新建复刻)
* 说明:数据来源、与父组件的契约
*/
import { computed } from 'vue'
import { getVoteDetail } from '@/api/uni-halo'
import type { IVote } from '@/api/types/uni-halo'
interface IProps {
/** 必传 prop */
voteName: string
/** 可选 prop */
errorText?: string
}
const props = withDefaults(defineProps<IProps>(), {
errorText: '',
})
const emit = defineEmits<{
(e: 'on-vote-success'): void
}>()
const isSingle = computed(() => voteData.value?.spec?.type === 'single')
async function handleGetData() {
// 组件内部请求自己的数据(业务内聚在组件里)
}
function handleSubmit() {
// 处理完 emit 事件通知父组件
emit('on-vote-success')
}
</script>
<template>
<view class="uh-vote-card w-full rounded-[28rpx] bg-white p-4">
<slot />
</view>
</template>
<style scoped lang="scss">
/* unocss 表达不了的再写 scoped 样式(keyframes、复杂状态配色等) */
</style>
```
**约定**
- `IProps` 接口定义 props`withDefaults` 提供默认值;props 用 JSDoc 注释说明用途
- 事件命名 `on-xxx`(如 `on-refresh`、`on-vote-success`),模板里 `@on-refresh`
- 通用样式优先 unocss 原子类;动画/复杂状态用 `<style scoped lang="scss">`
- 根节点类名与组件名一致(`uh-vote-card`),方便定位
- **组件内的请求放 `onMounted` 中**:组件没有页面级 `onLoad` 生命周期,
需要首屏请求时在 `onMounted` 里发起;依赖 props 变化重新请求用
`watch(() => props.xxx, fn, { immediate: true })`(参考 `uh-vote-card`),
需要父组件手动刷新时再 `defineExpose({ refresh })`
### 6.3 高内聚低耦合(业务尽量内聚)
- **业务逻辑能内聚在组件里的就内聚**:如 `uh-vote-card` 自己拉取投票详情、自己计算
投票状态、自己提交投票,页面只传 `vote-name`、监听 `on-vote-success` 提示
- **页面尽量薄**:页面只负责数据请求编排(hooks)、跳转、传参给组件
- **能抽 hooks 的就抽 hooks**:跨页面复用的业务逻辑放 `src/hooks/`(如
`use-maintenance-intercept`、`useScroll`、`useUpload`),auto-import 免 import 直接调用
- 组件不直接依赖 store 的散装字段,通过 props 传入、events 传出,降低耦合
---
## 7. 样式规范(苹果玻璃拟态风格)
### 7.1 优先级(铁律 3
```
UnoCSS 原子类(优先) → <style scoped lang="scss">(页面/组件内) → src/style/(全局复用)
```
### 7.2 主题色(uno.config.ts theme,与 wot-ui 联动)
| 令牌 | 值 | 用法 | 说明 |
|------|-----|------|------|
| `primary` | `#B9E424` | `text-primary` / `bg-primary` | 主主题色(荧光绿),已联动 wot-ui `--wot-color-theme` |
| `secondary` | `#D7F94C` | `text-secondary` / `bg-secondary` | 辅助色 |
| `page` | `#f6f3ee` | `bg-page` | 页面底色(米白) |
### 7.3 常用约定
- 快捷方式:`center`flex 居中)
- 安全区:`p-safe` / `pt-safe` / `pb-safe`(刘海屏/底部横条)
- 小字号:`text-2xs`20rpx/ `text-3xs`18rpx
- 单位用 rpx;**动态拼接的图标类名必须加入 `uno.config.ts` 的 safelist**,否则不生成样式
- 本地 SVG 图标放 `src/static/my-icons/`,用 `i-my-icons-图标名` 调用
- 全局玻璃拟态工具类(`src/style/index.scss`):
- `uh-global-card-glass`:毛玻璃卡片(backdrop-blur 白底 + 柔和内阴影 + 低端安卓兜底)
- `uh-shadow-xs`:柔和阴影(白色卡片常用)
### 7.4 苹果玻璃拟态(iOS 风格)要点
- 大面积 `bg-page` 米白底色 + 白色圆角卡片(`rounded-[28rpx]`+ `uh-shadow-xs`
- 毛玻璃:半透明白底 + `backdrop-filter: blur(24rpx) saturate(160%)`(组件内写 scoped scss
- 大圆角、留白充足、渐变/动效柔和克制(参考 `uh-data-loading` 的漂浮光晕动效)
- 文字层级:主文字深灰(`text-gray-900`)、次要灰(`text-gray-400/500`)、点缀用 primary 色
- UI 库 **wot-ui**`wd-` 前缀,easycom 已配):`wd-icon`、`wd-button`、`wd-empty`、`wd-tabs` 等;
图标大量使用 `wd-icon`emoji 表情用 `uhemoji-iconfont`
### 7.5 富文本
文章/瞬间正文用 `mp-html` 渲染(easycom 已配),配置走 `markdownConfig`
```vue
<mp-html
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
/>
```
---
## 8. 开发规范(高内聚低耦合)
### 8.1 业务逻辑分层
```
页面:请求编排(hooks)+ 跳转 + 传参给组件(薄页面)
组件:自身业务内聚(自己拉数据、算状态、提交),props 进、events 出
hooks:跨页面复用的业务逻辑(useDataLoading / useRequest / useScroll / useUpload / useMaintenanceIntercept
api:接口定义 + 类型(不写业务逻辑)
```
- **组件能自己处理业务就自己处理**,不在页面里写一堆判断
- **逻辑能抽 hooks 就抽 hooks**,页面里只留编排
- 组件不直接依赖 store 散装字段,props/events 解耦
### 8.2 TypeScript 规范
- **禁止新增 `any`**(团队约定);对象类型 `interface`、联合类型 `type`、导入 `import type`
- API 响应必须走 `src/api/types/` 的类型,过边界校验后才使用
- 组件 props 用 `interface IProps` + `withDefaults`
- 不自动排序 import(条件编译注释可能包裹 import,eslint 已关排序)
### 8.3 状态管理(Pinia
- store 放 `src/store/``defineStore` 定义
- 需要持久化的状态配置 `pinia-plugin-persistedstate`(如 `useAppConfigStore` 的 `persist: true`),不手写 `uni.setStorageSync` 同步业务状态
- `uni.getStorageSync` 读出的数据是**真边界数据**,使用前校验类型/结构
- 服务端状态以接口为事实源,store 只放跨页共享/持久化状态
### 8.4 国际化(i18n
- 文案文件在 `src/locale/`zh-Hans / en),页面用 `t()` 取文案:
```ts
import { t } from '@/locale'
loadMoreText.value = t('common.loadMore')
```
- 纯展示的静态中文(如状态文案)可写死在页面;会变化/需要多语言的走 t()
### 8.5 Hooks 规范
- 新 hooks 放 `src/hooks/`auto-import`unplugin-auto-import` 已配 `dirs: ['src/hooks']`),页面**免 import 直接调用**
- 命名 `useXxx`;有配套单测的写 `xxx.test.ts`(如 `useDataLoading.test.ts`、`useRequest.test.ts`
- 参考既有实现风格:`useDataLoading`(状态机四态)、`useScroll`、`useUpload`(平台条件编译处理)
### 8.6 Git 提交与合入
- **提交信息必须使用中文**:subject 用中文描述(如 `feat: 新增投票功能`、`fix: 修复文章详情白屏`
- commitlint 强制 conventional commits`feat: / fix: / docs: / style: / refactor: / perf: / test: / chore:`
- 版本发布走 changesets`pnpm upload:changeset`
- 合入前三条命令全过(见 §10
---
## 9. 平台差异(条件编译)
### 9.1 决策树
```
这个差异编译期就能确定吗?
├── 能 → 条件编译(#ifdef / #ifndef
└── 不能 → 运行时 API 判断(uni.getSystemInfoSync().uniPlatform 等)
```
**禁止**:用 UA 嗅探替代条件编译;在能编译期剔除的代码里留运行时 if。
### 9.2 条件编译速查
```ts
// #ifdef H5
import { h5Api } from '@/utils/h5'
// #endif
// #ifndef MP-WEIXIN
// 所有平台生效,除微信小程序
// #endif
```
```vue
<template>
<!-- #ifdef APP-PLUS -->
<view>仅 APP</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<wd-button open-type="contact">提交反馈</wd-button>
<!-- #endif -->
</template>
```
平台标识:`H5`、`MP-WEIXIN`、`MP-ALIPAY`、`APP-PLUS`app-android / app-ios)、`MP`(泛小程序)。
### 9.3 本项目已有差异点(改动时别破坏)
| 差异点 | 位置 | 规则 |
|--------|------|------|
| devServer 代理(`VITE_APP_PROXY_*` | vite.config.ts | 仅 H5 dev 生效;非 H5 端 dev 也是 build command,无热更新代理 |
| eruda 调试面板 | vite.config.ts | 仅 H5 development |
| 自动打开开发者工具 | scripts/open-dev-tools.js | mp-weixin/mp-alipay 构建后自动打开;上传用 `SKIP_OPEN_DEVTOOLS=true` 跳过 |
| bundle-optimizer 分包优化 | vite.config.ts | 仅微信小程序端 |
| 微信三环境地址 | env/.env | `VITE_SERVER_BASEURL__WEIXIN_DEVELOP/TRIAL/RELEASE` 按开发者工具 envVersion 覆写 |
---
## 10. 验证清单(合入前)
```bash
pnpm type-check # vue-tsc --noEmit,类型零错误(同时重新生成 uni-pages.d.ts 等类型)
pnpm lint # eslint 零 error(可用 pnpm lint:fix 自动修复)
pnpm test:run # vitest 全绿
```
- 新建页面后跑 `pnpm type-check`:重新生成 `src/types/uni-pages.d.ts`,路由类型才可用
- 修改配置类(pages/manifest/uno)后重启 dev server
- 小程序上传:`pnpm upload:mp`
---
## Keywords
uni-halo, uniapp, uni-app, Vue3, TypeScript, alova, halo, halo2, 博客, CMS, pages-blog, 分包, subPackages, easycom, uh-data-loading, useDataLoading, useRequest, hooks, definePage, wot-ui, UnoCSS, unocss, scss, 苹果风格, 玻璃拟态, 主题色, primary, 条件编译, 多平台, H5, 小程序, APP, 页面规范, 组件规范, API规范, 类型定义, i18n, pinia
+1
View File
@@ -294,6 +294,7 @@ export function postTrackersCounter(data: ITrackerCounterReq) {
*/ */
export function checkPluginAvailable(name: string) { export function checkPluginAvailable(name: string) {
return http.Get<IResponse<IPluginAvailable>>(`/apis/api.plugin.halo.run/v1alpha1/plugins/${name}/available`, { return http.Get<IResponse<IPluginAvailable>>(`/apis/api.plugin.halo.run/v1alpha1/plugins/${name}/available`, {
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo }, meta: { requestFrom: RequestFrom.Halo },
}) })
} }
+5
View File
@@ -56,9 +56,12 @@ const COMMENT_WIDGET_CAPTCHA_COOKIES = 'comment-widget-captcha'
/** /**
* 获取应用配置 * 获取应用配置
* 注意:cacheFor: 0 绕过 alova 内存缓存——维护模式/配置变更后需即时生效
* (维护页刷新、入口/首页拦截均依赖本接口的最新状态)
*/ */
export function getAppConfigs() { export function getAppConfigs() {
return http.Get<IResponse<IAppConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getConfigs', { return http.Get<IResponse<IAppConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getConfigs', {
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo }, meta: { requestFrom: RequestFrom.Halo },
}) })
} }
@@ -68,6 +71,7 @@ export function getAppConfigs() {
*/ */
export function getAuditData() { export function getAuditData() {
return http.Get<IResponse<IAuditDataResult>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/audit-data', { return http.Get<IResponse<IAuditDataResult>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/audit-data', {
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo }, meta: { requestFrom: RequestFrom.Halo },
}) })
} }
@@ -196,6 +200,7 @@ export function getNoticeDetail(name: string) {
*/ */
export function getLoveConfig() { export function getLoveConfig() {
return http.Get<IResponse<ILoveConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-config', { return http.Get<IResponse<ILoveConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-config', {
cacheFor: 0,
meta: { requestFrom: RequestFrom.Halo }, meta: { requestFrom: RequestFrom.Halo },
}) })
} }
+232 -254
View File
@@ -1,154 +1,143 @@
<script lang="ts" setup> <script lang="ts" setup>
/** import { computed, ref } from 'vue'
* 维护页(plugin-uni-halo 维护模式,2026-09-04 客户端接入) import { onLoad, onUnload } from '@dcloudio/uni-app'
* 数据源:getConfigs additive 顶层 maintenance 键(store.configs;仅 scheduled/active import { useAppConfigStore } from '@/store/appConfig'
* 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo import { checkUrl } from '@/utils/url'
* (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime) import { markdownConfig } from '@/config/markdown'
* / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。 import { usePluginAvailable } from '@/utils/plugin'
* 拦截入口(index/首页跳转,?from=plugin|maintenance):任一命中即展示维护页, import type { IPublicMaintenance } from '@/api/types/uni-halo'
* 未配置维护信息时使用默认标题/说明(logo 缺失时展示工具图标),并定时轮询检测
* 恢复(插件重新激活 / 维护结束)后自动返回首页。视觉参考 .design/维护页面.html(UnoCSS+scss)。
*/
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkUrl } from '@/utils/url'
import { markdownConfig } from '@/config/markdown'
import { usePluginAvailable } from '@/utils/plugin'
import type { IPublicMaintenance } from '@/api/types/uni-halo'
definePage({ definePage({
style: { style: {
// 去掉原生导航栏:维护页全屏沉浸式(顶部由 hero 的 --status-bar-height 内边距适配)
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '维护中 · UniHalo', navigationBarTitleText: '维护中...',
}, },
}) })
type ViewState = 'loading' | 'normal' | 'error' | 'maintenance' type ViewState = 'loading' | 'normal' | 'error' | 'maintenance'
type FromReason = 'plugin' | 'maintenance' type FromReason = 'plugin' | 'maintenance'
const uniHaloPluginId = 'plugin-uni-halo' const uniHaloPluginId = 'plugin-uni-halo'
/** 默认维护标题(拦截场景未配置维护信息时展示) */ /** 默认维护标题(拦截场景未配置维护信息时展示) */
const DEFAULT_MAINTENANCE_TITLE = '我们正在加油升级!' const DEFAULT_MAINTENANCE_TITLE = '我们正在加油升级!'
/** 恢复检测轮询间隔(ms):插件激活/维护结束探测 */ /** 恢复检测轮询间隔(ms):插件激活/维护结束探测 */
const RECOVERY_POLL_INTERVAL = 30 * 1000 const RECOVERY_POLL_INTERVAL = 30 * 1000
const store = useAppConfigStore() const store = useAppConfigStore()
const viewState = ref<ViewState>('loading') const viewState = ref<ViewState>('loading')
const maintenance = ref<IPublicMaintenance | null>(null) const maintenance = ref<IPublicMaintenance | null>(null)
/** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */ /** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */
const fromReason = ref<FromReason | null>(null) const fromReason = ref<FromReason | null>(null)
const nowMs = ref(Date.now()) const nowMs = ref(Date.now())
/** 刷新按钮旋转中 */ /** 刷新按钮旋转中 */
const spinning = ref(false) const spinning = ref(false)
/** Toast 文案(空 = 隐藏) */ /** Toast 文案(空 = 隐藏) */
const toast = ref('') const toast = ref('')
let timer: ReturnType<typeof setInterval> | null = null let timer : ReturnType<typeof setInterval> | null = null
let recoveryTimer: ReturnType<typeof setInterval> | null = null let recoveryTimer : ReturnType<typeof setInterval> | null = null
let toastTimer: ReturnType<typeof setTimeout> | null = null let toastTimer : ReturnType<typeof setTimeout> | null = null
let refreshing = false let refreshing = false
const isIntercepted = computed(() => !!fromReason.value) const isIntercepted = computed(() => !!fromReason.value)
const isScheduled = computed(() => maintenance.value?.status === 'scheduled') const isScheduled = computed(() => maintenance.value?.status === 'scheduled')
const title = computed(() => maintenance.value?.title || DEFAULT_MAINTENANCE_TITLE) const title = computed(() => maintenance.value?.title || DEFAULT_MAINTENANCE_TITLE)
/** 配置的富文本说明(未配置时展示设计稿默认文案) */ /** 配置的富文本说明(未配置时展示设计稿默认文案) */
const description = computed(() => maintenance.value?.description || '') const description = computed(() => maintenance.value?.description || '')
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */ /** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
const appLogo = computed(() => { const appLogo = computed(() => {
const appInfo = store.configs.appConfig?.appInfo const appInfo = store.configs.appConfig?.appInfo
const logo = appInfo && typeof appInfo === 'object' const logo = appInfo && typeof appInfo === 'object'
? (appInfo as { logo?: string }).logo ? (appInfo as { logo ?: string }).logo
: '' : ''
return logo ? checkUrl(logo) : '' return logo ? checkUrl(logo) : ''
}) })
/** 倒计时目标:scheduled → startTime / active → endTime */ /** 倒计时目标:scheduled → startTime / active → endTime */
const countdownTarget = computed(() => { const countdownTarget = computed(() => {
const info = maintenance.value const info = maintenance.value
if (!info) if (!info)
return '' return ''
return isScheduled.value ? info.startTime || '' : info.endTime || '' return isScheduled.value ? info.startTime || '' : info.endTime || ''
}) })
const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始' : '预计恢复倒计时')) const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始' : '预计恢复倒计时'))
/** 倒计时四格(天/时/分/秒),无目标或已归零为 null */ /** 倒计时四格(天/时/分/秒),无目标或已归零为 null */
const countdownParts = computed(() => { const countdownParts = computed(() => {
const target = countdownTarget.value const target = countdownTarget.value
if (!target) if (!target)
return null return null
const remaining = Math.max(0, Math.floor((new Date(target).getTime() - nowMs.value) / 1000)) const remaining = Math.max(0, Math.floor((new Date(target).getTime() - nowMs.value) / 1000))
if (remaining <= 0) if (remaining <= 0)
return null return null
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n : number) => String(n).padStart(2, '0')
return { return {
days: pad(Math.floor(remaining / 86400)), days: pad(Math.floor(remaining / 86400)),
hours: pad(Math.floor((remaining % 86400) / 3600)), hours: pad(Math.floor((remaining % 86400) / 3600)),
minutes: pad(Math.floor((remaining % 3600) / 60)), minutes: pad(Math.floor((remaining % 3600) / 60)),
seconds: pad(remaining % 60), seconds: pad(remaining % 60),
} }
}) })
/** 倒计时备注(本地时间):scheduled → 开始时刻 / active → 预计完成时刻 */ /** 倒计时备注(本地时间):scheduled → 开始时刻 / active → 预计完成时刻 */
const etaNote = computed(() => { const etaNote = computed(() => {
const target = countdownTarget.value const target = countdownTarget.value
if (!target) if (!target)
return '' return ''
const date = new Date(target) const date = new Date(target)
if (Number.isNaN(date.getTime())) if (Number.isNaN(date.getTime()))
return '' return ''
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n : number) => String(n).padStart(2, '0')
const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}` const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`
return isScheduled.value ? `将于 ${hm} 开始维护` : `预计 ${hm} 前完成 · 实际进度可能提前哦~` return isScheduled.value ? `将于 ${hm} 开始维护` : `预计 ${hm} 前完成 · 实际进度可能提前哦~`
}) })
function startTimer() { function startTimer() {
stopTimer() stopTimer()
timer = setInterval(tick, 1000) timer = setInterval(tick, 1000)
} }
function stopTimer() { function stopTimer() {
if (timer) { if (timer) {
clearInterval(timer) clearInterval(timer)
timer = null timer = null
} }
} }
function startRecoveryCheck() { function startRecoveryCheck() {
stopRecoveryCheck() stopRecoveryCheck()
recoveryTimer = setInterval(() => { recoveryTimer = setInterval(() => {
void silentCheck() void silentCheck()
}, RECOVERY_POLL_INTERVAL) }, RECOVERY_POLL_INTERVAL)
} }
function stopRecoveryCheck() { function stopRecoveryCheck() {
if (recoveryTimer) { if (recoveryTimer) {
clearInterval(recoveryTimer) clearInterval(recoveryTimer)
recoveryTimer = null recoveryTimer = null
} }
} }
/** 每秒刷新倒计时;目标时刻已到 → 静默重拉(服务端可能已切换 scheduled→active 或自动结束) */ /** 每秒刷新倒计时;目标时刻已到 → 静默重拉(服务端可能已切换 scheduled→active 或自动结束) */
function tick() { function tick() {
nowMs.value = Date.now() nowMs.value = Date.now()
const target = countdownTarget.value const target = countdownTarget.value
if (!target) if (!target) {
return return
}
const remaining = new Date(target).getTime() - nowMs.value const remaining = new Date(target).getTime() - nowMs.value
if (Number.isFinite(remaining) && remaining <= 0) if (Number.isFinite(remaining) && remaining <= 0)
void silentCheck() void silentCheck()
} }
/** /**
* 恢复检测(拦截场景):强制刷新配置;插件已激活且无维护信息 → 重定向入口页重新走拦截流程。 * 恢复检测(拦截场景):强制刷新配置;插件已激活且无维护信息 → 重定向入口页重新走拦截流程。
* 拉取失败(服务器停机)保持维护页不打扰。 * 拉取失败(服务器停机)保持维护页不打扰。
*/ */
async function silentCheck() { async function silentCheck() {
if (refreshing) if (refreshing) { return }
return
refreshing = true refreshing = true
try { try {
await store.bootstrap({ force: true }) await store.bootstrap({ force: true })
@@ -180,11 +169,10 @@ async function silentCheck() {
finally { finally {
refreshing = false refreshing = false
} }
} }
async function load(force = false) { async function load(force = false) {
if (refreshing) if (refreshing) { return }
return
refreshing = true refreshing = true
viewState.value = 'loading' viewState.value = 'loading'
maintenance.value = null maintenance.value = null
@@ -227,51 +215,52 @@ async function load(force = false) {
finally { finally {
refreshing = false refreshing = false
} }
} }
/** /**
* 检查通过 → 重定向入口页(index):由入口页重新执行「插件可用性 + 维护模式」拦截, * 检查通过 → 重定向入口页(index):由入口页重新执行「插件可用性 + 维护模式」拦截,
* 通过后再进入首页/文章详情等(保证任何时刻都从完整门禁通过)。 * 通过后再进入首页/文章详情等(保证任何时刻都从完整门禁通过)。
*/ */
function goIndex() { function goIndex() {
stopTimer() stopTimer()
stopRecoveryCheck() stopRecoveryCheck()
uni.reLaunch({ url: '/pages/index/index' }) uni.reLaunch({ url: '/pages/index/index' })
} }
/** Toast 提示(1.8s 自动消失) */ /** Toast 提示(1.8s 自动消失) */
function showToast(message: string) { function showToast(message : string) {
toast.value = message toast.value = message
if (toastTimer) if (toastTimer)
clearTimeout(toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toastTimer = setTimeout(() => {
toast.value = '' toast.value = ''
}, 1800) }, 1800)
} }
/** 刷新看看:转圈 → 静默重查(恢复则回入口页),仍在维护则提示 */ /** 刷新看看:转圈 → 静默重查(恢复则回入口页),仍在维护则提示 */
async function handleRefresh() { async function handleRefresh() {
if (spinning.value) if (spinning.value) { return }
return
spinning.value = true spinning.value = true
await silentCheck() await silentCheck()
spinning.value = false spinning.value = false
if (viewState.value === 'maintenance') if (viewState.value === 'maintenance') {
showToast('站点仍在维护中,请稍后再试 🙏') showToast('站点仍在维护中,请稍后再试')
} }
}
onLoad((options) => { onLoad((options) => {
const from = options?.from const from = options?.from
fromReason.value = from === 'plugin' || from === 'maintenance' ? from : null fromReason.value = from === 'plugin' || from === 'maintenance' ? from : null
void load() void load()
}) })
onUnload(() => { onUnload(() => {
stopTimer() stopTimer()
stopRecoveryCheck() stopRecoveryCheck()
if (toastTimer) if (toastTimer) {
clearTimeout(toastTimer) clearTimeout(toastTimer)
}) }
})
</script> </script>
<template> <template>
@@ -284,7 +273,8 @@ onUnload(() => {
</view> </view>
<!-- 服务正常(未维护,手动进入时) --> <!-- 服务正常(未维护,手动进入时) -->
<view v-else-if="viewState === 'normal'" class="flex flex-col items-center justify-center px-10 py-48 text-center"> <view v-else-if="viewState === 'normal'"
class="flex flex-col items-center justify-center px-10 py-48 text-center">
<text class="text-[64rpx]"> <text class="text-[64rpx]">
</text> </text>
@@ -302,7 +292,8 @@ onUnload(() => {
</view> </view>
<!-- 拉取失败(手动进入,通常为服务器停机中) --> <!-- 拉取失败(手动进入,通常为服务器停机中) -->
<view v-else-if="viewState === 'error'" class="flex flex-col items-center justify-center px-10 py-48 text-center"> <view v-else-if="viewState === 'error'"
class="flex flex-col items-center justify-center px-10 py-48 text-center">
<text class="text-[64rpx]"> <text class="text-[64rpx]">
</text> </text>
@@ -329,12 +320,7 @@ onUnload(() => {
<view class="medal"> <view class="medal">
<view class="medal-bg" /> <view class="medal-bg" />
<image <image v-if="appLogo" class="medal-img" :src="appLogo" mode="aspectFill" />
v-if="appLogo"
class="medal-img"
:src="appLogo"
mode="aspectFill"
/>
<wd-icon v-else class="medal-emoji" name="tool" size="52px" /> <wd-icon v-else class="medal-emoji" name="tool" size="52px" />
<view class="gear gear-1"> <view class="gear gear-1">
<wd-icon name="settings" size="24px" /> <wd-icon name="settings" size="24px" />
@@ -353,17 +339,9 @@ onUnload(() => {
<view class="doodle" /> <view class="doodle" />
<view class="hero-sub"> <view class="hero-sub">
<mp-html <mp-html v-if="description" :content="description" lazy-load :domain="markdownConfig.domain ?? ''"
v-if="description" scroll-table selectable :tag-style="markdownConfig.tagStyle"
:content="description" :container-style="markdownConfig.containStyle" copy-by-long-press />
lazy-load
:domain="markdownConfig.domain ?? ''"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
copy-by-long-press
/>
<template v-else> <template v-else>
<text>为了给你带来更好的体验站点正在维护升级中</text> <text>为了给你带来更好的体验站点正在维护升级中</text>
<text class="block"> <text class="block">
@@ -421,9 +399,6 @@ onUnload(() => {
<!-- 页脚 --> <!-- 页脚 -->
<view class="foot"> <view class="foot">
<view class="sticker st-acc rot-l">
<wd-icon name="face-smile-fill" size="22px" />
</view>
<view class="foot-text"> <view class="foot-text">
升级期间给你带来不便非常抱歉 升级期间给你带来不便非常抱歉
<text class="block"> <text class="block">
@@ -443,27 +418,26 @@ onUnload(() => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
/* 设计令牌(.design/维护页面.html 对齐,px → rpx ×2) */ $ink: #17181a;
$ink: #17181a; $ink2: #8a9099;
$ink2: #8a9099; $ink3: #c9cdd4;
$ink3: #c9cdd4; $sun: #ffd53d;
$sun: #ffd53d; $acc: #b8ec3f;
$acc: #b8ec3f; $acc-deep: #a7e93b;
$acc-deep: #a7e93b; $acc-soft: #ebfabf;
$acc-soft: #ebfabf; $acc-pale: #f4fbe0;
$acc-pale: #f4fbe0; $acc-ink: #63a002;
$acc-ink: #63a002; $hero: linear-gradient(175deg, #d9f77f 0%, #e8fbaf 52%, #f5fae8 100%);
$hero: linear-gradient(175deg, #d9f77f 0%, #e8fbaf 52%, #f5fae8 100%); $page: #f7f9f1;
$page: #f7f9f1; $glow: rgba(168, 232, 52, 0.5);
$glow: rgba(168, 232, 52, 0.5);
.maintenance-page { .maintenance-page {
min-height: 100vh; min-height: 100vh;
background: #e9edf1; background: #e9edf1;
} }
/* ========== 应用容器 ========== */ /* ========== 应用容器 ========== */
.app-container { .app-container {
max-width: 960rpx; max-width: 960rpx;
margin: 0 auto; margin: 0 auto;
min-height: 100vh; min-height: 100vh;
@@ -471,82 +445,84 @@ $glow: rgba(168, 232, 52, 0.5);
flex-direction: column; flex-direction: column;
background: $page; background: $page;
box-shadow: 0 0 60rpx rgba(50, 60, 25, 0.12); box-shadow: 0 0 60rpx rgba(50, 60, 25, 0.12);
} }
/* ========== Hero ========== */ /* ========== Hero ========== */
.hero { .hero {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
padding: calc(var(--status-bar-height) + 76rpx) 40rpx 64rpx; padding: calc(var(--status-bar-height) + 76rpx) 40rpx 64rpx;
background: $hero; background: $hero;
text-align: center; text-align: center;
} }
.blob { .blob {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(52rpx); filter: blur(52rpx);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
.b-white { .b-white {
background: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.4);
} }
.b-acc { .b-acc {
background: $acc; background: $acc;
opacity: 0.28; opacity: 0.28;
} }
.breathe { .breathe {
animation: breathe 5s ease-in-out infinite; animation: breathe 5s ease-in-out infinite;
} }
@keyframes breathe {
@keyframes breathe {
0%, 0%,
100% { 100% {
transform: scale(1); transform: scale(1);
opacity: 0.28; opacity: 0.28;
} }
50% { 50% {
transform: scale(1.18); transform: scale(1.18);
opacity: 0.4; opacity: 0.4;
} }
} }
.hero-blob-1 { .hero-blob-1 {
left: -60rpx; left: -60rpx;
top: 104rpx; top: 104rpx;
width: 260rpx; width: 260rpx;
height: 260rpx; height: 260rpx;
} }
.hero-blob-2 { .hero-blob-2 {
right: -48rpx; right: -48rpx;
top: 40rpx; top: 40rpx;
width: 200rpx; width: 200rpx;
height: 200rpx; height: 200rpx;
} }
.hero-blob-3 { .hero-blob-3 {
right: 72rpx; right: 72rpx;
bottom: -40rpx; bottom: -40rpx;
width: 160rpx; width: 160rpx;
height: 160rpx; height: 160rpx;
} }
/* 勋章 */ /* 勋章 */
.medal { .medal {
position: relative; position: relative;
width: 236rpx; width: 236rpx;
height: 236rpx; height: 236rpx;
margin: 44rpx auto 0; margin: 44rpx auto 0;
z-index: 2; z-index: 2;
} }
.medal-bg { .medal-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: 50%; border-radius: 50%;
@@ -555,67 +531,69 @@ $glow: rgba(168, 232, 52, 0.5);
0 0 0 12rpx #fff, 0 0 0 12rpx #fff,
0 28rpx 60rpx rgba(98, 124, 44, 0.22); 0 28rpx 60rpx rgba(98, 124, 44, 0.22);
animation: bob 3.2s ease-in-out infinite; animation: bob 3.2s ease-in-out infinite;
} }
.medal-img { .medal-img {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
z-index: 1; z-index: 1;
} }
.medal-emoji { .medal-emoji {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: $ink; color: $ink;
} }
@keyframes bob {
@keyframes bob {
0%, 0%,
100% { 100% {
transform: translateY(0) rotate(-2deg); transform: translateY(0) rotate(-2deg);
} }
50% { 50% {
transform: translateY(-14rpx) rotate(2deg); transform: translateY(-14rpx) rotate(2deg);
} }
} }
.gear { .gear {
position: absolute; position: absolute;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: spin 6s linear infinite; animation: spin 6s linear infinite;
} }
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.gear-1 { .gear-1 {
width: 68rpx; width: 68rpx;
height: 68rpx; height: 68rpx;
right: -28rpx; right: -28rpx;
top: -16rpx; top: -16rpx;
} }
.gear-2 { .gear-2 {
width: 48rpx; width: 48rpx;
height: 48rpx; height: 48rpx;
left: -32rpx; left: -32rpx;
bottom: 16rpx; bottom: 16rpx;
animation-duration: 4.5s; animation-duration: 4.5s;
animation-direction: reverse; animation-direction: reverse;
} }
.sticker { .sticker {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: #fff; background: #fff;
@@ -627,37 +605,37 @@ $glow: rgba(168, 232, 52, 0.5);
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.st-acc { .st-acc {
background: $acc-soft; background: $acc-soft;
border-color: $acc-soft; border-color: $acc-soft;
} }
.medal-st { .medal-st {
position: absolute; position: absolute;
left: 50%; left: 50%;
transform: translateX(-50%) rotate(-5deg); transform: translateX(-50%) rotate(-5deg);
bottom: -28rpx; bottom: -28rpx;
font-size: 22rpx; font-size: 22rpx;
padding: 12rpx 24rpx; padding: 12rpx 24rpx;
} }
.rot-l { .rot-l {
transform: rotate(-7deg); transform: rotate(-7deg);
} }
/* 标题与副标题 */ /* 标题与副标题 */
.hero-h1 { .hero-h1 {
margin-top: 64rpx; margin-top: 64rpx;
font-size: 54rpx; font-size: 54rpx;
line-height: 64rpx; line-height: 64rpx;
font-weight: 900; font-weight: 900;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.doodle { .doodle {
width: 224rpx; width: 224rpx;
height: 8rpx; height: 8rpx;
margin: 8rpx auto 0; margin: 8rpx auto 0;
@@ -665,9 +643,9 @@ $glow: rgba(168, 232, 52, 0.5);
background: linear-gradient(90deg, transparent, $acc-deep, transparent); background: linear-gradient(90deg, transparent, $acc-deep, transparent);
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.hero-sub { .hero-sub {
margin-top: 20rpx; margin-top: 20rpx;
font-size: 25rpx; font-size: 25rpx;
font-weight: 500; font-weight: 500;
@@ -675,55 +653,55 @@ $glow: rgba(168, 232, 52, 0.5);
position: relative; position: relative;
z-index: 2; z-index: 2;
line-height: 1.7; line-height: 1.7;
} }
/* ========== 内容区 ========== */ /* ========== 内容区 ========== */
.layer { .layer {
position: relative; position: relative;
flex: 1; flex: 1;
} }
.layer > :not(.bridge) { .layer> :not(.bridge) {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.bridge { .bridge {
position: absolute; position: absolute;
border-radius: 999rpx; border-radius: 999rpx;
filter: blur(44rpx); filter: blur(44rpx);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
.bridge-1 { .bridge-1 {
top: -52rpx; top: -52rpx;
left: 64rpx; left: 64rpx;
width: 192rpx; width: 192rpx;
height: 192rpx; height: 192rpx;
background: $acc-soft; background: $acc-soft;
opacity: 0.9; opacity: 0.9;
} }
.bridge-2 { .bridge-2 {
top: 300rpx; top: 300rpx;
right: -52rpx; right: -52rpx;
width: 168rpx; width: 168rpx;
height: 168rpx; height: 168rpx;
background: $sun; background: $sun;
opacity: 0.2; opacity: 0.2;
} }
.content { .content {
padding: 32rpx 32rpx 68rpx; padding: 32rpx 32rpx 68rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 28rpx; gap: 28rpx;
align-items: center; align-items: center;
} }
/* 倒计时卡 */ /* 倒计时卡 */
.eta-card { .eta-card {
border-radius: 36rpx; border-radius: 36rpx;
padding: 36rpx 32rpx; padding: 36rpx 32rpx;
text-align: center; text-align: center;
@@ -733,9 +711,9 @@ $glow: rgba(168, 232, 52, 0.5);
background: #fff; background: #fff;
border: 1rpx solid rgba(112, 138, 42, 0.08); border: 1rpx solid rgba(112, 138, 42, 0.08);
box-shadow: 0 4rpx 24rpx rgba(98, 124, 44, 0.08); box-shadow: 0 4rpx 24rpx rgba(98, 124, 44, 0.08);
} }
.eta-wm { .eta-wm {
position: absolute; position: absolute;
right: -12rpx; right: -12rpx;
bottom: -48rpx; bottom: -48rpx;
@@ -745,9 +723,9 @@ $glow: rgba(168, 232, 52, 0.5);
opacity: 0.1; opacity: 0.1;
line-height: 1; line-height: 1;
pointer-events: none; pointer-events: none;
} }
.eta-k { .eta-k {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -756,60 +734,60 @@ $glow: rgba(168, 232, 52, 0.5);
font-weight: 800; font-weight: 800;
letter-spacing: 2px; letter-spacing: 2px;
color: $ink2; color: $ink2;
} }
.eta-clock { .eta-clock {
margin-top: 22rpx; margin-top: 22rpx;
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10rpx; gap: 10rpx;
} }
.eta-cell { .eta-cell {
min-width: 118rpx; min-width: 118rpx;
padding: 18rpx 8rpx 16rpx; padding: 18rpx 8rpx 16rpx;
border-radius: 24rpx; border-radius: 24rpx;
background: $acc-pale; background: $acc-pale;
} }
.cell-num { .cell-num {
display: block; display: block;
font-size: 42rpx; font-size: 42rpx;
font-weight: 900; font-weight: 900;
line-height: 1; line-height: 1;
color: $ink; color: $ink;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.eta-cell.hot .cell-num { .eta-cell.hot .cell-num {
color: $acc-ink; color: $acc-ink;
} }
.cell-unit { .cell-unit {
display: block; display: block;
margin-top: 10rpx; margin-top: 10rpx;
font-size: 18rpx; font-size: 18rpx;
font-weight: 800; font-weight: 800;
color: $ink2; color: $ink2;
letter-spacing: 1px; letter-spacing: 1px;
} }
.eta-note { .eta-note {
margin-top: 24rpx; margin-top: 24rpx;
font-size: 21rpx; font-size: 21rpx;
color: $ink3; color: $ink3;
font-weight: 500; font-weight: 500;
} }
/* 操作区 */ /* 操作区 */
.cta-row { .cta-row {
display: flex; display: flex;
gap: 20rpx; gap: 20rpx;
width: 100%; width: 100%;
margin-top: 4rpx; margin-top: 4rpx;
} }
.btn-refresh { .btn-refresh {
flex: 1; flex: 1;
height: 100rpx; height: 100rpx;
border-radius: 999rpx; border-radius: 999rpx;
@@ -819,57 +797,57 @@ $glow: rgba(168, 232, 52, 0.5);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16rpx; gap: 16rpx;
} }
.ink-btn { .ink-btn {
color: #fff; color: #fff;
background: linear-gradient(150deg, #3a3e44 0%, #1d1f22 55%, #151619 100%); background: linear-gradient(150deg, #3a3e44 0%, #1d1f22 55%, #151619 100%);
box-shadow: box-shadow:
0 10rpx 28rpx -10rpx rgba(21, 23, 25, 0.4), 0 10rpx 28rpx -10rpx rgba(21, 23, 25, 0.4),
0 12rpx 36rpx -10rpx $glow, 0 12rpx 36rpx -10rpx $glow,
inset 0 2rpx 0 rgba(255, 255, 255, 0.16); inset 0 2rpx 0 rgba(255, 255, 255, 0.16);
} }
.refresh-ic { .refresh-ic {
display: inline-block; display: inline-block;
} }
.refresh-ic.spinning { .refresh-ic.spinning {
animation: spin 0.7s linear; animation: spin 0.7s linear;
} }
.press { .press {
transition: transform 0.12s ease; transition: transform 0.12s ease;
} }
.press:active { .press:active {
transform: scale(0.94); transform: scale(0.94);
} }
/* 页脚 */ /* 页脚 */
.foot { .foot {
margin-top: 12rpx; margin-top: 12rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.foot .sticker { .foot .sticker {
font-size: 44rpx; font-size: 44rpx;
padding: 14rpx 22rpx; padding: 14rpx 22rpx;
} }
.foot-text { .foot-text {
margin-top: 18rpx; margin-top: 18rpx;
font-size: 21rpx; font-size: 21rpx;
color: $ink3; color: $ink3;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
line-height: 1.7; line-height: 1.7;
} }
/* Toast */ /* Toast */
.toast { .toast {
position: fixed; position: fixed;
left: 50%; left: 50%;
bottom: calc(72rpx + env(safe-area-inset-bottom)); bottom: calc(72rpx + env(safe-area-inset-bottom));
@@ -886,10 +864,10 @@ $glow: rgba(168, 232, 52, 0.5);
inset 0 2rpx 0 rgba(255, 255, 255, 0.16); inset 0 2rpx 0 rgba(255, 255, 255, 0.16);
white-space: nowrap; white-space: nowrap;
z-index: 60; z-index: 60;
} }
:deep(img) { :deep(img) {
max-width: 100%; max-width: 100%;
border-radius: 8rpx; border-radius: 8rpx;
} }
</style> </style>
+70 -73
View File
@@ -1,73 +1,73 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app' import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
import { getPostList } from '@/api/halo' import { getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting' import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url' import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { t } from '@/locale' import { t } from '@/locale'
import type { IPost } from '@/api/types/halo' import type { IPost } from '@/api/types/halo'
definePage({ definePage({
style: { style: {
navigationBarTitleText: '首页', navigationBarTitleText: '首页',
enablePullDownRefresh: true, enablePullDownRefresh: true,
navigationStyle: 'custom', navigationStyle: 'custom',
}, },
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */ /** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
const { interceptOrContinue } = useMaintenanceIntercept() const { interceptOrContinue } = useMaintenanceIntercept()
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */ /** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
const intercepted = ref(!!appConfigStore.configs.maintenance) const intercepted = ref(!!appConfigStore.configs.maintenance)
const haloConfigs = computed(() => appConfigStore.configs) const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading') const loading = ref<'loading' | 'success' | 'error'>('loading')
const isLoadMore = ref(false) const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading')) const loadMoreText = ref(t('common.loading'))
const articleList = ref<IPost[]>([]) const articleList = ref<IPost[]>([])
const result = ref<{ hasNext: boolean }>({ hasNext: false }) const result = ref<{ hasNext : boolean }>({ hasNext: false })
const queryParams = ref({ const queryParams = ref({
size: 5, size: 5,
page: 1, page: 1,
sort: ['spec.pinned,desc', 'spec.publishTime,desc'], sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
}) })
/* ---------------- 计算属性 ---------------- */ /* ---------------- 计算属性 ---------------- */
const appInfo = computed(() => { const appInfo = computed(() => {
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name?: string, logo?: string } | undefined const appInfoData = haloConfigs.value.appConfig?.appInfo as { name ?: string, logo ?: string } | undefined
return { return {
name: appInfoData?.name || 'uni-halo', name: appInfoData?.name || 'uni-halo',
logo: checkImageUrl(appInfoData?.logo), logo: checkImageUrl(appInfoData?.logo),
} }
}) })
const bloggerInfo = computed(() => { const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, avatar ?: string } | undefined
return { return {
nickname: blogger?.nickname || '', nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar), avatar: checkAvatarUrl(blogger?.avatar),
} }
}) })
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled) const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const globalAppSettings = computed(() => settingStore.settings) const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 数据加载 ---------------- */ /* ---------------- 数据加载 ---------------- */
async function handleQuery() { async function handleQuery() {
handleGetArticleList() handleGetArticleList()
} }
/** 文章列表 */ /** 文章列表 */
async function handleGetArticleList() { async function handleGetArticleList() {
if (calcAuditModeEnabled.value) { if (calcAuditModeEnabled.value) {
// 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序) // 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序)
const auditPostNames = appConfigStore.auditData.spec?.posts || [] const auditPostNames = appConfigStore.auditData.spec?.posts || []
@@ -119,25 +119,25 @@ async function handleGetArticleList() {
uni.hideLoading() uni.hideLoading()
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
} }
} }
/* ---------------- 跳转 ---------------- */ /* ---------------- 跳转 ---------------- */
function handleToArticleDetail(article: IPost) { function handleToArticleDetail(article : IPost) {
uni.navigateTo({ uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`, url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right', animationType: 'slide-in-right',
}) })
} }
function handleToSearch() { function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/search/search' }) uni.navigateTo({ url: '/pages-blog/search/search' })
} }
function handleOnLogoToPage() { function handleOnLogoToPage() {
uni.switchTab({ url: '/pages/tabbar/about/about' }) uni.switchTab({ url: '/pages/tabbar/about/about' })
} }
function handleToTopPage(duration = 500) { function handleToTopPage(duration = 500) {
uni.pageScrollTo({ uni.pageScrollTo({
scrollTop: 0, scrollTop: 0,
duration, duration,
@@ -145,22 +145,22 @@ function handleToTopPage(duration = 500) {
console.error('回顶失败', err) console.error('回顶失败', err)
}, },
}) })
} }
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
// 拦截:维护模式开启 / 主插件未激活(任一命中)→ 跳转维护页(tab 切回时重复检查) // 维护检查
onShow(async () => { onShow(async () => {
intercepted.value = await interceptOrContinue() intercepted.value = await interceptOrContinue()
}) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
isLoadMore.value = false isLoadMore.value = false
queryParams.value.page = 1 queryParams.value.page = 1
handleQuery() handleQuery()
}) })
onReachBottom(() => { onReachBottom(() => {
if (calcAuditModeEnabled.value) { if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') }) uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return return
@@ -173,23 +173,24 @@ onReachBottom(() => {
else { else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') }) uni.showToast({ icon: 'none', title: t('common.noMoreData') })
} }
}) })
// 首次加载 // 首次加载
onMounted(() => { onMounted(() => {
if (intercepted.value) if (intercepted.value) {
return return
}
handleQuery() handleQuery()
}) })
</script> </script>
<template> <template>
<view class="min-h-screen w-screen flex flex-col bg-page"> <view class="min-h-screen w-screen flex flex-col bg-page">
<!-- 加载/错误占位(列表为空时展示,避免覆盖下拉刷新的旧内容) --> <!-- 加载/错误占位 -->
<view v-if="loading !== 'success' && articleList.length === 0"> <uh-data-loading v-if="loading !== 'success' && articleList.length === 0" :loading-status="loading"
<uh-data-loading :loading-status="loading" @refresh="handleQuery" /> @refresh="handleQuery" />
</view>
<!-- 内容区域 -->
<block v-else> <block v-else>
<!-- 轮播 --> <!-- 轮播 -->
<uh-home-banner /> <uh-home-banner />
@@ -207,10 +208,8 @@ onMounted(() => {
<uh-section-title class="mb-4 box-border px-3"> <uh-section-title class="mb-4 box-border px-3">
最新内容 最新内容
<template #right> <template #right>
<view <view class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400"
class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400" @click="handleToSearch()">
@click="handleToSearch()"
>
<wd-icon name="arrow-right" size="12px" /> <wd-icon name="arrow-right" size="12px" />
</view> </view>
</template> </template>
@@ -221,10 +220,8 @@ onMounted(() => {
</view> </view>
<block v-else> <block v-else>
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home"> <view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
<uh-article-card <uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
v-for="(article, index) in articleList" :key="index" from="home" :article="article" @on-click="handleToArticleDetail" />
@on-click="handleToArticleDetail"
/>
</view> </view>
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400"> <view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
{{ loadMoreText }} {{ loadMoreText }}