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
|
||||
|
||||
@@ -294,6 +294,7 @@ export function postTrackersCounter(data: ITrackerCounterReq) {
|
||||
*/
|
||||
export function checkPluginAvailable(name: string) {
|
||||
return http.Get<IResponse<IPluginAvailable>>(`/apis/api.plugin.halo.run/v1alpha1/plugins/${name}/available`, {
|
||||
cacheFor: 0,
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,9 +56,12 @@ const COMMENT_WIDGET_CAPTCHA_COOKIES = 'comment-widget-captcha'
|
||||
|
||||
/**
|
||||
* 获取应用配置
|
||||
* 注意:cacheFor: 0 绕过 alova 内存缓存——维护模式/配置变更后需即时生效
|
||||
* (维护页刷新、入口/首页拦截均依赖本接口的最新状态)
|
||||
*/
|
||||
export function getAppConfigs() {
|
||||
return http.Get<IResponse<IAppConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getConfigs', {
|
||||
cacheFor: 0,
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
@@ -68,6 +71,7 @@ export function getAppConfigs() {
|
||||
*/
|
||||
export function getAuditData() {
|
||||
return http.Get<IResponse<IAuditDataResult>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/audit-data', {
|
||||
cacheFor: 0,
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
@@ -196,6 +200,7 @@ export function getNoticeDetail(name: string) {
|
||||
*/
|
||||
export function getLoveConfig() {
|
||||
return http.Get<IResponse<ILoveConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-config', {
|
||||
cacheFor: 0,
|
||||
meta: { requestFrom: RequestFrom.Halo },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,154 +1,143 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 维护页(plugin-uni-halo 维护模式,2026-09-04 客户端接入)
|
||||
* 数据源:getConfigs additive 顶层 maintenance 键(store.configs;仅 scheduled/active
|
||||
* 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo
|
||||
* (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime)
|
||||
* / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。
|
||||
* 拦截入口(index/首页跳转,?from=plugin|maintenance):任一命中即展示维护页,
|
||||
* 未配置维护信息时使用默认标题/说明(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'
|
||||
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: {
|
||||
// 去掉原生导航栏:维护页全屏沉浸式(顶部由 hero 的 --status-bar-height 内边距适配)
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '维护中 · UniHalo',
|
||||
navigationBarTitleText: '维护中...',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
type ViewState = 'loading' | 'normal' | 'error' | 'maintenance'
|
||||
type FromReason = 'plugin' | 'maintenance'
|
||||
type ViewState = 'loading' | 'normal' | 'error' | 'maintenance'
|
||||
type FromReason = 'plugin' | 'maintenance'
|
||||
|
||||
const uniHaloPluginId = 'plugin-uni-halo'
|
||||
/** 默认维护标题(拦截场景未配置维护信息时展示) */
|
||||
const DEFAULT_MAINTENANCE_TITLE = '我们正在加油升级!'
|
||||
/** 恢复检测轮询间隔(ms):插件激活/维护结束探测 */
|
||||
const RECOVERY_POLL_INTERVAL = 30 * 1000
|
||||
const uniHaloPluginId = 'plugin-uni-halo'
|
||||
/** 默认维护标题(拦截场景未配置维护信息时展示) */
|
||||
const DEFAULT_MAINTENANCE_TITLE = '我们正在加油升级!'
|
||||
/** 恢复检测轮询间隔(ms):插件激活/维护结束探测 */
|
||||
const RECOVERY_POLL_INTERVAL = 30 * 1000
|
||||
|
||||
const store = useAppConfigStore()
|
||||
const store = useAppConfigStore()
|
||||
|
||||
const viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
/** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */
|
||||
const fromReason = ref<FromReason | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
/** 刷新按钮旋转中 */
|
||||
const spinning = ref(false)
|
||||
/** Toast 文案(空 = 隐藏) */
|
||||
const toast = ref('')
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let recoveryTimer: ReturnType<typeof setInterval> | null = null
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let refreshing = false
|
||||
const viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
/** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */
|
||||
const fromReason = ref<FromReason | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
/** 刷新按钮旋转中 */
|
||||
const spinning = ref(false)
|
||||
/** Toast 文案(空 = 隐藏) */
|
||||
const toast = ref('')
|
||||
let timer : ReturnType<typeof setInterval> | null = null
|
||||
let recoveryTimer : ReturnType<typeof setInterval> | null = null
|
||||
let toastTimer : ReturnType<typeof setTimeout> | null = null
|
||||
let refreshing = false
|
||||
|
||||
const isIntercepted = computed(() => !!fromReason.value)
|
||||
const isScheduled = computed(() => maintenance.value?.status === 'scheduled')
|
||||
const title = computed(() => maintenance.value?.title || DEFAULT_MAINTENANCE_TITLE)
|
||||
/** 配置的富文本说明(未配置时展示设计稿默认文案) */
|
||||
const description = computed(() => maintenance.value?.description || '')
|
||||
const isIntercepted = computed(() => !!fromReason.value)
|
||||
const isScheduled = computed(() => maintenance.value?.status === 'scheduled')
|
||||
const title = computed(() => maintenance.value?.title || DEFAULT_MAINTENANCE_TITLE)
|
||||
/** 配置的富文本说明(未配置时展示设计稿默认文案) */
|
||||
const description = computed(() => maintenance.value?.description || '')
|
||||
|
||||
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
|
||||
const appLogo = computed(() => {
|
||||
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
|
||||
const appLogo = computed(() => {
|
||||
const appInfo = store.configs.appConfig?.appInfo
|
||||
const logo = appInfo && typeof appInfo === 'object'
|
||||
? (appInfo as { logo?: string }).logo
|
||||
? (appInfo as { logo ?: string }).logo
|
||||
: ''
|
||||
return logo ? checkUrl(logo) : ''
|
||||
})
|
||||
})
|
||||
|
||||
/** 倒计时目标:scheduled → startTime / active → endTime */
|
||||
const countdownTarget = computed(() => {
|
||||
/** 倒计时目标:scheduled → startTime / active → endTime */
|
||||
const countdownTarget = computed(() => {
|
||||
const info = maintenance.value
|
||||
if (!info)
|
||||
return ''
|
||||
return isScheduled.value ? info.startTime || '' : info.endTime || ''
|
||||
})
|
||||
})
|
||||
|
||||
const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始' : '预计恢复倒计时'))
|
||||
const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始' : '预计恢复倒计时'))
|
||||
|
||||
/** 倒计时四格(天/时/分/秒),无目标或已归零为 null */
|
||||
const countdownParts = computed(() => {
|
||||
/** 倒计时四格(天/时/分/秒),无目标或已归零为 null */
|
||||
const countdownParts = computed(() => {
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
return null
|
||||
const remaining = Math.max(0, Math.floor((new Date(target).getTime() - nowMs.value) / 1000))
|
||||
if (remaining <= 0)
|
||||
return null
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const pad = (n : number) => String(n).padStart(2, '0')
|
||||
return {
|
||||
days: pad(Math.floor(remaining / 86400)),
|
||||
hours: pad(Math.floor((remaining % 86400) / 3600)),
|
||||
minutes: pad(Math.floor((remaining % 3600) / 60)),
|
||||
seconds: pad(remaining % 60),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 倒计时备注(本地时间):scheduled → 开始时刻 / active → 预计完成时刻 */
|
||||
const etaNote = computed(() => {
|
||||
/** 倒计时备注(本地时间):scheduled → 开始时刻 / active → 预计完成时刻 */
|
||||
const etaNote = computed(() => {
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
return ''
|
||||
const date = new Date(target)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
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())}`
|
||||
return isScheduled.value ? `将于 ${hm} 开始维护` : `预计 ${hm} 前完成 · 实际进度可能提前哦~`
|
||||
})
|
||||
})
|
||||
|
||||
function startTimer() {
|
||||
function startTimer() {
|
||||
stopTimer()
|
||||
timer = setInterval(tick, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startRecoveryCheck() {
|
||||
function startRecoveryCheck() {
|
||||
stopRecoveryCheck()
|
||||
recoveryTimer = setInterval(() => {
|
||||
void silentCheck()
|
||||
}, RECOVERY_POLL_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecoveryCheck() {
|
||||
function stopRecoveryCheck() {
|
||||
if (recoveryTimer) {
|
||||
clearInterval(recoveryTimer)
|
||||
recoveryTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每秒刷新倒计时;目标时刻已到 → 静默重拉(服务端可能已切换 scheduled→active 或自动结束) */
|
||||
function tick() {
|
||||
/** 每秒刷新倒计时;目标时刻已到 → 静默重拉(服务端可能已切换 scheduled→active 或自动结束) */
|
||||
function tick() {
|
||||
nowMs.value = Date.now()
|
||||
const target = countdownTarget.value
|
||||
if (!target)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
if (Number.isFinite(remaining) && remaining <= 0)
|
||||
void silentCheck()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 恢复检测(拦截场景):强制刷新配置;插件已激活且无维护信息 → 重定向入口页重新走拦截流程。
|
||||
* 拉取失败(服务器停机)保持维护页不打扰。
|
||||
*/
|
||||
async function silentCheck() {
|
||||
if (refreshing)
|
||||
return
|
||||
async function silentCheck() {
|
||||
if (refreshing) { return }
|
||||
refreshing = true
|
||||
try {
|
||||
await store.bootstrap({ force: true })
|
||||
@@ -180,11 +169,10 @@ async function silentCheck() {
|
||||
finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load(force = false) {
|
||||
if (refreshing)
|
||||
return
|
||||
async function load(force = false) {
|
||||
if (refreshing) { return }
|
||||
refreshing = true
|
||||
viewState.value = 'loading'
|
||||
maintenance.value = null
|
||||
@@ -227,51 +215,52 @@ async function load(force = false) {
|
||||
finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 检查通过 → 重定向入口页(index):由入口页重新执行「插件可用性 + 维护模式」拦截,
|
||||
* 通过后再进入首页/文章详情等(保证任何时刻都从完整门禁通过)。
|
||||
*/
|
||||
function goIndex() {
|
||||
function goIndex() {
|
||||
stopTimer()
|
||||
stopRecoveryCheck()
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Toast 提示(1.8s 自动消失) */
|
||||
function showToast(message: string) {
|
||||
/** Toast 提示(1.8s 自动消失) */
|
||||
function showToast(message : string) {
|
||||
toast.value = message
|
||||
if (toastTimer)
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value = ''
|
||||
}, 1800)
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新看看:转圈 → 静默重查(恢复则回入口页),仍在维护则提示 */
|
||||
async function handleRefresh() {
|
||||
if (spinning.value)
|
||||
return
|
||||
/** 刷新看看:转圈 → 静默重查(恢复则回入口页),仍在维护则提示 */
|
||||
async function handleRefresh() {
|
||||
if (spinning.value) { return }
|
||||
spinning.value = true
|
||||
await silentCheck()
|
||||
spinning.value = false
|
||||
if (viewState.value === 'maintenance')
|
||||
showToast('站点仍在维护中,请稍后再试 🙏')
|
||||
}
|
||||
if (viewState.value === 'maintenance') {
|
||||
showToast('站点仍在维护中,请稍后再试')
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
onLoad((options) => {
|
||||
const from = options?.from
|
||||
fromReason.value = from === 'plugin' || from === 'maintenance' ? from : null
|
||||
void load()
|
||||
})
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
onUnload(() => {
|
||||
stopTimer()
|
||||
stopRecoveryCheck()
|
||||
if (toastTimer)
|
||||
if (toastTimer) {
|
||||
clearTimeout(toastTimer)
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -284,7 +273,8 @@ onUnload(() => {
|
||||
</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>
|
||||
@@ -302,7 +292,8 @@ onUnload(() => {
|
||||
</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>
|
||||
@@ -329,12 +320,7 @@ onUnload(() => {
|
||||
|
||||
<view class="medal">
|
||||
<view class="medal-bg" />
|
||||
<image
|
||||
v-if="appLogo"
|
||||
class="medal-img"
|
||||
:src="appLogo"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image v-if="appLogo" class="medal-img" :src="appLogo" mode="aspectFill" />
|
||||
<wd-icon v-else class="medal-emoji" name="tool" size="52px" />
|
||||
<view class="gear gear-1">
|
||||
<wd-icon name="settings" size="24px" />
|
||||
@@ -353,17 +339,9 @@ onUnload(() => {
|
||||
<view class="doodle" />
|
||||
|
||||
<view class="hero-sub">
|
||||
<mp-html
|
||||
v-if="description"
|
||||
:content="description"
|
||||
lazy-load
|
||||
:domain="markdownConfig.domain ?? ''"
|
||||
scroll-table
|
||||
selectable
|
||||
:tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle"
|
||||
copy-by-long-press
|
||||
/>
|
||||
<mp-html v-if="description" :content="description" lazy-load :domain="markdownConfig.domain ?? ''"
|
||||
scroll-table selectable :tag-style="markdownConfig.tagStyle"
|
||||
:container-style="markdownConfig.containStyle" copy-by-long-press />
|
||||
<template v-else>
|
||||
<text>为了给你带来更好的体验,站点正在维护升级中</text>
|
||||
<text class="block">
|
||||
@@ -421,9 +399,6 @@ onUnload(() => {
|
||||
|
||||
<!-- 页脚 -->
|
||||
<view class="foot">
|
||||
<view class="sticker st-acc rot-l">
|
||||
<wd-icon name="face-smile-fill" size="22px" />
|
||||
</view>
|
||||
<view class="foot-text">
|
||||
升级期间给你带来不便,非常抱歉
|
||||
<text class="block">
|
||||
@@ -443,27 +418,26 @@ onUnload(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 设计令牌(.design/维护页面.html 对齐,px → rpx ×2) */
|
||||
$ink: #17181a;
|
||||
$ink2: #8a9099;
|
||||
$ink3: #c9cdd4;
|
||||
$sun: #ffd53d;
|
||||
$acc: #b8ec3f;
|
||||
$acc-deep: #a7e93b;
|
||||
$acc-soft: #ebfabf;
|
||||
$acc-pale: #f4fbe0;
|
||||
$acc-ink: #63a002;
|
||||
$hero: linear-gradient(175deg, #d9f77f 0%, #e8fbaf 52%, #f5fae8 100%);
|
||||
$page: #f7f9f1;
|
||||
$glow: rgba(168, 232, 52, 0.5);
|
||||
$ink: #17181a;
|
||||
$ink2: #8a9099;
|
||||
$ink3: #c9cdd4;
|
||||
$sun: #ffd53d;
|
||||
$acc: #b8ec3f;
|
||||
$acc-deep: #a7e93b;
|
||||
$acc-soft: #ebfabf;
|
||||
$acc-pale: #f4fbe0;
|
||||
$acc-ink: #63a002;
|
||||
$hero: linear-gradient(175deg, #d9f77f 0%, #e8fbaf 52%, #f5fae8 100%);
|
||||
$page: #f7f9f1;
|
||||
$glow: rgba(168, 232, 52, 0.5);
|
||||
|
||||
.maintenance-page {
|
||||
.maintenance-page {
|
||||
min-height: 100vh;
|
||||
background: #e9edf1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 应用容器 ========== */
|
||||
.app-container {
|
||||
/* ========== 应用容器 ========== */
|
||||
.app-container {
|
||||
max-width: 960rpx;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
@@ -471,82 +445,84 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
flex-direction: column;
|
||||
background: $page;
|
||||
box-shadow: 0 0 60rpx rgba(50, 60, 25, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Hero ========== */
|
||||
.hero {
|
||||
/* ========== Hero ========== */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height) + 76rpx) 40rpx 64rpx;
|
||||
background: $hero;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.blob {
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(52rpx);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.b-white {
|
||||
.b-white {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.b-acc {
|
||||
.b-acc {
|
||||
background: $acc;
|
||||
opacity: 0.28;
|
||||
}
|
||||
}
|
||||
|
||||
.breathe {
|
||||
.breathe {
|
||||
animation: breathe 5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.18);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-blob-1 {
|
||||
.hero-blob-1 {
|
||||
left: -60rpx;
|
||||
top: 104rpx;
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-blob-2 {
|
||||
.hero-blob-2 {
|
||||
right: -48rpx;
|
||||
top: 40rpx;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-blob-3 {
|
||||
.hero-blob-3 {
|
||||
right: 72rpx;
|
||||
bottom: -40rpx;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 勋章 */
|
||||
.medal {
|
||||
/* 勋章 */
|
||||
.medal {
|
||||
position: relative;
|
||||
width: 236rpx;
|
||||
height: 236rpx;
|
||||
margin: 44rpx auto 0;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.medal-bg {
|
||||
.medal-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
@@ -555,67 +531,69 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
0 0 0 12rpx #fff,
|
||||
0 28rpx 60rpx rgba(98, 124, 44, 0.22);
|
||||
animation: bob 3.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.medal-img {
|
||||
.medal-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.medal-emoji {
|
||||
.medal-emoji {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
|
||||
@keyframes bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-2deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-14rpx) rotate(2deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gear {
|
||||
.gear {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: spin 6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gear-1 {
|
||||
.gear-1 {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
right: -28rpx;
|
||||
top: -16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.gear-2 {
|
||||
.gear-2 {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
left: -32rpx;
|
||||
bottom: 16rpx;
|
||||
animation-duration: 4.5s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.sticker {
|
||||
.sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
@@ -627,37 +605,37 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.st-acc {
|
||||
.st-acc {
|
||||
background: $acc-soft;
|
||||
border-color: $acc-soft;
|
||||
}
|
||||
}
|
||||
|
||||
.medal-st {
|
||||
.medal-st {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(-5deg);
|
||||
bottom: -28rpx;
|
||||
font-size: 22rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.rot-l {
|
||||
.rot-l {
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 标题与副标题 */
|
||||
.hero-h1 {
|
||||
/* 标题与副标题 */
|
||||
.hero-h1 {
|
||||
margin-top: 64rpx;
|
||||
font-size: 54rpx;
|
||||
line-height: 64rpx;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.doodle {
|
||||
.doodle {
|
||||
width: 224rpx;
|
||||
height: 8rpx;
|
||||
margin: 8rpx auto 0;
|
||||
@@ -665,9 +643,9 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
background: linear-gradient(90deg, transparent, $acc-deep, transparent);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
.hero-sub {
|
||||
margin-top: 20rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 500;
|
||||
@@ -675,55 +653,55 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 内容区 ========== */
|
||||
.layer {
|
||||
/* ========== 内容区 ========== */
|
||||
.layer {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.layer > :not(.bridge) {
|
||||
.layer> :not(.bridge) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bridge {
|
||||
.bridge {
|
||||
position: absolute;
|
||||
border-radius: 999rpx;
|
||||
filter: blur(44rpx);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bridge-1 {
|
||||
.bridge-1 {
|
||||
top: -52rpx;
|
||||
left: 64rpx;
|
||||
width: 192rpx;
|
||||
height: 192rpx;
|
||||
background: $acc-soft;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.bridge-2 {
|
||||
.bridge-2 {
|
||||
top: 300rpx;
|
||||
right: -52rpx;
|
||||
width: 168rpx;
|
||||
height: 168rpx;
|
||||
background: $sun;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
.content {
|
||||
padding: 32rpx 32rpx 68rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 倒计时卡 */
|
||||
.eta-card {
|
||||
/* 倒计时卡 */
|
||||
.eta-card {
|
||||
border-radius: 36rpx;
|
||||
padding: 36rpx 32rpx;
|
||||
text-align: center;
|
||||
@@ -733,9 +711,9 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
background: #fff;
|
||||
border: 1rpx solid rgba(112, 138, 42, 0.08);
|
||||
box-shadow: 0 4rpx 24rpx rgba(98, 124, 44, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.eta-wm {
|
||||
.eta-wm {
|
||||
position: absolute;
|
||||
right: -12rpx;
|
||||
bottom: -48rpx;
|
||||
@@ -745,9 +723,9 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
opacity: 0.1;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.eta-k {
|
||||
.eta-k {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -756,60 +734,60 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
color: $ink2;
|
||||
}
|
||||
}
|
||||
|
||||
.eta-clock {
|
||||
.eta-clock {
|
||||
margin-top: 22rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.eta-cell {
|
||||
.eta-cell {
|
||||
min-width: 118rpx;
|
||||
padding: 18rpx 8rpx 16rpx;
|
||||
border-radius: 24rpx;
|
||||
background: $acc-pale;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-num {
|
||||
.cell-num {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: $ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.eta-cell.hot .cell-num {
|
||||
.eta-cell.hot .cell-num {
|
||||
color: $acc-ink;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-unit {
|
||||
.cell-unit {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 800;
|
||||
color: $ink2;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.eta-note {
|
||||
.eta-note {
|
||||
margin-top: 24rpx;
|
||||
font-size: 21rpx;
|
||||
color: $ink3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作区 */
|
||||
.cta-row {
|
||||
/* 操作区 */
|
||||
.cta-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
width: 100%;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
.btn-refresh {
|
||||
flex: 1;
|
||||
height: 100rpx;
|
||||
border-radius: 999rpx;
|
||||
@@ -819,57 +797,57 @@ $glow: rgba(168, 232, 52, 0.5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.ink-btn {
|
||||
.ink-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(150deg, #3a3e44 0%, #1d1f22 55%, #151619 100%);
|
||||
box-shadow:
|
||||
0 10rpx 28rpx -10rpx rgba(21, 23, 25, 0.4),
|
||||
0 12rpx 36rpx -10rpx $glow,
|
||||
inset 0 2rpx 0 rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-ic {
|
||||
.refresh-ic {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-ic.spinning {
|
||||
.refresh-ic.spinning {
|
||||
animation: spin 0.7s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.press {
|
||||
.press {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.press:active {
|
||||
.press:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.foot {
|
||||
/* 页脚 */
|
||||
.foot {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.foot .sticker {
|
||||
.foot .sticker {
|
||||
font-size: 44rpx;
|
||||
padding: 14rpx 22rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.foot-text {
|
||||
.foot-text {
|
||||
margin-top: 18rpx;
|
||||
font-size: 21rpx;
|
||||
color: $ink3;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
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);
|
||||
white-space: nowrap;
|
||||
z-index: 60;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { getPostList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useSettingStore } from '@/store/setting'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import type { IPost } from '@/api/types/halo'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { getPostList } from '@/api/halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useSettingStore } from '@/store/setting'
|
||||
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
|
||||
import { t } from '@/locale'
|
||||
import type { IPost } from '@/api/types/halo'
|
||||
|
||||
definePage({
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '首页',
|
||||
enablePullDownRefresh: true,
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const settingStore = useSettingStore()
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
|
||||
const { interceptOrContinue } = useMaintenanceIntercept()
|
||||
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
|
||||
const intercepted = ref(!!appConfigStore.configs.maintenance)
|
||||
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
|
||||
const { interceptOrContinue } = useMaintenanceIntercept()
|
||||
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
|
||||
const intercepted = ref(!!appConfigStore.configs.maintenance)
|
||||
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
const articleList = ref<IPost[]>([])
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const loading = ref<'loading' | 'success' | 'error'>('loading')
|
||||
const isLoadMore = ref(false)
|
||||
const loadMoreText = ref(t('common.loading'))
|
||||
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,
|
||||
page: 1,
|
||||
sort: ['spec.pinned,desc', 'spec.publishTime,desc'],
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const appInfo = computed(() => {
|
||||
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name?: string, logo?: string } | undefined
|
||||
/* ---------------- 计算属性 ---------------- */
|
||||
const appInfo = computed(() => {
|
||||
const appInfoData = haloConfigs.value.appConfig?.appInfo as { name ?: string, logo ?: string } | undefined
|
||||
return {
|
||||
name: appInfoData?.name || 'uni-halo',
|
||||
logo: checkImageUrl(appInfoData?.logo),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
|
||||
const bloggerInfo = computed(() => {
|
||||
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, avatar ?: string } | undefined
|
||||
return {
|
||||
nickname: blogger?.nickname || '',
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** 文章列表 */
|
||||
async function handleGetArticleList() {
|
||||
/** 文章列表 */
|
||||
async function handleGetArticleList() {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
// 审核模式:真实文章按 audit-data posts 过滤(数组顺序即展示顺序)
|
||||
const auditPostNames = appConfigStore.auditData.spec?.posts || []
|
||||
@@ -119,25 +119,25 @@ async function handleGetArticleList() {
|
||||
uni.hideLoading()
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 跳转 ---------------- */
|
||||
function handleToArticleDetail(article: IPost) {
|
||||
/* ---------------- 跳转 ---------------- */
|
||||
function handleToArticleDetail(article : IPost) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
|
||||
animationType: 'slide-in-right',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleToSearch() {
|
||||
function handleToSearch() {
|
||||
uni.navigateTo({ url: '/pages-blog/search/search' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnLogoToPage() {
|
||||
function handleOnLogoToPage() {
|
||||
uni.switchTab({ url: '/pages/tabbar/about/about' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
@@ -145,22 +145,22 @@ function handleToTopPage(duration = 500) {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
// 拦截:维护模式开启 / 主插件未激活(任一命中)→ 跳转维护页(tab 切回时重复检查)
|
||||
onShow(async () => {
|
||||
// 维护检查
|
||||
onShow(async () => {
|
||||
intercepted.value = await interceptOrContinue()
|
||||
})
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
onPullDownRefresh(() => {
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
handleQuery()
|
||||
})
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
onReachBottom(() => {
|
||||
if (calcAuditModeEnabled.value) {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
return
|
||||
@@ -173,23 +173,24 @@ onReachBottom(() => {
|
||||
else {
|
||||
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 首次加载
|
||||
onMounted(() => {
|
||||
if (intercepted.value)
|
||||
// 首次加载
|
||||
onMounted(() => {
|
||||
if (intercepted.value) {
|
||||
return
|
||||
}
|
||||
handleQuery()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen w-screen flex flex-col bg-page">
|
||||
<!-- 加载/错误占位(列表为空时展示,避免覆盖下拉刷新的旧内容) -->
|
||||
<view v-if="loading !== 'success' && articleList.length === 0">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleQuery" />
|
||||
</view>
|
||||
<!-- 加载/错误占位 -->
|
||||
<uh-data-loading v-if="loading !== 'success' && articleList.length === 0" :loading-status="loading"
|
||||
@refresh="handleQuery" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<block v-else>
|
||||
<!-- 轮播 -->
|
||||
<uh-home-banner />
|
||||
@@ -207,10 +208,8 @@ onMounted(() => {
|
||||
<uh-section-title class="mb-4 box-border px-3">
|
||||
最新内容
|
||||
<template #right>
|
||||
<view
|
||||
class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400"
|
||||
@click="handleToSearch()"
|
||||
>
|
||||
<view class="uh-global-card-glass flex items-center justify-center rounded-md p-1 text-gray-400"
|
||||
@click="handleToSearch()">
|
||||
<wd-icon name="arrow-right" size="12px" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -221,10 +220,8 @@ onMounted(() => {
|
||||
</view>
|
||||
<block v-else>
|
||||
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
|
||||
<uh-article-card
|
||||
v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
||||
@on-click="handleToArticleDetail"
|
||||
/>
|
||||
<uh-article-card v-for="(article, index) in articleList" :key="index" from="home" :article="article"
|
||||
@on-click="handleToArticleDetail" />
|
||||
</view>
|
||||
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
|
||||
{{ loadMoreText }}
|
||||
|
||||
Reference in New Issue
Block a user