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:
@@ -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/images;SVG 图标 → static/my-icons/)
|
||||||
|
├── tabbar/ # 底部导航(config.ts 是唯一配置源)
|
||||||
|
├── utils/ # 通用工具(url/storage/merge/plugin/vote 等)
|
||||||
|
├── locale/ # i18n(zh-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'
|
||||||
|
|
||||||
|
/** GET:query 参数走 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-config,TTL 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-paging(easycom 已配置 `<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
|
||||||
|
|
||||||
+3
-2
@@ -124,7 +124,7 @@ export function getPostByTagName(tagName: string, params: IPostListReq) {
|
|||||||
export function getPostCommentList(params: ICommentListReq) {
|
export function getPostCommentList(params: ICommentListReq) {
|
||||||
return http.Get<IResponse<ICommentListRes>>('/apis/api.halo.run/v1alpha1/comments', {
|
return http.Get<IResponse<ICommentListRes>>('/apis/api.halo.run/v1alpha1/comments', {
|
||||||
params,
|
params,
|
||||||
cacheFor: 0,
|
cacheFor: 0,
|
||||||
meta: { requestFrom: RequestFrom.Halo },
|
meta: { requestFrom: RequestFrom.Halo },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ export function getPostCommentList(params: ICommentListReq) {
|
|||||||
export function getPostCommentReplyList(commentName: string, params: ICommentListReq) {
|
export function getPostCommentReplyList(commentName: string, params: ICommentListReq) {
|
||||||
return http.Get<IResponse<ICommentListRes>>(`/apis/api.halo.run/v1alpha1/comments/${commentName}/reply`, {
|
return http.Get<IResponse<ICommentListRes>>(`/apis/api.halo.run/v1alpha1/comments/${commentName}/reply`, {
|
||||||
params,
|
params,
|
||||||
cacheFor: 0,
|
cacheFor: 0,
|
||||||
meta: { requestFrom: RequestFrom.Halo },
|
meta: { requestFrom: RequestFrom.Halo },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+201
-204
@@ -1,239 +1,236 @@
|
|||||||
<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 || []
|
||||||
try {
|
try {
|
||||||
const res = await getPostList({ page: 1, size: 0, sort: ['spec.publishTime,desc'] })
|
const res = await getPostList({ page: 1, size: 0, sort: ['spec.publishTime,desc'] })
|
||||||
const filtered = res.data.items.filter(item => auditPostNames.includes(item.metadata.name))
|
const filtered = res.data.items.filter(item => auditPostNames.includes(item.metadata.name))
|
||||||
articleList.value = filtered.map((item) => {
|
articleList.value = filtered.map((item) => {
|
||||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
loading.value = 'success'
|
loading.value = 'success'
|
||||||
loadMoreText.value = t('common.noMore')
|
loadMoreText.value = t('common.noMore')
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('获取审核文章失败', err)
|
console.error('获取审核文章失败', err)
|
||||||
loading.value = 'error'
|
loading.value = 'error'
|
||||||
loadMoreText.value = t('common.loadFailed')
|
loadMoreText.value = t('common.loadFailed')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.stopPullDownRefresh()
|
uni.stopPullDownRefresh()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoadMore.value) {
|
if (!isLoadMore.value) {
|
||||||
loading.value = 'loading'
|
loading.value = 'loading'
|
||||||
}
|
}
|
||||||
loadMoreText.value = t('common.loading')
|
loadMoreText.value = t('common.loading')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getPostList({ ...toRaw(queryParams.value) })
|
const res = await getPostList({ ...toRaw(queryParams.value) })
|
||||||
result.value.hasNext = res.data.hasNext
|
result.value.hasNext = res.data.hasNext
|
||||||
articleList.value = (isLoadMore.value
|
articleList.value = (isLoadMore.value
|
||||||
? articleList.value.concat(res.data.items)
|
? articleList.value.concat(res.data.items)
|
||||||
: res.data.items).map((item) => {
|
: res.data.items).map((item) => {
|
||||||
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
item.owner.avatar = checkAvatarUrl(item.owner.avatar)
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
loading.value = 'success'
|
loading.value = 'success'
|
||||||
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
loading.value = 'error'
|
loading.value = 'error'
|
||||||
loadMoreText.value = t('common.loadFailed')
|
loadMoreText.value = t('common.loadFailed')
|
||||||
console.error('获取文章失败', err)
|
console.error('获取文章失败', err)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
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,
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
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
|
||||||
}
|
}
|
||||||
if (result.value.hasNext) {
|
if (result.value.hasNext) {
|
||||||
queryParams.value.page += 1
|
queryParams.value.page += 1
|
||||||
isLoadMore.value = true
|
isLoadMore.value = true
|
||||||
handleGetArticleList()
|
handleGetArticleList()
|
||||||
}
|
}
|
||||||
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 />
|
||||||
|
|
||||||
<!-- 公告 -->
|
<!-- 公告 -->
|
||||||
<uh-home-notify />
|
<uh-home-notify />
|
||||||
|
|
||||||
<!-- 快捷导航 -->
|
<!-- 快捷导航 -->
|
||||||
<uh-home-quick-nav />
|
<uh-home-quick-nav />
|
||||||
|
|
||||||
<!-- 精选分类 -->
|
<!-- 精选分类 -->
|
||||||
<uh-home-category />
|
<uh-home-category />
|
||||||
|
|
||||||
<!-- 最新文章 -->
|
<!-- 最新文章 -->
|
||||||
<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" />
|
||||||
>
|
</view>
|
||||||
<wd-icon name="arrow-right" size="12px" />
|
</template>
|
||||||
</view>
|
</uh-section-title>
|
||||||
</template>
|
|
||||||
</uh-section-title>
|
|
||||||
|
|
||||||
<view v-if="articleList.length === 0" class="article-empty py-10">
|
<view v-if="articleList.length === 0" class="article-empty py-10">
|
||||||
<wd-empty description="博主还没有发表任何内容~" />
|
<wd-empty description="博主还没有发表任何内容~" />
|
||||||
</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 class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
|
||||||
</view>
|
{{ loadMoreText }}
|
||||||
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
|
</view>
|
||||||
{{ loadMoreText }}
|
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
|
||||||
</view>
|
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
||||||
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
|
</view>
|
||||||
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
|
</block>
|
||||||
</view>
|
</block>
|
||||||
</block>
|
</view>
|
||||||
</block>
|
<uh-notify-dialog />
|
||||||
</view>
|
|
||||||
<uh-notify-dialog />
|
|
||||||
</template>
|
</template>
|
||||||
Reference in New Issue
Block a user