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

chore: 批量新增项目依赖、工具函数、页面与组件资源

1. 新增mp-html、qs等生产依赖,补全项目基础库
2. 新增平台判断、缓存、工具函数等通用工具集
3. 新增标签页、网站浏览页、关于页等业务页面
4. 新增分类卡片、通知弹窗等业务组件
5. 新增uts-progressNotification、liu-poster、uhalo-upgrade等uni模块
6. 补充audio/video组件样式补件,修复uni-components路径缺失问题
7. 新增环境变量Halo个人令牌配置项
8. 重构store导出结构,新增appConfig/halo/setting三个状态模块
9. 新增tsconfig编译目标配置,适配更高版本ES语法
This commit is contained in:
小莫唐尼
2026-08-31 19:56:16 +08:00
parent ba5b77568b
commit 067f3ed98a
147 changed files with 20866 additions and 325 deletions
Vendored
+8 -5
View File
@@ -1,8 +1,8 @@
VITE_APP_TITLE = 'unibest'
VITE_APP_PORT = 9000
VITE_APP_TITLE = 'uni-halo'
VITE_APP_PORT = 9520
VITE_UNI_APPID = '__UNI__D1E5001'
VITE_WX_APPID = 'wxa2abb91f64032a2b'
VITE_UNI_APPID = '__UNI__5748B6E'
VITE_WX_APPID = 'wx1bb161867082c72f'
# 微信开发者工具 CLI 路径,仅当默认安装路径不正确时配置(就是当 pnpm dev:mp 无法自动打开微信开发者工具时,才需要配置,通常是你更改了默认的安装位置导致的,一般出现在windows系统)
# macOS 示例:
@@ -12,9 +12,12 @@ VITE_WX_APPID = 'wxa2abb91f64032a2b'
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router
# 比如你要部署到 https://unibest.tech/doc/ ,则配置为 /doc/
# 比如你要部署到 https://uni-halo.925i.cn/uni-halo/ ,则配置为 /uni-halo/
VITE_APP_PUBLIC_BASE=/
# 在halo个人中心生成的 token
VITE_APP_HALO_TOKEN = ''
# 默认后台请求地址
# 不同命令会按 mode 叠加读取 .env.development / .env.test / .env.production。
# 微信小程序如果没有配置下面的专用地址,也会回退使用这个值。
+3
View File
@@ -5,5 +5,8 @@ VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
# 在halo个人中心生成的 token
VITE_APP_HALO_TOKEN = ''
# development mode 后台请求地址
VITE_SERVER_BASEURL = 'http://localhost:8090'
+3
View File
@@ -5,5 +5,8 @@ VITE_DELETE_CONSOLE = true
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
# 在halo个人中心生成的 token
VITE_APP_HALO_TOKEN = ''
# production mode 后台请求地址
# VITE_SERVER_BASEURL = 'http://localhost:8090
+3
View File
@@ -5,5 +5,8 @@ VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
# 在halo个人中心生成的 token
VITE_APP_HALO_TOKEN = ''
# test mode 后台请求地址
# VITE_SERVER_BASEURL = 'http://localhost:8090'
+13
View File
@@ -10,6 +10,17 @@ export default uniHelper({
// 忽略原生插件目录
'**/nativeplugins/',
'dist',
// AI skills 示例文件(非项目代码,避免 lint --fix 误改)
'.agents/',
// 环境变量与脚本目录(非 lint 目标)
'env/',
'scripts/',
// 第三方图表组件(从旧项目带入,保持原样)
'src/components/qiun-*/',
// mp-html 富文本组件(第三方,从旧项目带入,保持原样)
'src/components/mp-html/',
// unibest 演示页面与数据
'src/pages-demo/',
// unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
'auto-import.d.ts',
// vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
@@ -32,6 +43,8 @@ export default uniHelper({
'jsdoc/require-returns-description': 'off',
'ts/no-empty-object-type': 'off',
'no-extend-native': 'off',
// uni-app 中 view 组件用 v-html 渲染服务端富文本(评论/免责声明/故事)是常规需求,关闭该规则
'vue/no-v-text-v-html-on-component': 'off',
// uni 条件编译注释可能包裹 import,自动排序会破坏平台条件边界
'perfectionist/sort-imports': 'off',
'vue/singleline-html-element-content-newline': [
+2
View File
@@ -141,8 +141,10 @@
"abortcontroller-polyfill": "^1.7.8",
"alova": "^3.3.3",
"dayjs": "1.11.10",
"mp-html": "^2.5.2",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"qs": "6.12.1",
"vue": "^3.4.21",
"vue-i18n": "9.1.9",
"vue-router": "4.5.1",
+4 -2
View File
@@ -13,17 +13,19 @@ export default defineUniPages({
autoscan: true,
custom: {
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
'^uh-(.*)': '@/components/uh-$1/uh-$1.vue',
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
'z-paging/components/z-paging$1/z-paging$1.vue',
'^wd-(.*)': '@wot-ui/ui/components/wd-$1/wd-$1.vue',
},
'^mp-html(.*)': 'mp-html/dist/uni-app/components/mp-html/mp-html$1.vue',
},
},
// tabbar 的配置统一在 “./src/tabbar/config.ts” 文件中
// pages-blog 是博客页面,对应旧版项目的 pagesA 子包
subPackages: [{
root: 'pages-demo',
pages: [],
},{
}, {
root: 'pages-blog',
pages: [],
}],
+19
View File
@@ -84,12 +84,18 @@ importers:
dayjs:
specifier: 1.11.10
version: 1.11.10
mp-html:
specifier: ^2.5.2
version: 2.5.2
pinia:
specifier: 2.0.36
version: 2.0.36(typescript@5.8.3)(vue@3.4.21(typescript@5.8.3))
pinia-plugin-persistedstate:
specifier: 3.2.1
version: 3.2.1(pinia@2.0.36(typescript@5.8.3)(vue@3.4.21(typescript@5.8.3)))
qs:
specifier: 6.12.1
version: 6.12.1
vue:
specifier: ^3.4.21
version: 3.4.21(typescript@5.8.3)
@@ -7321,6 +7327,9 @@ packages:
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
mp-html@2.5.2:
resolution: {integrity: sha512-45e8c32Qgux4YU4iC3qCSFsOh3y+RwPwZ+iz/vvLkDgSGWk+1zsL4WUzWWQc9w3AsAfkaD/QR0oIufIDngBmXA==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -8189,6 +8198,10 @@ packages:
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
hasBin: true
qs@6.12.1:
resolution: {integrity: sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==}
engines: {node: '>=0.6'}
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@@ -19774,6 +19787,8 @@ snapshots:
moment@2.30.1: {}
mp-html@2.5.2: {}
mri@1.2.0: {}
mrmime@2.0.1: {}
@@ -20602,6 +20617,10 @@ snapshots:
qrcode-terminal@0.12.0: {}
qs@6.12.1:
dependencies:
side-channel: 1.1.0
qs@6.13.0:
dependencies:
side-channel: 1.1.0
+299
View File
@@ -0,0 +1,299 @@
/**
* Halo 官方 API 接口定义
* 覆盖官方扩展点:api.content.halo.run / api.halo.run / api.moment.halo.run /
* api.photo.halo.run / api.link.halo.run / api.plugin.halo.run
* 风格参考 src/api/foo-alova.ts:http.Get<IResponse<T>>(url, { params, header, meta })
* 源自旧项目 api/v2/all.api.js(官方部分),按需命名导出
*/
import { http } from '@/http/alova';
import { RequestFrom } from '@/http/tools/enum';
import type { IResponse } from '@/http/types';
import { getCache } from '@/utils/storage';
import { getNologinEmail, getOpenid } from '@/utils/auth';
import type {
IBlogStats,
ICategory,
ICategoryListReq,
ICategoryListRes,
IComment,
ICommentListReq,
ICommentListRes,
ILink,
ILinkGroupListRes,
ILinkListRes,
IMoment,
IMomentListReq,
IMomentListRes,
IPhotoGroupListReq,
IPhotoGroupListRes,
IPhotoListReq,
IPhotoListRes,
IPluginAvailable,
IPost,
IPostListReq,
IPostListRes,
ISearchReq,
ISearchRes,
ITagListRes,
ITrackerCounterReq,
IUpvoteReq
} from './types/halo';
/** 评论验证码 cookie key */
const COMMENT_WIDGET_CAPTCHA_COOKIES = 'comment-widget-captcha';
/* ==================== 文章 ==================== */
/**
* 文章列表
*/
export function getPostList(params: IPostListReq) {
return http.Get<IResponse<IPostListRes>>('/apis/api.content.halo.run/v1alpha1/posts', {
query: params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 文章详情(带访客标识头)
*/
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 }
});
}
/**
* 关键词搜索文章
*/
export function getPostListByKeyword(params: ISearchReq) {
return http.Post<IResponse<ISearchRes>>('/apis/api.halo.run/v1alpha1/indices/-/search', params, {
meta: { requestFrom: RequestFrom.Halo }
});
}
/* ==================== 分类 / 标签 ==================== */
/**
* 分类列表
* 注:旧代码用 qs.stringify 特殊序列化(arrayFormat:'repeat'、allowDots),
* alova 的 params 对数组默认即 repeat 形式(a=1&a=2),已等价;如遇嵌套对象场景再单独处理
*/
export function getCategoryList(params: ICategoryListReq) {
return http.Get<IResponse<ICategoryListRes>>('/apis/api.content.halo.run/v1alpha1/categories', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 分类下文章列表
*/
export function getCategoryPostList(name: string, params: IPostListReq) {
return http.Get<IResponse<IPostListRes>>(`/apis/api.content.halo.run/v1alpha1/categories/${name}/posts`, {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 标签列表
*/
export function getTagList(params: ICategoryListReq) {
return http.Get<IResponse<ITagListRes>>('/apis/api.content.halo.run/v1alpha1/tags', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 标签下文章列表
*/
export function getPostByTagName(tagName: string, params: IPostListReq) {
return http.Get<IResponse<IPostListRes>>(`/apis/api.content.halo.run/v1alpha1/tags/${tagName}/posts`, {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/* ==================== 评论(含验证码 cookie 链路) ==================== */
/**
* 评论列表
*/
export function getPostCommentList(params: ICommentListReq) {
return http.Get<IResponse<ICommentListRes>>('/apis/api.halo.run/v1alpha1/comments', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 评论回复列表
*/
export function getPostCommentReplyList(commentName: string, params: ICommentListReq) {
return http.Get<IResponse<ICommentListRes>>(`/apis/api.halo.run/v1alpha1/comments/${commentName}/reply`, {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/** 新增评论(带验证码,captchaCode 转入请求头) */
export interface IAddCommentReq {
raw: string;
content?: string;
owner?: Record<string, unknown>;
/** 评论目标引用(subjectRef: group/kind/name/version) */
subjectRef?: {
group: string;
kind: string;
name: string;
version?: string;
};
/** 验证码,提交时转入 X-Captcha-Code 头 */
captchaCode?: string;
}
/**
* 新增评论(captchaCode 拆出转 X-Captcha-Code 头 + Cookie)
*/
export function addPostComment(data: IAddCommentReq) {
const { captchaCode, ...rest } = data;
const headers: Record<string, string> = {
Accept: 'application/json'
};
if (captchaCode) headers['X-Captcha-Code'] = captchaCode;
const cookie = getCache<string>(COMMENT_WIDGET_CAPTCHA_COOKIES);
if (cookie) headers.Cookie = cookie;
return http.Post<IResponse<IComment>>('/apis/api.halo.run/v1alpha1/comments', rest, {
headers,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 新增评论回复(同上,验证码逻辑)
*/
export function addPostCommentReply(commentName: string, data: IAddCommentReq) {
const { captchaCode, ...rest } = data;
const headers: Record<string, string> = {
Accept: 'application/json'
};
if (captchaCode) headers['X-Captcha-Code'] = captchaCode;
const cookie = getCache<string>(COMMENT_WIDGET_CAPTCHA_COOKIES);
if (cookie) headers.Cookie = cookie;
return http.Post<IResponse<IComment>>(`/apis/api.halo.run/v1alpha1/comments/${commentName}/reply`, rest, {
headers,
meta: { requestFrom: RequestFrom.Halo }
});
}
/* ==================== 瞬间 ==================== */
/**
* 瞬间列表
*/
export function getMomentList(params: IMomentListReq) {
return http.Get<IResponse<IMomentListRes>>('/apis/api.moment.halo.run/v1alpha1/moments', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 瞬间详情
*/
export function getMomentByName(name: string) {
return http.Get<IResponse<IMoment>>(`/apis/api.moment.halo.run/v1alpha1/moments/${name}`, {
meta: { requestFrom: RequestFrom.Halo }
});
}
/* ==================== 图库 ==================== */
/**
* 相册分组列表
*/
export function getPhotoGroupList(params: IPhotoGroupListReq) {
return http.Get<IResponse<IPhotoGroupListRes>>('/apis/api.photo.halo.run/v1alpha1/photogroups', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 照片列表(按相册)
*/
export function getPhotoListByGroupName(params: IPhotoListReq) {
return http.Get<IResponse<IPhotoListRes>>('/apis/api.photo.halo.run/v1alpha1/photos', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/* ==================== 友链 ==================== */
/**
* 友链分组列表
*/
export function getFriendLinkGroupList(params: ICategoryListReq) {
return http.Get<IResponse<ILinkGroupListRes>>('/apis/api.link.halo.run/v1alpha1/linkgroups', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 友链列表
*/
export function getFriendLinkList(params: ICategoryListReq) {
return http.Get<IResponse<ILinkListRes>>('/apis/api.link.halo.run/v1alpha1/links', {
params,
meta: { requestFrom: RequestFrom.Halo }
});
}
/* ==================== 统计 / 埋点 / 插件 ==================== */
/**
* 博客统计信息
*/
export function getBlogStatistics() {
return http.Get<IResponse<IBlogStats>>('/apis/api.halo.run/v1alpha1/stats/-', {
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 提交点赞
*/
export function submitUpvote(data: IUpvoteReq) {
return http.Post<IResponse<unknown>>('/apis/api.halo.run/v1alpha1/trackers/upvote', data, {
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 提交计数埋点
*/
export function postTrackersCounter(data: ITrackerCounterReq) {
return http.Post<IResponse<unknown>>('/apis/api.halo.run/v1alpha1/trackers/counter', data, {
meta: { requestFrom: RequestFrom.Halo }
});
}
/**
* 检查插件是否可用
*/
export function checkPluginAvailable(name: string) {
return http.Get<IResponse<IPluginAvailable>>(`/apis/api.plugin.halo.run/v1alpha1/plugins/${name}/available`, {
meta: { requestFrom: RequestFrom.Halo }
});
}
/** 分类资源(供加密分类判断等场景) */
export type { ICategory, ILink, IMoment, IPost };
+432
View File
@@ -0,0 +1,432 @@
/**
* Halo 官方 API 类型定义
* 对应 src/api/halo.ts(Halo 官方 api.*.halo.run 扩展点)
* 结构基于 Halo 2.x API 规范
*/
/** Halo 通用元数据 */
export interface IMetadata {
name: string
labels?: Record<string, string>
annotations?: Record<string, string>
version?: number
creationTimestamp?: string
updateTimestamp?: string
}
/** Halo 通用分页结构(ListResult) */
export interface IListResult<T> {
page: number
size: number
total: number
items: T[]
first: boolean
last: boolean
hasNext: boolean
hasPrevious: boolean
totalPages: number
}
/* ---------- 文章 Post ---------- */
export interface IPostSpec {
title: string
slug: string
excerpt?: string
cover?: string
/** 发布时间(Halo 2.x 结构,旧项目直接使用) */
publishTime?: string
deleted: boolean
publish: boolean
pinned: boolean
allowComment: boolean
visible: 'PUBLIC' | 'PRIVATE'
priority: number
categories: string[]
tags: string[]
template?: string
htmlMetas?: Record<string, string>
baseSnapshot?: string
headSnapshot?: string
snapshotVersion?: number
excerptAutoGenerate?: boolean
}
export interface IPostStatus {
permalink: string
inProgress: boolean
commentsCount?: number
contributors?: IContributor[]
stats?: IPostStats
excerpt?: string
}
export interface IContributor {
name: string
displayName: string
avatar: string
}
export interface IPostStats {
visits?: number
upvotes?: number
comments?: number
}
/** 文章内容(含受限阅读的 raw 源文本) */
export interface IContent {
raw: string
content: string
displayVersion?: string
}
export interface IPost {
metadata: IMetadata
spec: IPostSpec
status?: IPostStatus
content?: IContent
categories?: ICategory[]
tags?: ITag[]
contributors?: IContributor[]
/** 旧项目使用的顶层统计(兼容 Halo 旧版结构) */
stats?: {
visit?: number
upvote?: number
comment?: number
[key: string]: unknown
}
}
/** 文章列表请求参数(分页 + 过滤) */
export interface IPostListReq {
page?: number
size?: number
sort?: string[]
keyword?: string
category?: string
tag?: string
visible?: 'PUBLIC' | 'PRIVATE'
[key: string]: unknown
}
export type IPostListRes = IListResult<IPost>
/** 文章搜索请求参数(关键字) */
export interface ISearchReq {
keyword?: string
page?: number
size?: number
highlightPreTag?: string
highlightPostTag?: string
}
export type ISearchRes = IListResult<IPost>
/* ---------- 分类 Category ---------- */
export interface ICategory {
metadata: IMetadata
spec: {
displayName: string
slug: string
description?: string
cover?: string
template?: string
priority: number
children?: string[]
}
status?: {
permalink: string
postCount?: number
visiblePostCount?: number
}
children?: ICategory[]
/** 文章数(旧项目首页直接读取/排序用) */
postCount?: number
}
export interface ICategoryListReq {
page?: number
size?: number
sort?: string[]
tree?: boolean
[key: string]: unknown
}
export type ICategoryListRes = IListResult<ICategory>
/** 分类下文章列表请求 */
export interface ICategoryPostListReq extends IPostListReq {
/** 私密分类密码(加密分类访问) */
password?: string
}
export type ICategoryPostListRes = IListResult<IPost>
/* ---------- 标签 Tag ---------- */
export interface ITag {
metadata: IMetadata
spec: {
displayName: string
slug: string
color?: string
cover?: string
}
status?: {
permalink: string
postCount?: number
visiblePostCount?: number
}
}
export interface ITagListReq {
page?: number
size?: number
sort?: string[]
[key: string]: unknown
}
export type ITagListRes = IListResult<ITag>
/* ---------- 评论 Comment ---------- */
export interface ICommentOwner {
kind: 'EMAIL' | 'PHONE' | 'GRAVATAR' | 'WEIXIN' | 'QQ'
displayName: string
avatar?: string
email?: string
website?: string
}
export interface IComment {
metadata: IMetadata
spec: {
raw: string
content: string
owner: ICommentOwner
userAgent?: string
ipAddress?: string
priority?: number
top?: boolean
allowNotification?: boolean
approved: boolean
hidden: boolean
subjectRef: {
group: string
version: string
kind: string
name: string
}
lastReadTime?: string
creationTime?: string
}
status?: {
hasReply?: boolean
replyCount?: number
visibleTime?: string
}
replies?: ICommentReply[]
}
export interface ICommentReply {
metadata: IMetadata
spec: {
raw: string
content: string
owner: ICommentOwner
quoteReply?: string
userAgent?: string
ipAddress?: string
priority?: number
top?: boolean
approved: boolean
hidden: boolean
subjectRef: {
group: string
version: string
kind: string
name: string
}
lastReadTime?: string
creationTime?: string
}
}
export interface ICommentListReq {
page?: number
size?: number
sort?: string[]
/** 主题名称(文章 name) */
name?: string
[key: string]: unknown
}
export type ICommentListRes = IListResult<IComment>
/* ---------- 瞬间 Moment ---------- */
export interface IMoment {
metadata: IMetadata
spec: {
content: string
owner: {
displayName: string
avatar?: string
website?: string
}
visible: 'PUBLIC' | 'PRIVATE'
allowComment: boolean
approved: boolean
pinned?: boolean
priority?: number
tags?: string[]
releaseTime?: string
}
status?: {
permalink: string
approved?: boolean
inProgress?: boolean
commentsCount?: number
}
}
export interface IMomentListReq {
page?: number
size?: number
sort?: string[]
[key: string]: unknown
}
export type IMomentListRes = IListResult<IMoment>
/* ---------- 图库 Photo ---------- */
export interface IPhotoGroup {
metadata: IMetadata
spec: {
displayName: string
description?: string
priority?: number
cover?: string
}
status?: {
permalink?: string
photoCount?: number
}
}
export interface IPhoto {
metadata: IMetadata
spec: {
displayName: string
description?: string
url: string
/** 封面图(部分接口返回,兼容旧代码) */
cover?: string
priority?: number
takeTime?: string
location?: string
latitude?: number
longitude?: number
}
}
export interface IPhotoGroupListReq {
page?: number
size?: number
sort?: string[]
[key: string]: unknown
}
export type IPhotoGroupListRes = IListResult<IPhotoGroup>
export interface IPhotoListReq {
page?: number
size?: number
sort?: string[]
groupName?: string
[key: string]: unknown
}
export type IPhotoListRes = IListResult<IPhoto>
/* ---------- 友链 FriendLink ---------- */
export interface ILinkGroup {
metadata: IMetadata
spec: {
displayName: string
priority?: number
description?: string
}
}
export interface ILink {
metadata: IMetadata
spec: {
displayName: string
url: string
logo: string
description?: string
priority?: number
groupName?: string
}
}
export interface ILinkGroupListReq {
page?: number
size?: number
sort?: string[]
[key: string]: unknown
}
export type ILinkGroupListRes = IListResult<ILinkGroup>
export interface ILinkListReq {
page?: number
size?: number
sort?: string[]
[key: string]: unknown
}
export type ILinkListRes = IListResult<ILink>
/* ---------- 统计 / 埋点 ---------- */
export interface IBlogStats {
visit?: number
upvote?: number
comment?: number
post?: number
category?: number
tag?: number
[key: string]: unknown
}
export interface ITrackerCounterReq {
group: string
name: string
plural?: string
endpoint?: string
hostname?: string
screen?: string
title?: string
referrer?: string
language?: string
/** 访问页面路径(旧项目埋点 url 字段) */
url?: string
}
export interface IUpvoteReq {
group: string
name: string
plural?: string
}
/* ---------- 插件可用性 ---------- */
export interface IPluginAvailable {
available: boolean
reason?: string
}
+313
View File
@@ -0,0 +1,313 @@
/**
* plugin-uni-halo 自定义接口 / 三方插件 API 类型定义
* 对应 src/api/uni-halo.ts(plugin-uni-halo 自身接口 + 三方插件)
*/
/* ---------- plugin-uni-halo 应用配置 ---------- */
/** 图片相关配置 */
export interface IImagesConfig {
defaultThumbnailUrl?: string
defaultStaticThumbnailUrl?: string
defaultImageUrl?: string
defaultAvatarUrl?: string
[key: string]: unknown
}
/** 插件配置(toolsPlugin/linksSubmitPlugin 等带 Authorization) */
export interface IPluginConfig {
votePlugin?: Record<string, unknown>
toolsPlugin?: { Authorization?: string } & Record<string, unknown>
linksPlugin?: Record<string, unknown>
linksSubmitPlugin?: { Authorization?: string } & Record<string, unknown>
doubanPlugin?: { position?: string } & Record<string, unknown>
[key: string]: unknown
}
/** 首页配置(banner 等) */
export interface IBannerConfig {
enabled?: boolean
showTitle?: boolean
showIndicator?: boolean
height?: string
dotPosition?: string
type?: string
list?: unknown[]
}
export interface IPageConfig {
homeConfig?: {
pageTitle?: string
useCategory?: boolean
/** 是否显示快捷导航(首页) */
useQuickNavigation?: boolean
bannerConfig?: IBannerConfig
}
categoryConfig?: { type?: string }
momentConfig?: { useTagRandomColor?: boolean }
galleryConfig?: { pageTitle?: string }
aboutConfig?: {
bgImageUrl?: string
waveImageUrl?: string
}
[key: string]: unknown
}
/** 审计模式配置 */
export interface IAuditConfig {
auditModeEnabled?: boolean
auditModeData?: {
jsonUrl?: string
jsonData?: string
}
}
/** 应用基础配置(对应旧 DefaultAppConfigs) */
export interface IAppConfig {
basicConfig?: {
tokenConfig?: {
personalToken?: string
}
}
loveConfig?: Record<string, unknown>
imagesConfig?: IImagesConfig
authorConfig?: Record<string, unknown>
appConfig?: Record<string, unknown>
pluginConfig?: IPluginConfig
pageConfig?: IPageConfig
auditConfig?: IAuditConfig
[key: string]: unknown
}
/** Halo 全局配置(对应旧 DefaultHaloGlobalConfigs,/actuator/globalinfo) */
export interface IHaloGlobalConfig {
allowAnonymousComments?: boolean
allowRegistration?: boolean
mustVerifyEmailOnRegistration?: boolean
mustVerifyPhoneOnRegistration?: boolean
restrictRegistrationDomain?: boolean
shopDisabled?: boolean
[key: string]: unknown
}
/** 审计模式模拟数据 */
export type IMockJson = Record<string, unknown>
/* ---------- plugin-uni-halo 二维码 / 检查更新 ---------- */
export interface IQRCodeInfo {
[key: string]: unknown
}
export interface IQRCodeImg {
[key: string]: unknown
}
/** 检查更新结果(uhalo-upgrade 对接) */
export interface IUpdateCheckRes {
code: number
message: string
url?: string
title?: string
contents?: string
is_mandatory?: boolean
is_silently?: boolean
type?: 'wgt' | 'apk' | 'ipa'
platform?: string[]
version?: string
[key: string]: unknown
}
/* ---------- 受限阅读(tools.muyin.site) ---------- */
export type RestrictReadType = 'password' | 'code' | 'login' | 'pay' | 'comment'
/** 受限阅读校验请求 */
export interface IRestrictReadCheckReq {
code: string
templateType: 'post'
restrictType: RestrictReadType
keyId: string
}
export interface IRestrictReadCheckRes {
success?: boolean
[key: string]: unknown
}
/** 验证码创建结果 */
export interface IVerificationCodeRes {
code?: string
[key: string]: unknown
}
/* ---------- 友链提交(linkssubmit.muyin.site) ---------- */
export interface ISubmitLinkForm {
name?: string
url?: string
logo?: string
description?: string
email?: string
[key: string]: unknown
}
export interface ILinkSubmitRes {
success?: boolean
message?: string
[key: string]: unknown
}
/* ---------- 投票(api.vote.kunkunyu.com) ---------- */
export interface IVoteListReq {
page?: number
size?: number
[key: string]: unknown
}
export interface IVoteItem {
name: string
title: string
description?: string
[key: string]: unknown
}
export type IVoteListRes = IVoteItem[]
export interface IVote {
name: string
title: string
description?: string
options?: IVoteOption[]
[key: string]: unknown
}
export interface IVoteOption {
name?: string
label?: string
count?: number
[key: string]: unknown
}
export interface IVoteUser {
[key: string]: unknown
}
export type IVoteUserListRes = IVoteUser[]
/** 提交投票 */
export interface IVoteSubmitReq {
optionName?: string
[key: string]: unknown
}
export interface IVoteSubmitRes {
success?: boolean
[key: string]: unknown
}
/* ---------- 豆瓣(api.douban.moony.la) ---------- */
export interface IDoubanDetail {
title?: string
poster?: string
rating?: string
year?: string
[key: string]: unknown
}
/* ---------- 评论组件(api.commentwidget.halo.run) ---------- */
export interface ICommentWidgetConfig {
[key: string]: unknown
}
/** 评论验证码 */
export interface ICommentCaptcha {
[key: string]: unknown
}
/* ---------- 恋爱模块(plugin-uni-halo love) ---------- */
export interface ILoveConfig {
/** 纪念日 */
anniversary?: string
/** 恋人信息 */
lovers?: {
name?: string
avatar?: string
[key: string]: unknown
}
[key: string]: unknown
}
export interface ILoveAlbum {
name?: string
title?: string
/** 相册展示名(接口返回 displayName) */
displayName?: string
description?: string
locked?: boolean
cover?: string
photos?: ILovePhoto[]
[key: string]: unknown
}
export interface ILovePhoto {
url?: string
description?: string
[key: string]: unknown
}
export interface ILoveAlbumListReq {
page?: number
size?: number
[key: string]: unknown
}
export type ILoveAlbumListRes = ILoveAlbum[]
export interface ILoveAlbumDetailReq {
[key: string]: unknown
}
export interface IUnlockAlbumReq {
password: string
}
export interface ILoveUnlockRes {
success?: boolean
[key: string]: unknown
}
export interface ILoveDailyItem {
id?: string
content?: string
date?: string
[key: string]: unknown
}
export interface ILoveDailyItemListReq {
page?: number
size?: number
[key: string]: unknown
}
export type ILoveDailyItemListRes = ILoveDailyItem[]
export interface ILoveStory {
id?: string
title?: string
content?: string
date?: string
[key: string]: unknown
}
export interface ILoveStoryListReq {
page?: number
size?: number
[key: string]: unknown
}
export type ILoveStoryListRes = ILoveStory[]
+360
View File
@@ -0,0 +1,360 @@
/**
* uni-halo 自定义 API 接口定义
* 覆盖 plugin-uni-halo(/apis/api.unihalo.ialley.cn)与三方插件接口:
* 受限阅读(tools.muyin.site)、友链提交(linkssubmit.muyin.site)、投票(api.vote.kunkunyu.com)、
* 豆瓣(api.douban.moony.la)、评论组件(api.commentwidget.halo.run)
* 风格参考 src/api/foo-alova.ts:http.Get<IResponse<T>>(url, { params, header, meta })
* 源自旧项目 api/v2/all.config.js、all.api.js(三方部分)、love.*.js,按需命名导出
*/
import { http } from '@/http/alova'
import { RequestFrom } from '@/http/tools/enum'
import type { IResponse } from '@/http/types'
import { getCache } from '@/utils/storage'
import { getNologinEmail, getOpenid } from '@/utils/auth'
import { getPersonalToken } from '@/store/token'
import type {
IAppConfig,
ICommentWidgetConfig,
IDoubanDetail,
IHaloGlobalConfig,
ILoveAlbum,
ILoveAlbumListReq,
ILoveAlbumListRes,
ILoveConfig,
ILoveDailyItem,
ILoveDailyItemListReq,
ILoveDailyItemListRes,
ILoveStory,
ILoveStoryListReq,
ILoveStoryListRes,
IQRCodeInfo,
IRestrictReadCheckReq,
IRestrictReadCheckRes,
ISubmitLinkForm,
IUpdateCheckRes,
IVote,
IVoteListReq,
IVoteListRes,
IVoteSubmitReq,
RestrictReadType,
} from './types/uni-halo'
/** 评论验证码 cookie key */
const COMMENT_WIDGET_CAPTCHA_COOKIES = 'comment-widget-captcha'
/* ==================== plugin-uni-halo 配置 ==================== */
/**
* 获取应用配置
*/
export function getAppConfigs() {
return http.Get<IResponse<IAppConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getConfigs', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取 Halo 全局配置信息
*/
export function getHaloGlobalInfo() {
return http.Get<IResponse<IHaloGlobalConfig>>('/actuator/globalinfo', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取评论组件配置
*/
export function getCommentWidgetConfig() {
return http.Get<IResponse<ICommentWidgetConfig>>('/apis/api.commentwidget.halo.run/v1alpha1/config', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 检查更新(uhalo-upgrade 插件对接,插件本体引入后业务侧入口)
*/
export function checkVersion(baseUrl: string) {
return http.Get<IResponse<IUpdateCheckRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/checkVersion', {
params: { baseUrl },
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取二维码信息
*/
export function getQRCodeInfo(key: string) {
return http.Get<IResponse<IQRCodeInfo>>(`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getQRCodeInfo/${key}`, {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取文章二维码图片
*/
export function getQRCodeImg(postId: string) {
return http.Get<IResponse<unknown>>(`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/getQRCodeImg/${postId}`, {
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 恋爱模块 ==================== */
/**
* 获取恋爱配置(纪念日 + 恋人信息)
*/
export function getLoveConfig() {
return http.Get<IResponse<ILoveConfig>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-config', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取恋爱相册列表
*/
export function getLoveAlbums(params: ILoveAlbumListReq) {
return http.Get<IResponse<ILoveAlbumListRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums', {
params,
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取恋爱相册详情
*/
export function getLoveAlbumByName(name: string, params: ILoveAlbumListReq) {
return http.Get<IResponse<ILoveAlbum>>(`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums/${name}`, {
params,
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 密码解锁相册
*/
export function unlockAlbum(name: string, password: string) {
return http.Post<IResponse<{ token: string, photos?: unknown[] }>>(
`/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-albums/${name}/unlock`,
{
password,
},
{
meta: { requestFrom: RequestFrom.Halo },
},
)
}
/**
* 获取恋爱清单列表(分页)
*/
export function getLoveDailyItems(params: ILoveDailyItemListReq) {
return http.Get<IResponse<ILoveDailyItemListRes>>(
'/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-daily-items',
{
params,
meta: { requestFrom: RequestFrom.Halo },
},
)
}
/**
* 获取恋爱故事列表
*/
export function getLoveStories(params: ILoveStoryListReq) {
return http.Get<IResponse<ILoveStoryListRes>>('/apis/api.unihalo.ialley.cn/v1alpha1/plugins/plugin-uni-halo/love-stories', {
params,
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 受限阅读(tools.muyin.site) ==================== */
/**
* 受限阅读检查(密码/验证码/登录/付费/评论)
*/
export function requestRestrictReadCheck(restrictType: RestrictReadType, code: string, keyId: string) {
const data: IRestrictReadCheckReq = {
code,
templateType: 'post',
restrictType,
keyId,
}
return http.Post<IResponse<IRestrictReadCheckRes>>('/apis/tools.muyin.site/v1alpha1/restrict-read/check', data, {
headers: {
'Authorization': getToolsAuthorization(),
'Wechat-Session-Id': getOpenid(),
'nologin-email': getNologinEmail(),
},
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 创建验证码(受限阅读)
*/
export function createVerificationCode() {
return http.Get<IResponse<string>>('/apis/tools.muyin.site/v1alpha1/restrict-read/create', {
headers: {
'Authorization': getToolsAuthorization(),
'Wechat-Session-Id': getOpenid(),
},
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 友链提交(linkssubmit.muyin.site) ==================== */
/**
* 提交友链
*/
export function submitLink(form: ISubmitLinkForm) {
return http.Post<IResponse<{ msg?: string, message?: string }>>('/apis/linkssubmit.muyin.site/v1alpha1/submit', form, {
headers: {
'Authorization': getLinksSubmitAuthorization(),
'Wechat-Session-Id': getOpenid(),
},
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 投票(api.vote.kunkunyu.com) ==================== */
/**
* 投票列表
*/
export function getVoteList(params: IVoteListReq) {
return http.Get<IResponse<IVoteListRes>>('/apis/api.vote.kunkunyu.com/v1alpha1/votes', {
params,
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 投票详情
*/
export function getVoteDetail(name: string) {
return http.Get<IResponse<IVote>>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/detail`, {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 投票用户列表
*/
export function getVoteUserList(name: string) {
return http.Get<IResponse<unknown[]>>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/user-list`, {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 提交投票
* @param canAnonymously 是否允许匿名;非匿名时带个人 token
*/
export function submitVote(name: string, data: IVoteSubmitReq, canAnonymously = true) {
const headers: Record<string, string> = {}
if (!canAnonymously) {
const token = getPersonalToken()
if (token)
headers.Authorization = `Bearer ${token}`
}
return http.Post<IResponse<unknown>>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/submit`, data, {
headers,
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 豆瓣(api.douban.moony.la) ==================== */
/**
* 获取豆瓣条目详情
*/
export function getDoubanDetail(url: string) {
return http.Get<IResponse<IDoubanDetail>>('/apis/api.douban.moony.la/v1alpha1/doubanmovies/-/getDoubanDetail', {
params: { url },
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 评论验证码(api.commentwidget.halo.run) ==================== */
/**
* 获取评论验证码
*/
export function getCommentWidgetCaptcha() {
return http.Get<IResponse<string>>('/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 数据看板(api.data.statistics.xhhao.com) ==================== */
/** 图表统计数据 */
export interface IDataStatistics {
tags: { name: string, count: number }[]
categories: { name: string, total: number }[]
articles: { date: string, count: number }[]
comments: { username: string, count: number }[]
top10Articles: { name: string, views: number }[]
[key: string]: unknown
}
/**
* 获取图表统计数据
* @description 标签、分类、文章发布趋势、评论活跃用户、热门文章 top10
*/
export function getChartData() {
return http.Get<IResponse<IDataStatistics>>('/apis/api.data.statistics.xhhao.com/v1alpha1/chart/data', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取 Github 配置信息
*/
export function getGithubConfig() {
return http.Get<IResponse<unknown>>('/apis/api.data.statistics.xhhao.com/v1alpha1/github/config', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/**
* 获取 Uptime Kuma 状态页面数据
*/
export function getUptimeKumaStatus() {
return http.Get<IResponse<unknown>>('/apis/api.data.statistics.xhhao.com/v1alpha1/uptime/status', {
meta: { requestFrom: RequestFrom.Halo },
})
}
/* ==================== 内部辅助 ==================== */
/**
* 工具类插件授权头(源自应用配置 pluginConfig.toolsPlugin.Authorization)
*/
function getToolsAuthorization(): string {
return getAppConfigFromStore().pluginConfig?.toolsPlugin?.Authorization || ''
}
/**
* 友链提交插件授权头(源自应用配置 pluginConfig.linksSubmitPlugin.Authorization)
*/
function getLinksSubmitAuthorization(): string {
return getAppConfigFromStore().pluginConfig?.linksSubmitPlugin?.Authorization || ''
}
/**
* 读取应用配置(store 未就绪时兜底为空)
* 注:待 src/store/appConfig.ts 建立后改为 useAppConfigStore 读取
*/
function getAppConfigFromStore(): IAppConfig {
// TODO: 迁移 store 后改为从 Pinia 读取,避免每次从 storage 解析
const cached = getCache<IAppConfig>('APP_GLOBAL_CONFIGS')
return cached || {}
}
/** 评论验证码 cookie key(供拦截器/页面使用) */
export { COMMENT_WIDGET_CAPTCHA_COOKIES }
export type { ILoveDailyItem, ILoveStory }
@@ -0,0 +1,11 @@
// 以下项目可以删减或更换顺序,但不能添加或更改名字
export default {
// 普通标签的菜单项
node: ['大小', '斜体', '粗体', '下划线', '居中', '缩进', '上移', '下移', '删除'],
// 图片的菜单项
img: ['换图', '宽度', '超链接', '预览图', '禁用预览', '上移', '下移', '删除'],
// 链接的菜单项
link: ['更换链接', '上移', '下移', '删除'],
// 音视频的菜单项
media: ['封面', '循环', '自动播放', '上移', '下移', '删除']
}
@@ -0,0 +1,532 @@
/**
* @fileoverview editable 插件
*/
import config from './config'
import Parser from '../parser'
function Editable (vm) {
this.vm = vm
this.editHistory = [] // 历史记录
this.editI = -1 // 历史记录指针
vm._mask = [] // 蒙版被点击时进行的操作
vm._setData = function (path, val) {
const paths = path.split('.')
let target = vm
for (let i = 0; i < paths.length - 1; i++) {
target = target[paths[i]]
}
vm.$set(target, paths.pop(), val)
}
/**
* @description 移动历史记录指针
* @param {Number} num 移动距离
*/
const move = num => {
setTimeout(() => {
const item = this.editHistory[this.editI + num]
if (item) {
this.editI += num
vm._setData(item.key, item.value)
}
}, 200)
}
vm.undo = () => move(-1) // 撤销
vm.redo = () => move(1) // 重做
/**
* @description 更新记录
* @param {String} path 更新内容路径
* @param {*} oldVal 旧值
* @param {*} newVal 新值
* @param {Boolean} set 是否更新到视图
* @private
*/
vm._editVal = (path, oldVal, newVal, set) => {
// 当前指针后的内容去除
while (this.editI < this.editHistory.length - 1) {
this.editHistory.pop()
}
// 最多存储 30 条操作记录
while (this.editHistory.length > 30) {
this.editHistory.pop()
this.editI--
}
const last = this.editHistory[this.editHistory.length - 1]
if (!last || last.key !== path) {
if (last) {
// 去掉上一次的新值
this.editHistory.pop()
this.editI--
}
// 存入这一次的旧值
this.editHistory.push({
key: path,
value: oldVal
})
this.editI++
}
// 存入本次的新值
this.editHistory.push({
key: path,
value: newVal
})
this.editI++
// 更新到视图
if (set) {
vm._setData(path, newVal)
}
}
/**
* @description 获取菜单项
* @private
*/
vm._getItem = function (node, up, down) {
let items
let i
if (node.name === 'img') {
items = config.img.slice(0)
if (!vm.getSrc) {
i = items.indexOf('换图')
if (i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('超链接')
if (i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('预览图')
if (i !== -1) {
items.splice(i, 1)
}
}
i = items.indexOf('禁用预览')
if (i !== -1 && node.attrs.ignore) {
items[i] = '启用预览'
}
} else if (node.name === 'a') {
items = config.link.slice(0)
if (!vm.getSrc) {
i = items.indexOf('更换链接')
if (i !== -1) {
items.splice(i, 1)
}
}
} else if (node.name === 'video' || node.name === 'audio') {
items = config.media.slice(0)
i = items.indexOf('封面')
if (!vm.getSrc && i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('循环')
if (node.attrs.loop && i !== -1) {
items[i] = '不循环'
}
i = items.indexOf('自动播放')
if (node.attrs.autoplay && i !== -1) {
items[i] = '不自动播放'
}
} else {
items = config.node.slice(0)
}
if (!up) {
i = items.indexOf('上移')
if (i !== -1) {
items.splice(i, 1)
}
}
if (!down) {
i = items.indexOf('下移')
if (i !== -1) {
items.splice(i, 1)
}
}
return items
}
/**
* @description 显示 tooltip
* @param {object} obj
* @private
*/
vm._tooltip = function (obj) {
vm.$set(vm, 'tooltip', {
top: obj.top,
items: obj.items
})
vm._tooltipcb = obj.success
}
/**
* @description 显示滚动条
* @param {object} obj
* @private
*/
vm._slider = function (obj) {
vm.$set(vm, 'slider', {
min: obj.min,
max: obj.max,
value: obj.value,
top: obj.top
})
vm._slideringcb = obj.changing
vm._slidercb = obj.change
}
/**
* @description 点击蒙版
* @private
*/
vm._maskTap = function () {
// 隐藏所有悬浮窗
while (vm._mask.length) {
(vm._mask.pop())()
}
if (vm.tooltip) {
vm.$set(vm, 'tooltip', null)
}
if (vm.slider) {
vm.$set(vm, 'slider', null)
}
}
/**
* @description 插入节点
* @param {Object} node
*/
function insert (node) {
if (vm._edit) {
vm._edit.insert(node)
} else {
const nodes = vm.nodes.slice(0)
nodes.push(node)
vm._editVal('nodes', vm.nodes, nodes, true)
}
}
/**
* @description 在光标处插入指定 html 内容
* @param {String} html 内容
*/
vm.insertHtml = html => {
this.inserting = true
const arr = new Parser(vm).parse(html)
this.inserting = undefined
for (let i = 0; i < arr.length; i++) {
insert(arr[i])
}
}
/**
* @description 在光标处插入图片
*/
vm.insertImg = function () {
vm.getSrc && vm.getSrc('img').then(src => {
if (typeof src === 'string') {
src = [src]
}
const parser = new Parser(vm)
for (let i = 0; i < src.length; i++) {
insert({
name: 'img',
attrs: {
src: parser.getUrl(src[i])
}
})
}
}).catch(() => { })
}
/**
* @description 在光标处插入一个链接
*/
vm.insertLink = function () {
vm.getSrc && vm.getSrc('link').then(url => {
insert({
name: 'a',
attrs: {
href: url
},
children: [{
type: 'text',
text: url
}]
})
}).catch(() => { })
}
/**
* @description 在光标处插入一个表格
* @param {Number} rows 行数
* @param {Number} cols 列数
*/
vm.insertTable = function (rows, cols) {
const table = {
name: 'table',
attrs: {
style: 'display:table;width:100%;margin:10px 0;text-align:center;border-spacing:0;border-collapse:collapse;border:1px solid gray'
},
children: []
}
for (let i = 0; i < rows; i++) {
const tr = {
name: 'tr',
attrs: {},
children: []
}
for (let j = 0; j < cols; j++) {
tr.children.push({
name: 'td',
attrs: {
style: 'padding:2px;border:1px solid gray'
},
children: [{
type: 'text',
text: ''
}]
})
}
table.children.push(tr)
}
insert(table)
}
/**
* @description 插入视频/音频
* @param {Object} node
*/
function insertMedia (node) {
if (typeof node.src === 'string') {
node.src = [node.src]
}
const parser = new Parser(vm)
// 拼接主域名
for (let i = 0; i < node.src.length; i++) {
node.src[i] = parser.getUrl(node.src[i])
}
insert({
name: 'div',
attrs: {
style: 'text-align:center'
},
children: [node]
})
}
/**
* @description 在光标处插入一个视频
*/
vm.insertVideo = function () {
vm.getSrc && vm.getSrc('video').then(src => {
insertMedia({
name: 'video',
attrs: {
controls: 'T'
},
children: [],
src,
// #ifdef APP-PLUS
html: `<video src="${src}" style="width:100%;height:100%"></video>`
// #endif
})
}).catch(() => { })
}
/**
* @description 在光标处插入一个音频
*/
vm.insertAudio = function () {
vm.getSrc && vm.getSrc('audio').then(attrs => {
let src
if (attrs.src) {
src = attrs.src
attrs.src = undefined
} else {
src = attrs
attrs = {}
}
attrs.controls = 'T'
insertMedia({
name: 'audio',
attrs,
children: [],
src
})
}).catch(() => { })
}
/**
* @description 在光标处插入一段文本
*/
vm.insertText = function () {
insert({
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
})
}
/**
* @description 清空内容
*/
vm.clear = function () {
vm._maskTap()
vm._edit = undefined
vm.$set(vm, 'nodes', [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}])
}
/**
* @description 获取编辑后的 html
*/
vm.getContent = function () {
let html = '';
// 递归遍历获取
(function traversal (nodes, table) {
for (let i = 0; i < nodes.length; i++) {
let item = nodes[i]
if (item.type === 'text') {
html += item.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>').replace(/\xa0/g, '&nbsp;') // 编码实体
} else {
if (item.name === 'img') {
item.attrs.i = ''
// 还原被转换的 svg
if ((item.attrs.src || '').includes('data:image/svg+xml;utf8,')) {
html += item.attrs.src.substr(24).replace(/%23/g, '#').replace('<svg', '<svg style="' + (item.attrs.style || '') + '"')
continue
}
} else if (item.name === 'video' || item.name === 'audio') {
// 还原 video 和 audio 的 source
item = JSON.parse(JSON.stringify(item))
if (item.src.length > 1) {
item.children = []
for (let j = 0; j < item.src.length; j++) {
item.children.push({
name: 'source',
attrs: {
src: item.src[j]
}
})
}
} else {
item.attrs.src = item.src[0]
}
} else if (item.name === 'div' && (item.attrs.style || '').includes('overflow:auto') && (item.children[0] || {}).name === 'table') {
// 还原滚动层
item = item.children[0]
}
// 还原 table
if (item.name === 'table') {
item = JSON.parse(JSON.stringify(item))
table = item.attrs
if ((item.attrs.style || '').includes('display:grid')) {
item.attrs.style = item.attrs.style.split('display:grid')[0]
const children = [{
name: 'tr',
attrs: {},
children: []
}]
for (let j = 0; j < item.children.length; j++) {
item.children[j].attrs.style = item.children[j].attrs.style.replace(/grid-[^;]+;*/g, '')
if (item.children[j].r !== children.length) {
children.push({
name: 'tr',
attrs: {},
children: [item.children[j]]
})
} else {
children[children.length - 1].children.push(item.children[j])
}
}
item.children = children
}
}
html += '<' + item.name
for (const attr in item.attrs) {
let val = item.attrs[attr]
if (!val) continue
if (val === 'T' || val === true) {
// bool 型省略值
html += ' ' + attr
continue
} else if (item.name[0] === 't' && attr === 'style' && table) {
// 取消为了显示 table 添加的 style
val = val.replace(/;*display:table[^;]*/, '')
if (table.border) {
val = val.replace(/border[^;]+;*/g, $ => $.includes('collapse') ? $ : '')
}
if (table.cellpadding) {
val = val.replace(/padding[^;]+;*/g, '')
}
if (!val) continue
}
html += ' ' + attr + '="' + val.replace(/"/g, '&quot;') + '"'
}
html += '>'
if (item.children) {
traversal(item.children, table)
html += '</' + item.name + '>'
}
}
}
})(vm.nodes)
// 其他插件处理
for (let i = vm.plugins.length; i--;) {
if (vm.plugins[i].onGetContent) {
html = vm.plugins[i].onGetContent(html) || html
}
}
return html
}
}
Editable.prototype.onUpdate = function (content, config) {
if (this.vm.editable) {
this.vm._maskTap()
config.entities.amp = '&'
if (!this.inserting) {
this.vm._edit = undefined
if (!content) {
setTimeout(() => {
this.vm.$set(this.vm, 'nodes', [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}])
}, 0)
}
}
}
}
Editable.prototype.onParse = function (node) {
// 空白单元格可编辑
if (this.vm.editable && (node.name === 'td' || node.name === 'th') && !this.vm.getText(node.children)) {
node.children.push({
type: 'text',
text: ''
})
}
}
export default Editable
@@ -0,0 +1,203 @@
/**
* @fileoverview emoji 插件
*/
const reg = /\[(\S+?)\]/g
const data = {
笑脸: '😄',
生病: '😷',
破涕为笑: '😂',
吐舌: '😝',
脸红: '😳',
恐惧: '😱',
失望: '😔',
无语: '😒',
眨眼: '😉',
: '😎',
: '😭',
痴迷: '😍',
: '😘',
思考: '🤔',
困惑: '😕',
颠倒: '🙃',
: '🤑',
惊讶: '😲',
白眼: '🙄',
叹气: '😤',
睡觉: '😴',
书呆子: '🤓',
愤怒: '😡',
面无表情: '😑',
张嘴: '😮',
量体温: '🤒',
呕吐: '🤮',
光环: '😇',
幽灵: '👻',
外星人: '👽',
机器人: '🤖',
捂眼镜: '🙈',
捂耳朵: '🙉',
捂嘴: '🙊',
婴儿: '👶',
男孩: '👦',
女孩: '👧',
男人: '👨',
女人: '👩',
老人: '👴',
老妇人: '👵',
警察: '👮',
王子: '🤴',
公主: '🤴',
举手: '🙋',
跑步: '🏃',
家庭: '👪',
眼睛: '👀',
鼻子: '👃',
耳朵: '👂',
舌头: '👅',
: '👄',
: '❤️',
心碎: '💔',
雪人: '☃️',
情书: '💌',
大便: '💩',
闹钟: '⏰',
眼镜: '👓',
雨伞: '☂️',
音乐: '🎵',
话筒: '🎤',
游戏机: '🎮',
喇叭: '📢',
耳机: '🎧',
礼物: '🎁',
电话: '📞',
电脑: '💻',
打印机: '🖨️',
手电筒: '🔦',
灯泡: '💡',
书本: '📖',
信封: '✉️',
药丸: '💊',
口红: '💄',
手机: '📱',
相机: '📷',
电视: '📺',
: '🀄',
垃圾桶: '🚮',
厕所: '🚾',
感叹号: '❗',
: '🈲',
: '🉑',
彩虹: '🌈',
旋风: '🌀',
雷电: '⚡',
雪花: '❄️',
星星: '⭐',
水滴: '💧',
玫瑰: '🌹',
加油: '💪',
: '👈',
: '👉',
: '👆',
: '👇',
手掌: '🖐️',
好的: '👌',
: '👍',
: '👎',
胜利: '✌',
拳头: '👊',
挥手: '👋',
鼓掌: '👏',
猴子: '🐒',
: '🐶',
: '🐺',
: '🐱',
老虎: '🐯',
: '🐎',
独角兽: '🦄',
斑马: '🦓',
鹿: '🦌',
: '🐮',
: '🐷',
: '🐏',
长颈鹿: '🦒',
大象: '🐘',
老鼠: '🐭',
蝙蝠: '🦇',
刺猬: '🦔',
熊猫: '🐼',
鸽子: '🕊️',
鸭子: '🦆',
兔子: '🐇',
老鹰: '🦅',
青蛙: '🐸',
: '🐍',
: '🐉',
鲸鱼: '🐳',
海豚: '🐬',
足球: '⚽',
棒球: '⚾',
篮球: '🏀',
排球: '🏐',
橄榄球: '🏉',
网球: '🎾',
骰子: '🎲',
鸡腿: '🍗',
蛋糕: '🎂',
啤酒: '🍺',
饺子: '🥟',
汉堡: '🍔',
薯条: '🍟',
意大利面: '🍝',
干杯: '🥂',
筷子: '🥢',
糖果: '🍬',
奶瓶: '🍼',
爆米花: '🍿',
邮局: '🏤',
医院: '🏥',
银行: '🏦',
酒店: '🏨',
学校: '🏫',
城堡: '🏰',
火车: '🚂',
高铁: '🚄',
地铁: '🚇',
公交: '🚌',
救护车: '🚑',
消防车: '🚒',
警车: '🚓',
出租车: '🚕',
汽车: '🚗',
货车: '🚛',
自行车: '🚲',
摩托: '🛵',
红绿灯: '🚥',
帆船: '⛵',
游轮: '🛳️',
轮船: '⛴️',
飞机: '✈️',
直升机: '🚁',
缆车: '🚠',
警告: '⚠️',
禁止: '⛔'
}
function Emoji () {
}
Emoji.prototype.onUpdate = function (content) {
return content.replace(reg, ($, $1) => {
if (data[$1]) return data[$1]
return $
})
}
Emoji.prototype.onGetContent = function (content) {
for (const item in data) {
content = content.replace(new RegExp(data[item], 'g'), '[' + item + ']')
}
return content
}
export default Emoji
@@ -0,0 +1,5 @@
export default {
copyByLongPress: true, // 是否需要长按代码块时显示复制代码内容菜单
showLanguageName: true, // 是否在代码块右上角显示语言的名称
showLineNumber: true // 是否显示行号
}
@@ -0,0 +1,96 @@
/**
* @fileoverview highlight 插件
* Include prismjs (https://prismjs.com)
*/
import prism from './prism.min'
import config from './config'
import Parser from '../parser'
function Highlight (vm) {
this.vm = vm
}
Highlight.prototype.onParse = function (node, vm) {
if (node.name === 'pre') {
if (vm.options.editable) {
node.attrs.class = (node.attrs.class || '') + ' hl-pre'
return
}
let i
for (i = node.children.length; i--;) {
if (node.children[i].name === 'code') break
}
if (i === -1) return
const code = node.children[i]
let className = code.attrs.class + ' ' + node.attrs.class
i = className.indexOf('language-')
if (i === -1) {
i = className.indexOf('lang-')
if (i === -1) {
className = 'language-text'
i = 9
} else {
i += 5
}
} else {
i += 9
}
let j
for (j = i; j < className.length; j++) {
if (className[j] === ' ') break
}
const lang = className.substring(i, j)
if (code.children.length) {
const text = this.vm.getText(code.children).replace(/&amp;/g, '&')
if (!text) return
if (node.c) {
node.c = undefined
}
if (prism.languages[lang]) {
code.children = (new Parser(this.vm).parse(
// 加一层 pre 保留空白符
'<pre>' + prism.highlight(text, prism.languages[lang], lang).replace(/token /g, 'hl-') + '</pre>'))[0].children
}
node.attrs.class = 'hl-pre'
code.attrs.class = 'hl-code'
if (config.showLanguageName) {
node.children.push({
name: 'div',
attrs: {
class: 'hl-language',
style: 'user-select:none'
},
children: [{
type: 'text',
text: lang
}]
})
}
if (config.copyByLongPress) {
node.attrs.style += (node.attrs.style || '') + ';user-select:none'
node.attrs['data-content'] = text
vm.expose()
}
if (config.showLineNumber) {
const line = text.split('\n').length; const children = []
for (let k = line; k--;) {
children.push({
name: 'span',
attrs: {
class: 'span'
}
})
}
node.children.push({
name: 'span',
attrs: {
class: 'line-numbers-rows'
},
children
})
}
}
}
}
export default Highlight
@@ -0,0 +1,7 @@
/**
* prism.min.js 类型声明(mp-html highlight 插件依赖的压缩脚本)
* 文件为第三方压缩 JS,不参与类型检查,仅声明模块导出
*/
declare const prism: any
export default prism
File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
const data = {
name: 'imgcache',
prefix: 'imgcache_'
}
function ImgCache (vm) {
this.vm = vm // 保存实例在其他周期使用
this.i = 0 // 用于标记第几张图
vm.imgCache = {
get list () {
return uni
.getStorageInfoSync()
.keys.filter((key) => key.startsWith(data.prefix))
.map((key) => key.split(data.prefix)[1])
},
get (url) {
return uni.getStorageSync(data.prefix + url)
},
delete (url) {
const path = uni.getStorageSync(data.prefix + url)
if (!path) return false
plus.io.resolveLocalFileSystemURL(path, (entry) => {
entry.remove()
})
uni.removeStorageSync(data.prefix + url)
return true
},
async add (url) {
const filename = await download(url)
if (filename) {
uni.setStorageSync(data.prefix + url, filename)
return 'file://' + plus.io.convertLocalFileSystemURL(filename)
}
return null
},
clear () {
uni
.getStorageInfoSync()
.keys.filter((key) => key.startsWith(data.prefix))
.forEach((key) => {
uni.removeStorageSync(key)
})
plus.io.resolveLocalFileSystemURL(`_doc/${data.name}/`, (entry) => {
entry.removeRecursively(
(entry) => {
console.log(`${data.name}缓存删除成功`, entry)
},
(e) => {
console.log(`${data.name}缓存删除失败`, e)
}
)
})
}
}
}
// #ifdef APP-PLUS
ImgCache.prototype.onParse = function (node, parser) {
// 启用本插件 && 解析图片标签 && 拥有src属性 && 是网络图片
if (
this.vm.ImgCache &&
node.name === 'img' &&
node.attrs.src &&
/^https?:\/\//.test(node.attrs.src)
) {
const src = node.attrs.src
node.attrs.src = ''
node.attrs.i = this.vm.imgList.length + this.i++
parser.expose()
async function getUrl (path) {
if (await resolveFile(path)) return path
const filename = await download(src)
filename && uni.setStorageSync(data.prefix + src, filename)
return filename
}
uni.getStorage({
key: data.prefix + src,
success: async (res) => {
const path = await getUrl(res.data)
const url = path
? 'file://' + plus.io.convertLocalFileSystemURL(path)
: src
node.attrs.src = url
this.vm.imgList[node.attrs.i] = path || src
},
fail: async () => {
const path = await getUrl()
const url = path
? 'file://' + plus.io.convertLocalFileSystemURL(path)
: src
node.attrs.src = url
this.vm.imgList[node.attrs.i] = path || src
}
})
}
}
const taskQueue = new Set()
function download (url) {
return new Promise((resolve) => {
if (taskQueue.has(url)) return
taskQueue.add(url)
const suffix = /.+\.(jpg|jpeg|png|bmp|gif|webp)/.exec(url)
const name = `${makeid(8)}_${Date.now()}${suffix ? '.' + suffix[1] : ''}`
const task = plus.downloader.createDownload(
url,
{ filename: `_doc/${data.name}/${name}` },
(download, status) => {
taskQueue.delete(url)
resolve(status === 200 ? download.filename : null)
}
)
task.start()
})
}
// 判断文件存在
function resolveFile (url) {
return new Promise((resolve) => {
plus.io.resolveLocalFileSystemURL(url, resolve, () => resolve(null))
})
}
// 生成uuid
function makeid (length) {
let result = ''
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length))
}
return result
}
// #endif
export default ImgCache
@@ -0,0 +1,34 @@
/**
* @fileoverview markdown 插件
* Include marked (https://github.com/markedjs/marked)
* Include github-markdown-css (https://github.com/sindresorhus/github-markdown-css)
*/
import marked from './marked.min'
let index = 0
function Markdown (vm) {
this.vm = vm
vm._ids = {}
}
Markdown.prototype.onUpdate = function (content) {
if (this.vm.markdown) {
return marked(content)
}
}
Markdown.prototype.onParse = function (node, vm) {
if (vm.options.markdown) {
// 中文 id 需要转换,否则无法跳转
if (vm.options.useAnchor && node.attrs && /[\u4e00-\u9fa5]/.test(node.attrs.id)) {
const id = 't' + index++
this.vm._ids[node.attrs.id] = id
node.attrs.id = id
}
if (node.name === 'p' || node.name === 'table' || node.name === 'tr' || node.name === 'th' || node.name === 'td' || node.name === 'blockquote' || node.name === 'pre' || node.name === 'code') {
node.attrs.class = `md-${node.name} ${node.attrs.class || ''}`
}
}
}
export default Markdown
@@ -0,0 +1,7 @@
/**
* marked.min.js 类型声明(mp-html markdown 插件依赖的压缩脚本)
* 文件为第三方压缩 JS,不参与类型检查,仅声明模块导出
*/
declare const marked: any
export default marked
File diff suppressed because one or more lines are too long
@@ -0,0 +1,579 @@
<template>
<view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="(editable?'min-height:200px;':'')+containerStyle" @tap="_containTap">
<slot v-if="!nodes[0]" />
<!-- #ifndef APP-PLUS-NVUE -->
<node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable,editable,placeholder,'nodes']" name="span" />
<!-- #endif -->
<!-- #ifdef APP-PLUS-NVUE -->
<web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
<!-- #endif -->
<view v-if="tooltip" class="_tooltip_contain" :style="'top:'+tooltip.top+'px'">
<view class="_tooltip">
<view v-for="(item, index) in tooltip.items" v-bind:key="index" class="_tooltip_item" :data-i="index" @tap="_tooltipTap">{{item}}</view>
</view>
</view>
<view v-if="slider" class="_slider" :style="'top:'+slider.top+'px'">
<slider :value="slider.value" :min="slider.min" :max="slider.max" handle-size="14" block-size="14" show-value activeColor="white" style="padding:3px" @changing="_sliderChanging" @change="_sliderChange" />
</view>
</view>
</template>
<script>
/**
* mp-html v2.4.0
* @description 富文本组件
* @tutorial https://github.com/jin-yufeng/mp-html
* @property {String} container-style 容器的样式
* @property {String} content 用于渲染的 html 字符串
* @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
* @property {String} domain 主域名,用于拼接链接
* @property {String} error-img 图片出错时的占位图链接
* @property {Boolean} lazy-load 是否开启图片懒加载
* @property {string} loading-img 图片加载过程中的占位图链接
* @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
* @property {Boolean} preview-img 是否允许图片被点击时自动预览
* @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
* @property {Boolean | String} selectable 是否开启长按复制
* @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
* @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
* @property {Object} tag-style 标签的默认样式
* @property {Boolean | Number} use-anchor 是否使用锚点链接
* @event {Function} load dom 结构加载完毕时触发
* @event {Function} ready 所有图片加载完毕时触发
* @event {Function} imgtap 图片被点击时触发
* @event {Function} linktap 链接被点击时触发
* @event {Function} play 音视频播放时触发
* @event {Function} error 媒体加载出错时触发
*/
// #ifndef APP-PLUS-NVUE
import node from './node/node'
// #endif
import Parser from './parser'
import markdown from './markdown/index.js'
import emoji from './emoji/index.js'
import highlight from './highlight/index.js'
import style from './style/index.js'
import imgCache from './img-cache/index.js'
import editable from './editable/index.js'
const plugins=[markdown,emoji,highlight,style,imgCache,editable,]
// #ifdef APP-PLUS-NVUE
const dom = weex.requireModule('dom')
// #endif
export default {
name: 'mp-html',
data() {
return {
tooltip: null,
slider: null,
nodes: [],
// #ifdef APP-PLUS-NVUE
height: 3
// #endif
}
},
props: {
editable: Boolean,
placeholder: String,
ImgCache: Boolean,
markdown: Boolean,
containerStyle: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
copyLink: {
type: [Boolean, String],
default: true
},
domain: String,
errorImg: {
type: String,
default: ''
},
lazyLoad: {
type: [Boolean, String],
default: false
},
loadingImg: {
type: String,
default: ''
},
pauseVideo: {
type: [Boolean, String],
default: true
},
previewImg: {
type: [Boolean, String],
default: true
},
scrollTable: [Boolean, String],
selectable: [Boolean, String],
setTitle: {
type: [Boolean, String],
default: true
},
showImgMenu: {
type: [Boolean, String],
default: true
},
tagStyle: Object,
useAnchor: [Boolean, Number]
},
// #ifdef VUE3
emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
// #endif
// #ifndef APP-PLUS-NVUE
components: {
node
},
// #endif
watch: {
editable(val) {
this.setContent(val ? this.content : this.getContent())
if (!val)
this._maskTap()
},
content (content) {
this.setContent(content)
}
},
created () {
this.plugins = []
for (let i = plugins.length; i--;) {
this.plugins.push(new plugins[i](this))
}
},
mounted () {
if ((this.content || this.editable) && !this.nodes.length) {
this.setContent(this.content)
}
},
beforeDestroy () {
this._hook('onDetached')
},
methods: {
_containTap() {
if (!this._lock && !this.slider) {
this._edit = undefined
this._maskTap()
}
},
_tooltipTap(e) {
this._tooltipcb(e.currentTarget.dataset.i)
this.$set(this, 'tooltip', null)
},
_sliderChanging(e) {
this._slideringcb(e.detail.value)
},
_sliderChange(e) {
this._slidercb(e.detail.value)
},
/**
* @description 将锚点跳转的范围限定在一个 scroll-view 内
* @param {Object} page scroll-view 所在页面的示例
* @param {String} selector scroll-view 的选择器
* @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
*/
in (page, selector, scrollTop) {
// #ifndef APP-PLUS-NVUE
if (page && selector && scrollTop) {
this._in = {
page,
selector,
scrollTop
}
}
// #endif
},
/**
* @description 锚点跳转
* @param {String} id 要跳转的锚点 id
* @param {Number} offset 跳转位置的偏移量
* @returns {Promise}
*/
navigateTo (id, offset) {
id = this._ids[decodeURI(id)] || id
return new Promise((resolve, reject) => {
if (!this.useAnchor) {
reject(Error('Anchor is disabled'))
return
}
offset = offset || parseInt(this.useAnchor) || 0
// #ifdef APP-PLUS-NVUE
if (!id) {
dom.scrollToElement(this.$refs.web, {
offset
})
resolve()
} else {
this._navigateTo = {
resolve,
reject,
offset
}
this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
}
// #endif
// #ifndef APP-PLUS-NVUE
let deep = ' '
// #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
deep = '>>>'
// #endif
const selector = uni.createSelectorQuery()
// #ifndef MP-ALIPAY
.in(this._in ? this._in.page : this)
// #endif
.select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
if (this._in) {
selector.select(this._in.selector).scrollOffset()
.select(this._in.selector).boundingClientRect()
} else {
// 获取 scroll-view 的位置和滚动距离
selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
}
selector.exec(res => {
if (!res[0]) {
reject(Error('Label not found'))
return
}
const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
if (this._in) {
// scroll-view 跳转
this._in.page[this._in.scrollTop] = scrollTop
} else {
// 页面跳转
uni.pageScrollTo({
scrollTop,
duration: 300
})
}
resolve()
})
// #endif
})
},
/**
* @description 获取文本内容
* @return {String}
*/
getText (nodes) {
let text = '';
(function traversal (nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.type === 'text') {
text += node.text.replace(/&amp;/g, '&')
} else if (node.name === 'br') {
text += '\n'
} else {
// 块级标签前后加换行
const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
if (isBlock && text && text[text.length - 1] !== '\n') {
text += '\n'
}
// 递归获取子节点的文本
if (node.children) {
traversal(node.children)
}
if (isBlock && text[text.length - 1] !== '\n') {
text += '\n'
} else if (node.name === 'td' || node.name === 'th') {
text += '\t'
}
}
}
})(nodes || this.nodes)
return text
},
/**
* @description 获取内容大小和位置
* @return {Promise}
*/
getRect () {
return new Promise((resolve, reject) => {
uni.createSelectorQuery()
// #ifndef MP-ALIPAY
.in(this)
// #endif
.select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
})
},
/**
* @description 暂停播放媒体
*/
pauseMedia () {
for (let i = (this._videos || []).length; i--;) {
this._videos[i].pause()
}
// #ifdef APP-PLUS
const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
// #ifndef APP-PLUS-NVUE
let page = this.$parent
while (!page.$scope) page = page.$parent
page.$scope.$getAppWebview().evalJS(command)
// #endif
// #ifdef APP-PLUS-NVUE
this.$refs.web.evalJs(command)
// #endif
// #endif
},
/**
* @description 设置媒体播放速率
* @param {Number} rate 播放速率
*/
setPlaybackRate (rate) {
this.playbackRate = rate
for (let i = (this._videos || []).length; i--;) {
this._videos[i].playbackRate(rate)
}
// #ifdef APP-PLUS
const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
// #ifndef APP-PLUS-NVUE
let page = this.$parent
while (!page.$scope) page = page.$parent
page.$scope.$getAppWebview().evalJS(command)
// #endif
// #ifdef APP-PLUS-NVUE
this.$refs.web.evalJs(command)
// #endif
// #endif
},
/**
* @description 设置内容
* @param {String} content html 内容
* @param {Boolean} append 是否在尾部追加
*/
setContent (content, append) {
if (!append || !this.imgList) {
this.imgList = []
}
const nodes = new Parser(this).parse(content)
// #ifdef APP-PLUS-NVUE
if (this._ready) {
this._set(nodes, append)
}
// #endif
this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
// #ifndef APP-PLUS-NVUE
this._videos = []
this.$nextTick(() => {
this._hook('onLoad')
this.$emit('load')
})
if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
// 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
let height
const callback = rect => {
// 350ms 总高度无变化就触发 ready 事件
if (rect.height === height) {
this.$emit('ready', rect)
} else {
height = rect.height
setTimeout(() => {
this.getRect().then(callback)
}, 350)
}
}
this.getRect().then(callback)
} else {
// 未设置懒加载,等待所有图片加载完毕
if (!this.imgList._unloadimgs) {
this.getRect(rect => {
this.$emit('ready', rect)
})
}
}
// #endif
},
/**
* @description 调用插件钩子函数
*/
_hook (name) {
for (let i = plugins.length; i--;) {
if (this.plugins[i][name]) {
this.plugins[i][name]()
}
}
},
// #ifdef APP-PLUS-NVUE
/**
* @description 设置内容
*/
_set (nodes, append) {
this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes) + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
},
/**
* @description 接收到 web-view 消息
*/
_onMessage (e) {
const message = e.detail.data[0]
switch (message.action) {
// web-view 初始化完毕
case 'onJSBridgeReady':
this._ready = true
if (this.nodes) {
this._set(this.nodes)
}
break
// 内容 dom 加载完毕
case 'onLoad':
this.height = message.height
this._hook('onLoad')
this.$emit('load')
break
// 所有图片加载完毕
case 'onReady':
this.getRect().then(res => {
this.$emit('ready', res)
}).catch(() => { })
break
// 总高度发生变化
case 'onHeightChange':
this.height = message.height
break
// 图片点击
case 'onImgTap':
this.$emit('imgtap', message.attrs)
if (this.previewImg) {
uni.previewImage({
current: parseInt(message.attrs.i),
urls: this.imgList
})
}
break
// 链接点击
case 'onLinkTap': {
const href = message.attrs.href
this.$emit('linktap', message.attrs)
if (href) {
// 锚点跳转
if (href[0] === '#') {
if (this.useAnchor) {
dom.scrollToElement(this.$refs.web, {
offset: message.offset
})
}
} else if (href.includes('://')) {
// 打开外链
if (this.copyLink) {
plus.runtime.openWeb(href)
}
} else {
uni.navigateTo({
url: href,
fail () {
uni.switchTab({
url: href
})
}
})
}
}
break
}
case 'onPlay':
this.$emit('play')
break
// 获取到锚点的偏移量
case 'getOffset':
if (typeof message.offset === 'number') {
dom.scrollToElement(this.$refs.web, {
offset: message.offset + this._navigateTo.offset
})
this._navigateTo.resolve()
} else {
this._navigateTo.reject(Error('Label not found'))
}
break
// 点击
case 'onClick':
this.$emit('tap')
this.$emit('click')
break
// 出错
case 'onError':
this.$emit('error', {
source: message.source,
attrs: message.attrs
})
}
}
// #endif
}
}
</script>
<style>
/* #ifndef APP-PLUS-NVUE */
/* 根节点样式 */
._root {
padding: 1px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
/* 长按复制 */
._select {
user-select: text;
}
/* #endif */
/* 提示条 */
._tooltip_contain {
position: absolute;
right: 20px;
left: 20px;
text-align: center;
}
._tooltip {
box-sizing: border-box;
display: inline-block;
width: auto;
max-width: 100%;
height: 30px;
padding: 0 3px;
overflow: scroll;
font-size: 14px;
line-height: 30px;
white-space: nowrap;
}
._tooltip_item {
display: inline-block;
width: auto;
padding: 0 2vw;
line-height: 30px;
background-color: black;
color: white;
}
/* 图片宽度滚动条 */
._slider {
position: absolute;
left: 20px;
width: 220px;
}
._tooltip,
._slider {
background-color: black;
border-radius: 3px;
opacity: 0.75;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
/**
* @fileoverview style 插件
*/
// #ifndef APP-PLUS-NVUE
import Parser from './parser'
// #endif
function Style () {
this.styles = []
}
// #ifndef APP-PLUS-NVUE
Style.prototype.onParse = function (node, vm) {
// 获取样式
if (node.name === 'style' && node.children.length && node.children[0].type === 'text') {
this.styles = this.styles.concat(new Parser().parse(node.children[0].text))
} else if (node.name) {
// 匹配样式(对非文本标签)
// 存储不同优先级的样式 name < class < id < 后代
let matched = ['', '', '', '']
for (let i = 0, len = this.styles.length; i < len; i++) {
const item = this.styles[i]
let res = match(node, item.key || item.list[item.list.length - 1])
let j
if (res) {
// 后代选择器
if (!item.key) {
j = item.list.length - 2
for (let k = vm.stack.length; j >= 0 && k--;) {
// 子选择器
if (item.list[j] === '>') {
// 错误情况
if (j < 1 || j > item.list.length - 2) break
if (match(vm.stack[k], item.list[j - 1])) {
j -= 2
} else {
j++
}
} else if (match(vm.stack[k], item.list[j])) {
j--
}
}
res = 4
}
if (item.key || j < 0) {
// 添加伪类
if (item.pseudo && node.children) {
let text
item.style = item.style.replace(/content:([^;]+)/, (_, $1) => {
text = $1.replace(/['"]/g, '')
// 处理 attr 函数
.replace(/attr\((.+?)\)/, (_, $1) => node.attrs[$1.trim()] || '')
// 编码 \xxx
.replace(/\\(\w{4})/, (_, $1) => String.fromCharCode(parseInt($1, 16)))
return ''
})
const pseudo = {
name: 'span',
attrs: {
style: item.style
},
children: [{
type: 'text',
text
}]
}
if (item.pseudo === 'before') {
node.children.unshift(pseudo)
} else {
node.children.push(pseudo)
}
} else {
matched[res - 1] += item.style + (item.style[item.style.length - 1] === ';' ? '' : ';')
}
}
}
}
matched = matched.join('')
if (matched.length > 2) {
node.attrs.style = matched + (node.attrs.style || '')
}
}
}
/**
* @description 匹配样式
* @param {object} node 要匹配的标签
* @param {string|string[]} keys 选择器
* @returns {number} 0:不匹配;1name 匹配;2class 匹配;3id 匹配
*/
function match (node, keys) {
function matchItem (key) {
if (key[0] === '#') {
// 匹配 id
if (node.attrs.id && node.attrs.id.trim() === key.substr(1)) return 3
} else if (key[0] === '.') {
// 匹配 class
key = key.substr(1)
const selectors = (node.attrs.class || '').split(' ')
for (let i = 0; i < selectors.length; i++) {
if (selectors[i].trim() === key) return 2
}
} else if (node.name === key) {
// 匹配 name
return 1
}
return 0
}
// 多选择器交集
if (keys instanceof Array) {
let res = 0
for (let j = 0; j < keys.length; j++) {
const tmp = matchItem(keys[j])
// 任意一个不匹配就失败
if (!tmp) return 0
// 优先级最大的一个作为最终优先级
if (tmp > res) {
res = tmp
}
}
return res
}
return matchItem(keys)
}
// #endif
export default Style
@@ -0,0 +1,175 @@
const blank = {
' ': true,
'\n': true,
'\t': true,
'\r': true,
'\f': true
}
function Parser () {
this.styles = []
this.selectors = []
}
/**
* @description 解析 css 字符串
* @param {string} content css 内容
*/
Parser.prototype.parse = function (content) {
new Lexer(this).parse(content)
return this.styles
}
/**
* @description 解析到一个选择器
* @param {string} name 名称
*/
Parser.prototype.onSelector = function (name) {
// 不支持的选择器
if (name.includes('[') || name.includes('*') || name.includes('@')) return
const selector = {}
// 伪类
if (name.includes(':')) {
const info = name.split(':')
const pseudo = info.pop()
if (pseudo === 'before' || pseudo === 'after') {
selector.pseudo = pseudo
name = info[0]
} else return
}
// 分割交集选择器
function splitItem (str) {
const arr = []
let i, start
for (i = 1, start = 0; i < str.length; i++) {
if (str[i] === '.' || str[i] === '#') {
arr.push(str.substring(start, i))
start = i
}
}
if (!arr.length) {
return str
} else {
arr.push(str.substring(start, i))
return arr
}
}
// 后代选择器
if (name.includes(' ')) {
selector.list = []
const list = name.split(' ')
for (let i = 0; i < list.length; i++) {
if (list[i].length) {
// 拆分子选择器
const arr = list[i].split('>')
for (let j = 0; j < arr.length; j++) {
selector.list.push(splitItem(arr[j]))
if (j < arr.length - 1) {
selector.list.push('>')
}
}
}
}
} else {
selector.key = splitItem(name)
}
this.selectors.push(selector)
}
/**
* @description 解析到选择器内容
* @param {string} content 内容
*/
Parser.prototype.onContent = function (content) {
// 并集选择器
for (let i = 0; i < this.selectors.length; i++) {
this.selectors[i].style = content
}
this.styles = this.styles.concat(this.selectors)
this.selectors = []
}
/**
* @description css 词法分析器
* @param {object} handler 高层处理器
*/
function Lexer (handler) {
this.selector = ''
this.style = ''
this.handler = handler
}
Lexer.prototype.parse = function (content) {
this.i = 0
this.content = content
this.state = this.blank
for (let len = content.length; this.i < len; this.i++) {
this.state(content[this.i])
}
}
Lexer.prototype.comment = function () {
this.i = this.content.indexOf('*/', this.i) + 1
if (!this.i) {
this.i = this.content.length
}
}
Lexer.prototype.blank = function (c) {
if (!blank[c]) {
if (c === '/' && this.content[this.i + 1] === '*') {
this.comment()
return
}
this.selector += c
this.state = this.name
}
}
Lexer.prototype.name = function (c) {
if (c === '/' && this.content[this.i + 1] === '*') {
this.comment()
return
}
if (c === '{' || c === ',' || c === ';') {
this.handler.onSelector(this.selector.trimEnd())
this.selector = ''
if (c !== '{') {
while (blank[this.content[++this.i]]);
}
if (this.content[this.i] === '{') {
this.floor = 1
this.state = this.val
} else {
this.selector += this.content[this.i]
}
} else if (blank[c]) {
this.selector += ' '
} else {
this.selector += c
}
}
Lexer.prototype.val = function (c) {
if (c === '/' && this.content[this.i + 1] === '*') {
this.comment()
return
}
if (c === '{') {
this.floor++
} else if (c === '}') {
this.floor--
if (!this.floor) {
this.handler.onContent(this.style)
this.style = ''
this.state = this.blank
return
}
}
this.style += c
}
export default Parser
@@ -0,0 +1 @@
"use strict";function t(t){for(var e=Object.create(null),n=t.attributes.length;n--;)e[t.attributes[n].name]=t.attributes[n].value;return e}function e(){a[1]&&(this.src=a[1],this.onerror=null),this.onclick=null,this.ontouchstart=null,uni.postMessage({data:{action:"onError",source:"img",attrs:t(this)}})}function n(){window.unloadimgs-=1,0===window.unloadimgs&&uni.postMessage({data:{action:"onReady"}})}function o(r,s,c){for(var d=0;d<r.length;d++)!function(d){var u=r[d],l=void 0;if(u.type&&"node"!==u.type)l=document.createTextNode(u.text.replace(/&amp;/g,"&"));else{var g=u.name;"svg"===g&&(c="http://www.w3.org/2000/svg"),"html"!==g&&"body"!==g||(g="div"),l=c?document.createElementNS(c,g):document.createElement(g);for(var p in u.attrs)l.setAttribute(p,u.attrs[p]);if(u.children&&o(u.children,l,c),"img"===g){if(window.unloadimgs+=1,l.onload=n,l.onerror=n,!l.src&&l.getAttribute("data-src")&&(l.src=l.getAttribute("data-src")),u.attrs.ignore||(l.onclick=function(e){e.stopPropagation(),uni.postMessage({data:{action:"onImgTap",attrs:t(this)}})}),a[2]){var h=new Image;h.src=l.src,l.src=a[2],h.onload=function(){l.src=this.src},h.onerror=function(){l.onerror()}}l.onerror=e}else if("a"===g)l.addEventListener("click",function(e){e.stopPropagation(),e.preventDefault();var n,o=this.getAttribute("href");o&&"#"===o[0]&&(n=(document.getElementById(o.substr(1))||{}).offsetTop),uni.postMessage({data:{action:"onLinkTap",attrs:t(this),offset:n}})},!0);else if("video"===g||"audio"===g)i.push(l),u.attrs.autoplay||u.attrs.controls||l.setAttribute("controls","true"),l.onplay=function(){if(uni.postMessage({data:{action:"onPlay"}}),a[3])for(var t=0;t<i.length;t++)i[t]!==this&&i[t].pause()},l.onerror=function(){uni.postMessage({data:{action:"onError",source:g,attrs:t(this)}})};else if("table"===g&&a[4]&&!l.style.cssText.includes("inline")){var f=document.createElement("div");f.style.overflow="auto",f.appendChild(l),l=f}else"svg"===g&&(c=void 0)}s.appendChild(l)}(d)}document.addEventListener("UniAppJSBridgeReady",function(){document.body.onclick=function(){return uni.postMessage({data:{action:"onClick"}})},uni.postMessage({data:{action:"onJSBridgeReady"}})});var a,i=[];window.setContent=function(t,e,n){var r=document.getElementById("content");e[0]&&(document.body.style.cssText=e[0]),e[5]||(r.style.userSelect="none"),n||(r.innerHTML="",i=[]),a=e,window.unloadimgs=0;var s=document.createDocumentFragment();o(t,s),r.appendChild(s);var c=r.scrollHeight;uni.postMessage({data:{action:"onLoad",height:c}}),window.unloadimgs||uni.postMessage({data:{action:"onReady",height:c}}),clearInterval(window.timer),window.timer=setInterval(function(){r.scrollHeight!==c&&(c=r.scrollHeight,uni.postMessage({data:{action:"onHeightChange",height:c}}))},350)},window.onunload=function(){clearInterval(window.timer)};
@@ -0,0 +1 @@
!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).uni=n()}(this,(function(){"use strict";try{var e={};Object.defineProperty(e,"passive",{get:function(){!0}}),window.addEventListener("test-passive",null,e)}catch(e){}var n=Object.prototype.hasOwnProperty;function t(e,t){return n.call(e,t)}var i=[],a=function(e,n){var t={options:{timestamp:+new Date},name:e,arg:n};if(window.__dcloud_weex_postMessage||window.__dcloud_weex_){if("postMessage"===e){var a={data:[n]};return window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessage(a):window.__dcloud_weex_.postMessage(JSON.stringify(a))}var o={type:"WEB_INVOKE_APPSERVICE",args:{data:t,webviewIds:i}};window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessageToService(o):window.__dcloud_weex_.postMessageToService(JSON.stringify(o))}if(!window.plus)return window.parent.postMessage({type:"WEB_INVOKE_APPSERVICE",data:t,pageId:""},"*");if(0===i.length){var r=plus.webview.currentWebview();if(!r)throw new Error("plus.webview.currentWebview() is undefined");var d=r.parent(),s="";s=d?d.id:r.id,i.push(s)}if(plus.webview.getWebviewById("__uniapp__service"))plus.webview.postMessageToUniNView({type:"WEB_INVOKE_APPSERVICE",args:{data:t,webviewIds:i}},"__uniapp__service");else{var w=JSON.stringify(t);plus.webview.getLaunchWebview().evalJS('UniPlusBridge.subscribeHandler("'.concat("WEB_INVOKE_APPSERVICE",'",').concat(w,",").concat(JSON.stringify(i),");"))}},o={navigateTo:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("navigateTo",{url:encodeURI(n)})},navigateBack:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.delta;a("navigateBack",{delta:parseInt(n)||1})},switchTab:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("switchTab",{url:encodeURI(n)})},reLaunch:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("reLaunch",{url:encodeURI(n)})},redirectTo:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("redirectTo",{url:encodeURI(n)})},getEnv:function(e){window.plus?e({plus:!0}):e({h5:!0})},postMessage:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};a("postMessage",e.data||{})}},r=/uni-app/i.test(navigator.userAgent),d=/Html5Plus/i.test(navigator.userAgent),s=/complete|loaded|interactive/;var w=window.my&&navigator.userAgent.indexOf("AlipayClient")>-1;var u=window.swan&&window.swan.webView&&/swan/i.test(navigator.userAgent);var c=window.qq&&window.qq.miniProgram&&/QQ/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var g=window.tt&&window.tt.miniProgram&&/toutiaomicroapp/i.test(navigator.userAgent);var v=window.wx&&window.wx.miniProgram&&/micromessenger/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var p=window.qa&&/quickapp/i.test(navigator.userAgent);for(var l,_=function(){window.UniAppJSBridge=!0,document.dispatchEvent(new CustomEvent("UniAppJSBridgeReady",{bubbles:!0,cancelable:!0}))},f=[function(e){if(r||d)return window.__dcloud_weex_postMessage||window.__dcloud_weex_?document.addEventListener("DOMContentLoaded",e):window.plus&&s.test(document.readyState)?setTimeout(e,0):document.addEventListener("plusready",e),o},function(e){if(v)return window.WeixinJSBridge&&window.WeixinJSBridge.invoke?setTimeout(e,0):document.addEventListener("WeixinJSBridgeReady",e),window.wx.miniProgram},function(e){if(c)return window.QQJSBridge&&window.QQJSBridge.invoke?setTimeout(e,0):document.addEventListener("QQJSBridgeReady",e),window.qq.miniProgram},function(e){if(w){document.addEventListener("DOMContentLoaded",e);var n=window.my;return{navigateTo:n.navigateTo,navigateBack:n.navigateBack,switchTab:n.switchTab,reLaunch:n.reLaunch,redirectTo:n.redirectTo,postMessage:n.postMessage,getEnv:n.getEnv}}},function(e){if(u)return document.addEventListener("DOMContentLoaded",e),window.swan.webView},function(e){if(g)return document.addEventListener("DOMContentLoaded",e),window.tt.miniProgram},function(e){if(p){window.QaJSBridge&&window.QaJSBridge.invoke?setTimeout(e,0):document.addEventListener("QaJSBridgeReady",e);var n=window.qa;return{navigateTo:n.navigateTo,navigateBack:n.navigateBack,switchTab:n.switchTab,reLaunch:n.reLaunch,redirectTo:n.redirectTo,postMessage:n.postMessage,getEnv:n.getEnv}}},function(e){return document.addEventListener("DOMContentLoaded",e),o}],m=0;m<f.length&&!(l=f[m](_));m++);l||(l={});var E="undefined"!=typeof uni?uni:{};if(!E.navigateTo)for(var b in l)t(l,b)&&(E[b]=l[b]);return E.webView=l,E}));
@@ -0,0 +1 @@
<style>.hl-code,.hl-pre{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}.hl-pre{padding:1em;margin:.5em 0;overflow:auto}.hl-pre{background:#2d2d2d}.hl-block-comment,.hl-cdata,.hl-comment,.hl-doctype,.hl-prolog{color:#999}.hl-punctuation{color:#ccc}.hl-attr-name,.hl-deleted,.hl-namespace,.hl-tag{color:#e2777a}.hl-function-name{color:#6196cc}.hl-boolean,.hl-function,.hl-number{color:#f08d49}.hl-class-name,.hl-constant,.hl-property,.hl-symbol{color:#f8c555}.hl-atrule,.hl-builtin,.hl-important,.hl-keyword,.hl-selector{color:#cc99cd}.hl-attr-value,.hl-char,.hl-regex,.hl-string,.hl-variable{color:#7ec699}.hl-entity,.hl-operator,.hl-url{color:#67cdcc}.hl-bold,.hl-important{font-weight:700}.hl-italic{font-style:italic}.hl-entity{cursor:help}.hl-inserted{color:green}</style><style>.md-p{margin-block-start:1em;margin-block-end:1em}.md-blockquote,.md-table{margin-bottom:16px}.md-table{box-sizing:border-box;width:100%;overflow:auto;border-spacing:0;border-collapse:collapse}.md-tr{background-color:#fff;border-top:1px solid #c6cbd1}.md-table .md-tr:nth-child(2n){background-color:#f6f8fa}.md-td,.md-th{padding:6px 13px!important;border:1px solid #dfe2e5}.md-th{font-weight:600}.md-blockquote{padding:0 1em;color:#6a737d;border-left:.25em solid #dfe2e5}.md-code{padding:.2em .4em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:85%;background-color:rgba(27,31,35,.05);border-radius:3px}.md-pre .md-code{padding:0;font-size:100%;background:0 0;border:0}.hl-pre{position:relative}.hl-code{overflow:auto;display:block}.hl-language{font-size:12px;font-weight:600;position:absolute;right:8px;text-align:right;top:3px}.hl-pre{padding-top:1.5em}.hl-pre{font-size:14px;padding-left:3.8em;counter-reset:linenumber}.line-numbers-rows{position:absolute;pointer-events:none;top:1.5em;font-size:100%;left:0;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows .span{display:block;counter-increment:linenumber}.line-numbers-rows .span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}._address,._article,._aside,._body,._caption,._center,._cite,._footer,._header,._html,._nav,._pre,._section{display:block}._video{width:300px;height:225px;display:inline-block;background-color:#000}</style><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><style>body,html{width:100%;height:100%;overflow-x:scroll;overflow-y:hidden}body{margin:0}video{width:300px;height:225px}img{max-width:100%;-webkit-touch-callout:none}</style></head><body><div id="content" style="overflow:hidden"></div><script type="text/javascript" src="./js/uni.webview.min.js"></script><script type="text/javascript" src="./js/handler.js"></script></body>
@@ -0,0 +1,166 @@
<script lang="ts" setup>
/**
* 相册图片查看弹窗(源自旧项目 components/album-photo-viewer,新建复刻)
* 双列瀑布流展示相册照片,支持大图预览
*/
import { computed, ref, watch } from 'vue'
import { checkImageUrl } from '@/utils/url'
const props = withDefaults(defineProps<{
show: boolean
albumName?: string
photos?: IAlbumPhoto[]
loading?: boolean
}>(), {
albumName: '',
photos: () => [],
loading: false,
})
const emit = defineEmits<{
(e: 'update:show', show: boolean): void
}>()
export interface IAlbumPhoto {
name?: string
url?: string
title?: string
takenDate?: string
location?: string
description?: string
[key: string]: unknown
}
const isShow = ref(false)
watch(() => props.show, (val) => {
isShow.value = val
})
/** 预处理图片路径(相对路径拼接 BASE_API) */
const photoList = computed<IAlbumPhoto[]>(() =>
(props.photos || []).map(photo => ({
...photo,
url: checkImageUrl(photo.url || ''),
})),
)
/** 左列(偶数位照片,瀑布流错落) */
const leftPhotos = computed(() => photoList.value.filter((_, index) => index % 2 === 0))
/** 右列(奇数位照片) */
const rightPhotos = computed(() => photoList.value.filter((_, index) => index % 2 === 1))
function handleClose() {
isShow.value = false
emit('update:show', false)
}
/** 预览大图 */
function handlePreview(url?: string) {
const urls = photoList.value.map(photo => photo.url || '')
if (urls.length === 0) {
uni.showToast({ title: '相册暂无照片', icon: 'none' })
return
}
uni.previewImage({
current: url || urls[0],
urls,
})
}
</script>
<template>
<wd-popup v-model="isShow" position="center" custom-style="width:94vw;height:82vh;border-radius:12rpx;" @close="handleClose">
<view class="album-photo-viewer h-full w-full flex flex-col overflow-hidden rounded-xl bg-white">
<!-- 头部 -->
<view class="viewer-header box-border flex shrink-0 items-center justify-between border-b border-black/5 px-7 py-6">
<text class="viewer-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[32rpx] text-[#333] font-bold">{{ albumName }}</text>
<view class="viewer-close h-14 w-14 flex shrink-0 items-center justify-center rounded-full bg-black/5" @click="handleClose">
<wd-icon name="close" size="16px" color="#666" />
</view>
</view>
<!-- 照片列表 -->
<scroll-view class="viewer-body box-border min-h-0 flex-1" scroll-y :show-scrollbar="false">
<view v-if="loading" class="viewer-empty box-border h-full flex items-center justify-center p-10">
<view class="viewer-loading flex flex-col items-center">
<view class="loading-text mt-7 text-[28rpx] text-[#56bbf9]">
照片正在努力加载中啦~
</view>
</view>
</view>
<view v-else-if="photoList.length === 0" class="viewer-empty box-border h-full flex items-center justify-center p-10">
<wd-empty description="这个相册暂时还没有照片~" />
</view>
<view v-else class="photo-list box-border flex items-start p-5">
<!-- 左列 -->
<view class="photo-column box-border min-w-0 flex-1">
<view v-for="photo in leftPhotos" :key="photo.name" class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm">
<image
class="photo-image w-full"
:src="photo.url"
mode="widthFix"
lazy-load
@click="handlePreview(photo.url)"
/>
<view class="photo-info box-border px-6 py-5">
<view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold">
{{ photo.title }}
</view>
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center">
<text v-if="photo.takenDate" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text>
<text v-if="photo.location" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text>
</view>
<view v-if="photo.description" class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
{{ photo.description }}
</view>
</view>
</view>
</view>
<!-- 右列 -->
<view class="photo-column box-border min-w-0 flex-1">
<view v-for="photo in rightPhotos" :key="photo.name" class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm">
<image
class="photo-image w-full"
:src="photo.url"
mode="widthFix"
lazy-load
@click="handlePreview(photo.url)"
/>
<view class="photo-info box-border px-6 py-5">
<view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold">
{{ photo.title }}
</view>
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center">
<text v-if="photo.takenDate" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text>
<text v-if="photo.location" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text>
</view>
<view v-if="photo.description" class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
{{ photo.description }}
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部关闭 -->
<view class="viewer-footer box-border shrink-0 border-t border-black/5 px-7 py-5">
<view class="viewer-footer-btn h-20 flex items-center justify-center rounded-[40rpx]" style="background: linear-gradient(135deg, #f88ca2, #ff6b9d); box-shadow: 0 4rpx 24rpx rgb(248 140 162 / 35%);" @click="handleClose">
<text class="footer-text text-[30rpx] text-white font-bold"> </text>
</view>
</view>
</view>
</wd-popup>
</template>
<style scoped>
.album-photo-viewer {
/* 布局全部由 UnoCSS 原子类实现 */
.photo-column:first-child {
margin-right: 20rpx;
}
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts" setup>
/**
* 相册密码解锁弹窗(源自旧项目 components/album-unlock-modal,新建复刻)
* 输入密码解锁加密相册
*/
import { ref, watch } from 'vue'
import { unlockAlbum } from '@/api/uni-halo'
const props = withDefaults(defineProps<{
show: boolean
albumName?: string
albumKey?: string
}>(), {
albumName: '',
albumKey: '',
})
const emit = defineEmits<{
(e: 'update:show', show: boolean): void
(e: 'success', data: { albumKey: string, token: string, photos: unknown[] }): void
}>()
const isShow = ref(false)
const password = ref('')
const loading = ref(false)
watch(() => props.show, (val) => {
isShow.value = val
})
function handleOnCancel() {
password.value = ''
isShow.value = false
emit('update:show', false)
}
async function handleOnConfirm() {
if (!password.value.trim()) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
loading.value = true
try {
const res = await unlockAlbum(props.albumKey, password.value)
if (res.data && (res.data as { token?: string }).token) {
password.value = ''
isShow.value = false
emit('update:show', false)
emit('success', {
albumKey: props.albumKey,
token: (res.data as { token: string }).token,
photos: (res.data as { photos?: unknown[] }).photos || [],
})
uni.showToast({ title: '解锁成功', icon: 'success' })
}
}
catch (e) {
console.error('解锁失败', e)
password.value = ''
uni.showToast({ title: '密码错误,请重试', icon: 'none' })
}
finally {
loading.value = false
}
}
</script>
<template>
<wd-dialog
v-model="isShow"
title="相册密码"
:show-cancel="true"
confirm-text="解锁"
confirm-button-color="#f88ca2"
@cancel="handleOnCancel"
@confirm="handleOnConfirm"
>
<view class="unlock-modal-content py-5">
<view class="album-info flex flex-col items-center">
<view class="lock-icon mb-5 text-[80rpx]">
🔒
</view>
<view class="album-name mb-3 text-[32rpx] text-[#333] font-bold">
{{ albumName }}
</view>
<view class="tip-text text-[26rpx] text-[#999]">
此相册已加密请输入密码查看
</view>
</view>
<wd-input
v-model="password"
:password="true"
placeholder="请输入相册密码"
align="center"
clearable
class="password-input mt-9"
/>
</view>
</wd-dialog>
</template>
<style scoped>
.unlock-modal-content {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
@@ -0,0 +1,339 @@
<script lang="ts" setup>
/**
* 文章卡片(源自旧项目 components/article-card,新建复刻)
* 支持布局:左图右文(lr_image_text)/左文右图(lr_text_image)/上图下文(tb_image_text)/上文下图(tb_text_image)/仅文字(only_text)
*/
import { computed } from 'vue'
import dayjs from 'dayjs'
import { checkThumbnailUrl } from '@/utils/url'
import { useSettingStore } from '@/store/setting'
import type { IPost } from '@/api/types/halo'
const props = withDefaults(defineProps<{
/** 来源页面(home 时跟随首页布局) */
from?: string
article: IPost
}>(), {
from: '',
})
const emit = defineEmits<{
(e: 'on-click', article: IPost): void
}>()
const settingStore = useSettingStore()
/** 卡片布局 class(由全局设置 layout 决定) */
const cardType = computed(() => {
const layout = settingStore.settings.layout
// 首页双列时强制上图下文布局,除非显式指定其他
if (props.from === 'home' && layout.home === 'h_row_col2') {
if (!['tb_image_text', 'tb_text_image', 'only_text'].includes(layout.cardType)) {
return [props.from, layout.home, 'tb_image_text']
}
return [props.from, layout.home, layout.cardType]
}
return [layout.home, layout.cardType]
})
/** 发布时间格式化 yyyy-MM-dd */
const publishTimeText = computed(() => {
const time = props.article.spec.publishTime
return time ? dayjs(time).format('YYYY-MM-DD') : ''
})
/** 阅读数(兼容 status.stats.visits 与旧版顶层 stats.visit) */
const visitCount = computed(() => {
return props.article.status?.stats?.visits ?? props.article.stats?.visit ?? 0
})
function handleClick() {
emit('on-click', props.article)
}
</script>
<template>
<view class="uh-article-card mb-3 flex overflow-hidden rounded-xl bg-white p-2.5 shadow-sm" :class="cardType" @click="handleClick">
<view class="left">
<image class="thumbnail" :src="checkThumbnailUrl(article.spec.cover)" mode="aspectFill" lazy-load />
</view>
<view class="right">
<view class="title">
<text v-if="article.spec.pinned" class="is-top">置顶</text>
<text class="title-text text-overflow">{{ article.spec.title }}</text>
</view>
<view class="content text-overflow-2">
{{ article.status?.excerpt }}
</view>
<view class="foot">
<view class="create-time">
<text class="time-label">发布时间</text>
{{ publishTimeText }}
</view>
<view class="visits">
浏览
<text class="number">{{ visitCount }}</text>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-article-card {
box-sizing: border-box;
margin: 0 24rpx;
&.h_row_col1 {
align-items: center;
}
&.home.h_row_col2 {
margin: 12rpx;
.left {
width: 100%;
height: 200rpx;
.thumbnail :deep(uni-image) {
border-radius: 6rpx 6rpx 0 0 !important;
}
}
.right {
.title {
display: flex;
align-items: center;
font-size: 26rpx;
font-weight: bold;
.is-top {
height: 36rpx;
margin-right: 10rpx;
line-height: 36rpx;
transform: scale(0.9);
}
}
.foot {
justify-content: space-between;
.create-time {
font-size: 24rpx;
.time-label {
display: none;
}
}
.visits {
font-size: 24rpx;
margin-left: 0;
}
}
}
&.tb_text_image {
padding: 12rpx;
.left .thumbnail :deep(uni-image) {
border-radius: 6rpx !important;
}
}
&.only_text {
padding: 24rpx;
.right .foot {
.create-time .time-label {
display: none;
}
.visits {
font-size: 24rpx;
}
}
}
}
&.lr_text_image {
.left {
order: 2;
padding-left: 30rpx;
}
.right {
order: 1;
padding-left: 0;
}
}
&.tb_image_text {
flex-direction: column;
padding: 24rpx;
.left {
width: 100%;
height: 340rpx;
.thumbnail :deep(uni-image) {
border-radius: 6rpx 6rpx 0 0 !important;
}
}
.right {
padding-left: 0;
padding: 24rpx 0 0;
width: 100%;
.foot {
justify-content: flex-start;
.create-time .time-label {
display: inline-block;
}
.visits {
margin-left: 24rpx;
}
}
}
}
&.tb_text_image {
flex-direction: column;
.left {
width: 100%;
height: 340rpx;
order: 2;
margin-top: 24rpx;
}
.right {
padding-left: 0;
width: 100%;
order: 1;
.foot {
justify-content: flex-start;
.create-time .time-label {
display: inline-block;
}
.visits {
margin-left: 24rpx;
}
}
}
}
&.only_text {
padding: 36rpx;
.left {
display: none;
}
.right {
padding-left: 0;
.content {
margin-top: 24rpx;
}
.foot {
justify-content: flex-start;
margin-top: 24rpx;
.create-time .time-label {
display: inline-block;
}
.visits {
margin-left: 24rpx;
}
}
}
}
.left {
width: 240rpx;
height: 180rpx;
.thumbnail {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
}
.right {
width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
padding-left: 30rpx;
box-sizing: border-box;
.title {
display: flex;
align-items: center;
font-size: 30rpx;
.is-top {
height: 40rpx;
padding: 0 12rpx;
margin-right: 10rpx;
line-height: 40rpx;
font-size: 24rpx;
white-space: nowrap;
vertical-align: 4rpx;
color: #fff;
background-color: rgba(33, 150, 243, 1);
border-radius: 6rpx 12rpx;
}
&-text {
color: #303133;
}
}
.content {
display: -webkit-box;
font-size: 26rpx;
color: #909399;
height: 80rpx;
margin-top: 14rpx;
line-height: 42rpx;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.foot {
display: flex;
font-size: 24rpx;
justify-content: space-between;
align-items: center;
color: #909399;
margin-top: 18rpx;
.create-time {
font-size: 26rpx;
.time-label {
display: none;
}
}
.visits .number {
padding: 0 6rpx;
font-size: 26rpx;
}
}
}
}
</style>
@@ -0,0 +1,118 @@
<script lang="ts" setup>
/**
* 文章迷你卡片(源自旧项目 components/article-min-card,新建复刻)
* 用于分类页 list-post 视图的文章列表
*/
import { computed } from 'vue'
import dayjs from 'dayjs'
import { checkThumbnailUrl } from '@/utils/url'
import type { IPost } from '@/api/types/halo'
const props = defineProps<{
article: IPost
}>()
const emit = defineEmits<{
(e: 'on-click', article: IPost): void
}>()
const publishTimeText = computed(() => {
const time = props.article.spec.publishTime
return time ? dayjs(time).format('YYYY-MM-DD') : ''
})
const visitCount = computed(() => {
return props.article.status?.stats?.visits ?? props.article.stats?.visit ?? 0
})
function handleClick() {
emit('on-click', props.article)
}
</script>
<template>
<view class="uh-article-min-card mx-6 mb-6 flex overflow-hidden rounded-xl bg-white p-2 shadow-sm" @click="handleClick">
<view class="left">
<image class="thumbnail" :src="checkThumbnailUrl(article.spec.cover)" mode="aspectFill" lazy-load />
</view>
<view class="right">
<view class="title text-overflow">
{{ article.spec.title }}
</view>
<view class="content text-overflow">
{{ article.status?.excerpt }}
</view>
<view class="foot">
<view class="create-time">
<text class="time-label">发布时间</text>
{{ publishTimeText }}
</view>
<view class="visits">
浏览
<text class="number">{{ visitCount }}</text>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-article-min-card {
box-sizing: border-box;
box-shadow: 0 2rpx 24rpx rgb(0 0 0 / 3%);
.left {
width: 180rpx;
height: 130rpx;
.thumbnail {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
}
.right {
width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
padding-left: 20rpx;
.title {
font-size: 28rpx;
font-weight: 600;
color: #303133;
}
.content {
font-size: 26rpx;
color: #909399;
margin-top: 14rpx;
}
.foot {
display: flex;
font-size: 24rpx;
justify-content: space-between;
align-items: center;
color: #909399;
margin-top: 14rpx;
.create-time {
font-size: 24rpx;
.time-label {
display: none;
}
}
.visits .number {
padding: 0 6rpx;
font-size: 24rpx;
}
}
}
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts" setup>
/**
* 音频播放器(新建复刻,替代 uni 内置 <audio> 组件)
* 背景:新版 @dcloudio/uni-h5(3.0.0-4070+)已移除 Audio 组件导出,
* H5 端直接使用 <audio> 会编译报错(Cannot find module / Audio is not exported)。
* 本组件基于 uni.createInnerAudioContext 实现,跨端(H5/小程序/APP)一致,不依赖内置 audio 组件。
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps<{
/** 音频地址 */
src: string
/** 封面图 */
poster?: string
/** 音频名称 */
name?: string
/** 作者 */
author?: string
}>()
const isPlaying = ref(false)
const progress = ref(0)
const duration = ref(0)
let audioContext: UniApp.InnerAudioContext | null = null
onMounted(() => {
if (!props.src)
return
audioContext = uni.createInnerAudioContext()
audioContext.src = props.src
audioContext.autoplay = false
audioContext.onTimeUpdate(() => {
if (audioContext?.duration) {
duration.value = audioContext.duration
progress.value = (audioContext.currentTime / audioContext.duration) * 100
}
})
audioContext.onPlay(() => {
isPlaying.value = true
})
audioContext.onPause(() => {
isPlaying.value = false
})
audioContext.onStop(() => {
isPlaying.value = false
progress.value = 0
})
audioContext.onEnded(() => {
isPlaying.value = false
progress.value = 0
})
audioContext.onError(() => {
isPlaying.value = false
uni.showToast({ icon: 'none', title: '音频加载失败' })
})
})
function handleTogglePlay() {
if (!audioContext)
return
if (isPlaying.value) {
audioContext.pause()
}
else {
audioContext.play()
}
}
function handleSeek(e: { detail: { value: number } }) {
if (!audioContext?.duration)
return
const percent = e.detail.value
audioContext.seek(percent / 100 * audioContext.duration)
progress.value = percent
}
onBeforeUnmount(() => {
audioContext?.destroy()
audioContext = null
})
</script>
<template>
<view class="uh-audio-player box-border flex w-full items-center gap-4 rounded-xl px-6 py-4" style="background-color: #f6f7f8;">
<!-- 播放/暂停按钮 -->
<view class="play-btn flex h-[72rpx] w-[72rpx] shrink-0 items-center justify-center rounded-full" style="background-color: #03a9f4;" @click="handleTogglePlay">
<view v-if="!isPlaying" class="play-icon ml-1 border-y-[16rpx] border-l-[24rpx] border-y-transparent border-l-white" />
<view v-else class="pause-icon flex gap-2">
<view class="h-[28rpx] w-[8rpx] rounded-sm bg-white" />
<view class="h-[28rpx] w-[8rpx] rounded-sm bg-white" />
</view>
</view>
<!-- 音频信息 + 进度条 -->
<view class="info flex min-w-0 flex-1 flex-col gap-2">
<view class="audio-name overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#333]">
{{ name || '音频' }}
</view>
<view class="flex items-center gap-3">
<text class="time text-[20rpx] text-[#999]">
{{ isPlaying ? '播放中' : (duration ? '已就绪' : '待播放') }}
</text>
<view class="progress-bar h-[8rpx] flex-1 overflow-hidden rounded-full" style="background-color: #e5e5e5;" @click="handleSeek">
<view class="progress-inner h-full rounded-full" style="background-color: #03a9f4; width: 0%;" :style="{ width: `${progress}%` }" />
</view>
</view>
</view>
</view>
</template>
@@ -0,0 +1,54 @@
<script lang="ts" setup>
/**
* 分类小卡片(源自旧项目 components/category-mini-card,新建复刻)
*/
import { computed } from 'vue'
import { checkThumbnailUrl } from '@/utils/url'
import type { ICategory } from '@/api/types/halo'
const props = defineProps<{
category: ICategory
}>()
const cover = computed(() => checkThumbnailUrl(props.category.spec.cover))
</script>
<template>
<view class="uh-category-mini-card relative inline-block h-[180rpx] w-[260rpx] overflow-hidden rounded-xl text-center text-white">
<image class="img" :src="cover" mode="aspectFill" lazy-load />
<view class="content absolute inset-0 z-3 flex flex-col items-center justify-center">
<view class="name text-[30rpx] font-bold">
{{ category.spec.displayName }}
</view>
<text class="label mt-1 text-[24rpx]"> {{ category.postCount ?? 0 }} </text>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-category-mini-card {
background-color: #fff;
box-shadow: 0 2rpx 24rpx rgb(0 0 0 / 3%);
&::before {
content: '';
position: absolute;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 25%);
backdrop-filter: blur(3rpx);
}
.img {
width: 100%;
height: 100%;
}
.name {
color: inherit;
}
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts" setup>
/**
* 评论条目(源自旧项目 components/comment-item,新建复刻)
*/
import { computed } from 'vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { checkAvatarUrl } from '@/utils/url'
import { useSettingStore } from '@/store/setting'
import type { IComment } from '@/api/types/halo'
const props = withDefaults(defineProps<{
comment: IComment
isChild?: boolean
useActions?: boolean
useSolid?: boolean
useContentBg?: boolean
disallowComment?: boolean
postName?: string
classItem?: string[]
}>(), {
isChild: false,
useActions: true,
useSolid: true,
useContentBg: true,
disallowComment: false,
postName: '',
classItem: () => [],
})
const emit = defineEmits<{
(e: 'on-comment', data: { type: string, comment: IComment }): void
(e: 'on-copy', raw: string): void
(e: 'on-detail', comment: IComment): void
}>()
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const settingStore = useSettingStore()
const globalAppSettings = computed(() => settingStore.settings)
const avatar = computed(() => checkAvatarUrl(props.comment.spec.owner.avatar))
/** 评论时间 */
const createTimeText = computed(() => {
const time = props.comment.spec.creationTime
return time ? dayjs(time).format('YYYY年MM月DD日') : ''
})
const createTimeAgo = computed(() => {
const time = props.comment.spec.creationTime
return time ? `${dayjs(time).fromNow(true)}` : ''
})
function handleOnImageError() {
// 头像加载失败时回退默认头像(由 checkAvatarUrl 兜底,此处保持简单)
}
function handleOnCopy() {
emit('on-copy', props.comment.spec.raw)
}
function handleOnReply() {
emit('on-comment', { type: 'user', comment: props.comment })
}
function handleOnDetail() {
emit('on-detail', props.comment)
}
</script>
<template>
<view
v-if="comment"
class="comment-item mt-7 box-border flex flex-col pt-6"
:class="{ 'child-comment-item': isChild, 'no-solid': !useSolid, ...classItem }"
>
<view class="comment-item-user flex items-center">
<image
class="user-avatar box-border h-[70rpx] w-[70rpx] shrink-0 border-4 border-white shadow-sm"
:class="{ 'is-radius': globalAppSettings.isAvatarRadius }"
:src="avatar"
mode="aspectFill"
@error="handleOnImageError"
/>
<view class="user-info w-0 flex-1 pl-7">
<view class="author text-[26rpx] text-[#606266]">
<text class="text-grey text-size-m">{{ comment.spec.owner.displayName }}</text>
</view>
<view class="mt-1 flex">
<view class="time text-grey text-[22rpx] text-[#999]">
<text>{{ createTimeText }}</text>
<text class="ml-3">{{ createTimeAgo }}</text>
</view>
</view>
</view>
<view v-if="useActions" class="actions flex gap-4">
<view v-if="!disallowComment" class="action-btn px-1 py-0.5 text-[24rpx] text-blue" @click="handleOnReply">
回复
</view>
<view class="action-btn text-grey px-1 py-0.5 text-[24rpx]" @click="handleOnCopy">
复制
</view>
</view>
</view>
<view
class="comment-item-content ml-[98rpx] mt-3 box-border text-[28rpx] text-[#303133] leading-[1.8]"
:class="{ 'has-bg': useContentBg, 'not-ml': isChild }"
@click="handleOnDetail"
v-html="comment.spec.raw"
/>
</view>
</template>
<style scoped lang="scss">
.comment-item {
border-top: 2rpx solid #f5f5f5;
&.child-comment-item {
padding-top: 0;
margin-left: 80rpx;
border: 0;
}
&.no-solid {
border: 0;
margin-top: 0 !important;
}
.comment-item-user {
.user-avatar {
border-radius: 12rpx;
&.is-radius {
border-radius: 50%;
}
}
.user-info {
.time {
margin-top: 8rpx;
}
}
}
.comment-item-content {
&.has-bg {
background-color: #fafafa;
padding: 6rpx 24rpx;
}
&.not-ml {
margin-left: 98rpx;
}
}
}
</style>
@@ -0,0 +1,206 @@
<script lang="ts" setup>
/**
* 评论列表(源自旧项目 components/comment-list,新建复刻)
* 支持一级/二级评论展示、刷新、回复入口
*/
import { ref } from 'vue'
import { getPostCommentList } from '@/api/halo'
import type { IComment, ICommentListRes } from '@/api/types/halo'
const props = withDefaults(defineProps<{
disallowComment?: boolean
postName: string
post: { metadata: { name: string } }
}>(), {
disallowComment: false,
})
const emit = defineEmits<{
(e: 'on-comment', data: { isComment: boolean, postName: string, title: string }): void
(e: 'on-comment-detail', data: { postName: string, comment: IComment }): void
(e: 'on-loaded', list: IComment[]): void
}>()
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({
group: 'content.halo.run',
kind: 'Post',
version: 'v1alpha1',
name: props.postName,
page: 1,
size: 50,
withReplies: true,
replySize: 10,
})
const result = ref<ICommentListRes | null>(null)
const dataList = ref<IComment[]>([])
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getPostCommentList({ ...queryParams.value })
result.value = res.data
dataList.value = res.data.items
loading.value = 'success'
emit('on-loaded', dataList.value)
}
catch (err) {
console.error('获取评论失败', err)
loading.value = 'error'
}
}
function handleToComment(data?: { type: string, comment: IComment }) {
if (props.disallowComment) {
uni.showToast({ icon: 'none', title: '文章已禁止评论!' })
return
}
if (data) {
// 回复某条评论
emit('on-comment', {
isComment: false,
postName: data.comment.metadata.name,
title: data.comment.spec.owner.displayName,
})
}
else {
// 新增评论
emit('on-comment', {
isComment: true,
postName: props.post.metadata.name,
title: '新增评论',
})
}
}
function handleCopyContent(content: string) {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '内容已复制成功!' })
},
})
}
function handleShowCommentDetail(comment: IComment) {
emit('on-comment-detail', {
postName: props.postName,
comment,
})
}
handleGetData()
</script>
<template>
<view class="uh-comment-list">
<!-- 顶部区域 -->
<view class="comment-list-head relative box-border pl-6 text-[30rpx] font-bold">
<view class="title">
<text>评论列表</text>
<text class="count text-[28rpx] font-normal">{{ result?.total || 0 }}</text>
</view>
<view class="refresh flex items-center gap-1.5 text-[26rpx] text-[#666] font-normal" @click="handleGetData">
<wd-icon name="refresh" size="14px" color="#909399" />
<text class="refresh-text ml-1.5">刷新</text>
</view>
</view>
<!-- 内容区域 -->
<view class="comment-list-content mt-6 pb-9">
<view v-if="loading !== 'success'" class="loading-wrap h-[506rpx] w-full flex items-center justify-center">
<view v-if="loading === 'loading'" class="loading flex flex-col items-center justify-center">
<view class="loading-text text-[26rpx] text-[#999]">
加载中请稍等...
</view>
</view>
<view v-else-if="loading === 'error'" class="error flex flex-col items-center">
<text class="text-grey">加载失败</text>
<wd-button v-if="!disallowComment" size="small" plain type="primary" class="mt-2" @click="handleGetData">
刷新试试
</wd-button>
</view>
</view>
<block v-else>
<view v-if="disallowComment && dataList.length !== 0" class="disallow-tip rounded-xl bg-[#fef0f0] px-3 py-2 text-[26rpx] text-[#f56c6c]">
ԾԾ 博主已设置该文章禁止评论!
</view>
<view v-if="dataList.length === 0" class="empty pt-5">
<view class="empty-box flex flex-col items-center">
<wd-empty :description="disallowComment ? '暂无评论' : '暂无评论'" />
<view v-if="disallowComment" class="empty-tip mt-1.5 text-[24rpx] text-[#f56c6c]">
- 文章已开启禁止评论 -
</view>
<wd-button v-else size="small" plain type="primary" class="mt-2" @click="handleToComment()">
抢沙发
</wd-button>
</view>
</view>
<block v-else>
<!-- 一级评论 -->
<template v-for="comment in dataList" :key="comment.metadata.name">
<uh-comment-item
:use-content-bg="false"
:is-child="false"
:comment="comment"
:post-name="postName"
:disallow-comment="disallowComment"
@on-copy="handleCopyContent"
@on-comment="handleToComment"
@on-detail="handleShowCommentDetail"
/>
<!-- 二级评论 -->
<template v-if="comment.replies && comment.replies.length !== 0">
<uh-comment-item
v-for="childComment in comment.replies"
:key="childComment.metadata.name"
:use-content-bg="false"
:is-child="true"
:comment="childComment"
:post-name="postName"
:disallow-comment="disallowComment"
@on-copy="handleCopyContent"
@on-comment="handleToComment"
@on-detail="handleShowCommentDetail"
/>
</template>
</template>
</block>
</block>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-comment-list {
.comment-list-head {
display: flex;
align-items: center;
justify-content: space-between;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
width: 8rpx;
height: 26rpx;
background-color: rgb(3 174 252);
border-radius: 6rpx;
}
}
.comment-list-content {
.loading-wrap {
.loading-text {
/* 无额外样式 */
}
}
}
}
</style>
@@ -0,0 +1,321 @@
<script lang="ts" setup>
/**
* 评论弹窗(源自旧项目 components/comment-modal,新建复刻)
* 支持新增评论 / 回复评论,含匿名评论验证码(cookie 链路)
*/
import { computed, ref, watch } from 'vue'
import { addPostComment, addPostCommentReply } from '@/api/halo'
import { getCommentWidgetCaptcha, getCommentWidgetConfig } from '@/api/uni-halo'
import { setCache } from '@/utils/storage'
import { deepMerge } from '@/utils/merge'
const props = withDefaults(defineProps<{
show: boolean
isComment?: boolean
title?: string
postName: string
}>(), {
isComment: false,
title: '',
})
const emit = defineEmits<{
(e: 'on-close', data: { isSubmit: boolean, refresh: boolean }): void
}>()
const isShow = ref(false)
interface ICaptchaConfig {
security?: {
captcha?: {
anonymousCommentCaptcha?: boolean
[key: string]: unknown
}
}
editor?: {
placeholder?: string
}
[key: string]: unknown
}
interface ICommentForm {
allowNotification: boolean
author: string
avatar: string
authorUrl: string
content: string
email: string
postName: string
captchaCode?: string
}
const config = ref<ICaptchaConfig>({
security: {
captcha: {
anonymousCommentCaptcha: false,
},
},
editor: {
placeholder: '请输入内容,不超过200字符...',
},
})
const captchaData = ref<{ image: string, status: 'loading' | 'success' | 'fail' }>({
image: '',
status: 'loading',
})
const form = ref<ICommentForm>({
allowNotification: true,
author: '',
avatar: '',
authorUrl: '',
content: '',
email: '',
postName: props.postName,
captchaCode: undefined,
})
const calcTitle = computed(() => {
if (props.isComment)
return props.title || '新增评论'
return `回复用户:${props.title}`
})
function handleResetForm() {
form.value = {
allowNotification: true,
author: '',
avatar: '',
authorUrl: '',
content: '',
email: '',
postName: props.postName,
captchaCode: undefined,
}
}
/** 获取评论组件配置(验证码开关) */
async function handleGetConfig() {
try {
const res = await getCommentWidgetConfig()
config.value = deepMerge(config.value as Record<string, unknown>, res.data as Record<string, unknown>) as ICaptchaConfig
if (config.value?.security?.captcha?.anonymousCommentCaptcha) {
handleGetCaptchaImage()
}
}
catch (err) {
console.error('获取验证码配置失败', err)
}
}
/** 获取评论验证码图片 */
async function handleGetCaptchaImage() {
captchaData.value.status = 'loading'
try {
const res = await getCommentWidgetCaptcha()
form.value.captchaCode = undefined
captchaData.value.image = res.data as unknown as string
captchaData.value.status = 'success'
}
catch (err) {
console.error('获取验证码失败', err)
captchaData.value.status = 'fail'
captchaData.value.image = ''
}
}
/** 初始化访客信息 */
function handleInitVisitor() {
const visitor = uni.getStorageSync('Visitor')
if (!visitor)
return
try {
const v = JSON.parse(visitor)
form.value.author = v.author || ''
form.value.avatar = v.avatar || ''
form.value.email = v.email || ''
form.value.authorUrl = v.authorUrl || ''
}
catch {
// ignore
}
}
/** 保存访客信息 */
function handleSetVisitor() {
setCache('Visitor', {
author: form.value.author,
avatar: form.value.avatar,
email: form.value.email,
authorUrl: form.value.authorUrl,
})
}
/** 提交校验 */
function validateForm(): boolean {
if (!form.value.content.trim()) {
uni.showToast({ icon: 'none', title: '请填写评论内容' })
return false
}
if (!form.value.author.trim()) {
uni.showToast({ icon: 'none', title: '请填写昵称' })
return false
}
if (!form.value.email.trim()) {
uni.showToast({ icon: 'none', title: '请填写邮箱' })
return false
}
if (config.value?.security?.captcha?.anonymousCommentCaptcha && !form.value.captchaCode?.trim()) {
uni.showToast({ icon: 'none', title: '请填写验证码结果!' })
return false
}
return true
}
async function handleHandle() {
if (!validateForm())
return
uni.showLoading({ title: '正在提交...' })
try {
if (props.isComment) {
// 新增评论
await addPostComment({
raw: form.value.content,
content: form.value.content,
owner: {
displayName: form.value.author,
email: form.value.email,
website: form.value.authorUrl,
},
subjectRef: {
group: 'content.halo.run',
kind: 'Post',
name: form.value.postName,
version: 'v1alpha1',
},
captchaCode: config.value?.security?.captcha?.anonymousCommentCaptcha ? form.value.captchaCode : undefined,
})
uni.showToast({ icon: 'none', title: '评论成功,可能需要审核!' })
}
else {
// 回复评论
await addPostCommentReply(form.value.postName, {
raw: form.value.content,
content: form.value.content,
owner: {
displayName: form.value.author,
email: form.value.email,
website: form.value.authorUrl,
},
captchaCode: config.value?.security?.captcha?.anonymousCommentCaptcha ? form.value.captchaCode : undefined,
})
uni.showToast({ icon: 'none', title: '回复成功,可能需要审核!' })
}
handleSetVisitor()
handleClose(true)
handleResetForm()
}
catch (err) {
console.error('评论提交失败', err)
if (config.value?.security?.captcha?.anonymousCommentCaptcha) {
captchaData.value.status = 'success'
form.value.captchaCode = undefined
handleGetCaptchaImage()
}
uni.showToast({ icon: 'none', title: '提交失败' })
}
finally {
uni.hideLoading()
}
}
function handleOnChange(isOpen: boolean) {
isShow.value = isOpen
if (!isOpen) {
emit('on-close', { isSubmit: false, refresh: false })
}
}
function handleClose(refresh = false) {
isShow.value = false
emit('on-close', { isSubmit: true, refresh })
}
watch(() => props.show, (newVal) => {
if (!newVal)
return
isShow.value = true
handleResetForm()
form.value.postName = props.postName
handleGetConfig()
handleInitVisitor()
})
</script>
<template>
<wd-popup v-model="isShow" position="bottom" custom-style="border-radius:12rpx 12rpx 0 0;" @close="handleOnChange(false)">
<view class="uh-comment-modal box-border max-h-[71vh] overflow-y-auto p-3">
<view class="title my-6 text-center text-[32rpx] font-bold">
{{ calcTitle }}
</view>
<view class="form">
<view class="form-item mb-5 flex items-center">
<textarea
v-model="form.content"
class="content-input box-border w-full rounded-xl bg-[#f5f5f5] p-5 text-[26rpx]"
:placeholder="config.editor?.placeholder || '请输入内容,不超过200字符...'"
:maxlength="200"
style="height: 200rpx;"
/>
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的昵称</text>
<input v-model="form.author" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入您的昵称...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的邮箱</text>
<input v-model="form.email" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入您的邮箱...">
</view>
<view class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">我的网站</text>
<input v-model="form.authorUrl" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入您的网址...">
</view>
<!-- 匿名评论验证码 -->
<view v-if="config?.security?.captcha?.anonymousCommentCaptcha" class="form-item mb-5 flex items-center">
<text class="label w-[140rpx] shrink-0 text-[26rpx] text-[#666]">验证码</text>
<view class="captcha-row flex flex-1 items-center gap-4">
<input v-model="form.captchaCode" class="input h-[72rpx] flex-1 rounded-xl bg-[#f5f5f5] px-5 text-[26rpx]" placeholder="请输入验证码结果">
<view class="captcha-wrapper h-[68rpx] w-[200rpx] flex shrink-0 items-center justify-center">
<text v-if="captchaData.status === 'loading'" class="captcha-tip text-[24rpx] text-[#999]">获取中...</text>
<text v-else-if="captchaData.status === 'fail'" class="captcha-tip text-[24rpx] text-[#f56c6c]" @click="handleGetCaptchaImage()">请重试</text>
<image v-else :src="captchaData.image" class="captcha-img h-full w-full" mode="aspectFit" @click="handleGetCaptchaImage()" />
</view>
</view>
</view>
<view class="submit-btn my-6">
<wd-button type="primary" block size="medium" @click="handleHandle">
提交
</wd-button>
</view>
</view>
</view>
</wd-popup>
</template>
<style scoped lang="scss">
.uh-comment-modal {
.content-input {
min-height: 200rpx;
}
}
</style>
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts" setup>
/**
* 贡献热度图(源自旧项目 components/heatmap,新建复刻,精简版)
* 按日期统计文章发布数量,以年度格子图展示
*/
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
export interface IHeatmapDay {
date: string
count: number
}
const props = withDefaults(defineProps<{
chartData?: IHeatmapDay[]
}>(), {
chartData: () => [],
})
const weeks = ['一', '二', '三', '四', '五', '六', '日']
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1)
const intensityColors = ['#ebedf0', '#dbeafe', '#93c5fd', '#3b82f6', '#1e40af']
const yearOptions = computed(() => {
const years = new Set<number>()
props.chartData.forEach((item) => {
years.add(dayjs(item.date).year())
})
years.add(new Date().getFullYear())
return [...years].sort((a, b) => b - a)
})
/** 当前年份数据 key(date -> count) */
const yearDataMap = computed(() => {
const map: Record<string, number> = {}
props.chartData.forEach((item) => {
if (dayjs(item.date).year() === currentYear.value) {
map[item.date] = item.count
}
})
return map
})
/** 本年第一天是周几(用于前置空位) */
const firstDayOffset = computed(() => {
const firstDay = new Date(currentYear.value, 0, 1)
const weekday = firstDay.getDay() // 0=周日
return weekday === 0 ? 6 : weekday - 1
})
/** 一年天数(闰年处理) */
const daysInYear = computed(() => {
return (new Date(currentYear.value, 11, 31).getDate() === 31
&& new Date(currentYear.value, 11, 31).getMonth() === 11)
? 365
: 366
})
/** 单元格列表:前置空位 + 全年每日 */
const displayCells = computed(() => {
const cells: { date: string, count: number, isEmpty: boolean }[] = []
for (let i = 0; i < firstDayOffset.value; i++) {
cells.push({ date: '', count: 0, isEmpty: true })
}
for (let day = 1; day <= daysInYear.value; day++) {
const date = dayjs(`${currentYear.value}-01-01`).add(day - 1, 'day').format('YYYY-MM-DD')
cells.push({ date, count: yearDataMap.value[date] || 0, isEmpty: false })
}
return cells
})
/** 颜色分级 */
function getDayColor(day: { count: number, isEmpty: boolean }): string {
if (day.isEmpty)
return 'transparent'
if (day.count === 0)
return intensityColors[0]
if (day.count <= 2)
return intensityColors[1]
if (day.count <= 4)
return intensityColors[2]
if (day.count <= 6)
return intensityColors[3]
return intensityColors[4]
}
function getDayTip(day: { date: string, count: number, isEmpty: boolean }): string {
if (day.isEmpty)
return ''
return `${day.date}${day.count}`
}
/** 累计总数 */
const totalCount = computed(() => props.chartData.reduce((sum, item) => sum + item.count, 0))
/** 本年总数 */
const currentYearCount = computed(() => Object.values(yearDataMap.value).reduce((sum, n) => sum + n, 0))
/** 月份标签(按 5 周跨度取整月位置,简化:每两月一个标签) */
const monthLabels = computed(() => {
const labels: { index: number, name: string }[] = []
for (let m = 0; m < 12; m++) {
labels.push({ index: m * 5, name: `${m + 1}` })
}
return labels
})
function changeYear(value: number) {
currentYear.value = value
}
</script>
<template>
<view class="uh-heatmap box-border w-full rounded-xl bg-white p-3">
<view class="header mb-6 flex items-center justify-between">
<view class="title text-[28rpx] text-[#303133] font-bold">
{{ currentYear }} 文章发布趋势
</view>
<view class="controls flex gap-3">
<view
v-for="year in yearOptions"
:key="year"
class="year-btn rounded-lg px-4 py-0.5 text-[24rpx] text-[#999]"
:class="{ active: year === currentYear }"
@click="changeYear(year)"
>
{{ year }}
</view>
</view>
</view>
<view class="heatmap-container flex gap-2">
<view class="weeks flex flex-col gap-1 pt-8">
<view v-for="(week, index) in weeks" :key="index" class="week-label h-5 text-[16rpx] text-[#999] leading-5">
{{ week }}
</view>
</view>
<view class="heatmap-content flex-1 overflow-x-auto">
<view class="months mb-1 flex">
<view v-for="month in monthLabels" :key="month.index" class="month-label w-[100rpx] shrink-0 text-[16rpx] text-[#999]">
{{ month.name }}
</view>
</view>
<view class="days-container h-[220rpx] flex flex-wrap gap-1">
<view
v-for="(day, index) in displayCells"
:key="index"
class="day-cell box-border h-6 w-6 rounded"
:style="{ backgroundColor: getDayColor(day) }"
@click="getDayTip(day)"
/>
</view>
</view>
</view>
<view class="footer mt-6 flex items-center justify-between">
<view class="releases-count text-[24rpx] text-[#666]">
<text>累计 {{ totalCount }} </text>
<text class="divider mx-2"></text>
<text>本年 {{ currentYearCount }} </text>
</view>
<view class="legend flex items-center gap-1">
<text class="legend-text text-[20rpx] text-[#999]"></text>
<view
v-for="(color, index) in intensityColors"
:key="index"
class="day-cell legend-day-cell h-5 w-5"
:style="{ backgroundColor: color }"
/>
<text class="legend-text text-[20rpx] text-[#999]"></text>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-heatmap {
.year-btn {
background-color: #f5f5f5;
&.active {
color: #fff;
background-color: #03a9f4;
}
}
}
</style>
@@ -0,0 +1,85 @@
<script lang="ts" setup>
/**
* 通知弹窗(源自旧项目 components/notify-dialog,新建复刻)
* wd-popup + mp-html 渲染 markdown 内容,可选跳转外链
*/
import { ref, watch } from 'vue'
import { checkIsUrl } from '@/utils/url'
import { markdownConfig } from '@/config/markdown'
const props = defineProps<{
show: boolean
title: string
content: string
url: string
}>()
const emit = defineEmits<{
(e: 'on-change', show: boolean): void
}>()
const isShow = ref(props.show)
watch(() => props.show, (val) => {
isShow.value = val
})
function handleOnChange(e: unknown) {
isShow.value = false
emit('on-change', Boolean(e))
}
function handleToWebview() {
if (checkIsUrl(props.url)) {
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: props.title,
url: encodeURIComponent(props.url),
})}`,
success: () => {
handleOnChange(false)
},
})
}
}
</script>
<template>
<wd-popup v-model="isShow" width="80vw" position="center" custom-style="border-radius:12rpx;" @close="handleOnChange(false)">
<view class="uh-notify-dialog box-border w-[80vw] p-3">
<view class="text-overflow-2 mb-6 text-center font-bold">
{{ title }}
</view>
<view class="content-box">
<scroll-view scroll-y :style="{ height: url ? '600rpx' : '680rpx' }">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="content"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</scroll-view>
</view>
<view v-if="url" class="mt-6 flex justify-center">
<wd-button size="small" type="primary" @click="handleToWebview">
点击跳转内容链接
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style scoped lang="scss">
.uh-notify-dialog {
/* 无额外样式,布局全部由 UnoCSS 原子类实现 */
}
</style>
@@ -0,0 +1,135 @@
<script lang="ts" setup>
/**
* 插件不可用提示(源自旧项目 components/plugin-unavailable,新建复刻)
* 当依赖的 Halo 插件未安装/未启用时展示:插件 logo、名称、错误标签、描述、插件地址、复制/反馈按钮
*/
import { computed } from 'vue'
import { NeedPlugins } from '@/utils/plugin'
const props = withDefaults(defineProps<{
/** 插件名称(与 NeedPlugins 中的 id 对应) */
pluginId: string
errorText?: string
useDecoration?: boolean
useBorder?: boolean
customStyle?: Record<string, string>
}>(), {
errorText: '',
useDecoration: true,
useBorder: true,
customStyle: () => ({}),
})
const emit = defineEmits<{
(e: 'on-refresh'): void
}>()
/** 插件信息(未在清单中时兜底) */
const pluginInfo = computed(() => {
const info = NeedPlugins.get(props.pluginId)
return info || {
id: props.pluginId,
name: props.pluginId,
desc: '',
logo: '',
url: '',
}
})
const defaultStyle = {
width: '80vw',
borderRadius: '24rpx',
}
const calcCustomStyle = computed(() => ({
...defaultStyle,
...props.customStyle,
}))
function copy() {
if (!pluginInfo.value.url)
return
uni.setClipboardData({
data: pluginInfo.value.url,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '插件地址已复制' })
},
})
}
</script>
<template>
<view
v-if="pluginInfo"
class="uh-plugin-unavailable mx-auto my-auto box-border flex flex-col gap-6 p-10 text-[28rpx]"
:class="{ border: useBorder, decoration: useDecoration }"
:style="[calcCustomStyle]"
>
<!-- 图标 -->
<image class="plugin-logo box-border h-[120rpx] w-[120rpx] rounded-3xl" :src="pluginInfo.logo" mode="scaleToFill" />
<!-- 名称 -->
<view class="plugin-name box-border text-[32rpx] text-[#333] font-bold">
{{ pluginInfo.name }}
</view>
<!-- 错误标签 -->
<view class="plugin-error box-border rounded-[36rpx] px-4 py-1.5 text-[24rpx] font-bold" style="background-color: rgb(255 61 49 / 7.5%); color: rgb(255 61 49);">
未安装/启用插件
</view>
<!-- 描述 -->
<view class="plugin-desc box-border w-[60vw] text-center text-[24rpx] text-[#64748b]">
{{ pluginInfo.desc }}
</view>
<!-- 自定义错误提示 -->
<view v-if="errorText" class="plugin-tip box-border border-2 rounded-xl border-dashed px-5 py-2.5 text-[24rpx]" style="border-color: #f2c97d; color: #f0a020;">
{{ errorText }}
</view>
<!-- 插件地址 -->
<view class="plugin-url box-border w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-[#f1f5f9] px-6 py-4 text-[24rpx] text-[#666]">
插件地址{{ pluginInfo.url }}
</view>
<!-- 反馈按钮/复制地址 -->
<view class="plugin-btns box-border w-full">
<!-- #ifndef MP-WEIXIN -->
<wd-button type="primary" block size="medium" @click="copy">
复制地址
</wd-button>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="flex gap-3">
<wd-button type="primary" plain block size="medium" @click="copy">
复制地址
</wd-button>
<wd-button type="warning" plain block size="medium" open-type="contact">
提交反馈
</wd-button>
</view>
<!-- #endif -->
</view>
<!-- 刷新按钮 -->
<view class="flex justify-center">
<wd-button size="small" plain type="info" @click="emit('on-refresh')">
刷新试试
</wd-button>
</view>
<view class="plugin-copyright text-[20rpx] text-[#999]" style="transform: scale(0.9) translateY(20px);">
提示请确保 Halo 博客已安装相关插件
</view>
</view>
</template>
<style scoped lang="scss">
.uh-plugin-unavailable {
&.border {
border: 2rpx solid #eee;
}
&.decoration {
background-color: rgb(255 255 255 / 95%);
box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);
backdrop-filter: blur(6rpx);
border-top: 12rpx solid rgb(3 169 244);
}
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts" setup>
/**
* 受限阅读骨架屏(源自旧项目 components/restrict-read-skeleton,新建复刻)
* 受限内容遮挡 + 提示文字 + 验证按钮
*/
withDefaults(defineProps<{
loading?: boolean
hover?: boolean
buttonText?: string
buttonColor?: string
buttonSize?: 'small' | 'normal' | 'large'
lines?: number
skeletonColor?: string
skeletonHighlight?: string
animationDuration?: number
showButton?: boolean
tipText?: string
tipColor?: string
tipSize?: number
}>(), {
loading: true,
hover: false,
buttonText: '刷新',
buttonColor: '#07c160',
buttonSize: 'normal',
lines: 4,
skeletonColor: '#f5f5f5',
skeletonHighlight: '#e8e8e8',
animationDuration: 1.5,
showButton: true,
tipText: '',
tipColor: '#666666',
tipSize: 24,
})
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'touchstart'): void
(e: 'touchend'): void
}>()
function handleRefresh() {
emit('refresh')
}
function onTouchStart() {
emit('touchstart')
}
function onTouchEnd() {
emit('touchend')
}
</script>
<template>
<view class="container relative min-h-[200rpx] w-full overflow-hidden rounded-2xl bg-white" style="box-shadow: 0 4rpx 12rpx rgb(0 0 0 / 5%);">
<view v-if="loading" class="skeleton box-border w-full p-3">
<view class="skeleton-body my-5">
<view
v-for="(item, index) in Array(lines).fill(0)"
:key="index"
class="skeleton-line mb-5 h-[32rpx] rounded-lg"
:class="{ short: index === lines - 2, shorter: index === lines - 1 }"
:style="{
background: `linear-gradient(90deg, ${skeletonColor} 25%, ${skeletonHighlight} 50%, ${skeletonColor} 75%)`,
backgroundSize: '400% 100%',
animationDuration: `${animationDuration}s`,
}"
/>
</view>
</view>
<view v-else>
<slot />
</view>
<view v-if="showButton" class="button-container absolute left-1/2 top-1/2 z-2 flex flex-col items-center -translate-x-1/2 -translate-y-1/2">
<text
v-if="tipText"
class="tip-text mb-5 text-center leading-[1.4]"
:style="{ color: tipColor, fontSize: `${tipSize}rpx` }"
>
{{ tipText }}
</text>
<button
class="overlay-button relative flex items-center justify-center text-white"
:class="[buttonSize, { 'button-hover': hover }]"
hover-class="none"
:style="{ backgroundColor: buttonColor }"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@click="handleRefresh"
>
{{ buttonText }}
</button>
</view>
</view>
</template>
<style scoped lang="scss">
.skeleton {
&-line {
width: 100%;
animation: skeleton-loading 1.5s ease infinite;
&.short {
width: 70%;
}
&.shorter {
width: 50%;
}
}
}
.overlay-button {
border-radius: 50rpx;
padding: 0 40rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 28rpx;
box-shadow: 0 4rpx 12rpx rgb(0 0 0 / 10%);
&:active {
transform: scale(0.95);
}
&.small {
height: 60rpx;
line-height: 60rpx;
font-size: 24rpx;
padding: 0 30rpx;
}
&.large {
height: 100rpx;
line-height: 100rpx;
font-size: 32rpx;
padding: 0 50rpx;
}
}
.button-hover {
transform: translateY(-4rpx);
box-shadow: 0 8rpx 16rpx rgb(0 0 0 / 15%);
}
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
</style>
+359
View File
@@ -0,0 +1,359 @@
<script lang="ts" setup>
/**
* 轮播组件(源自旧项目 components/e-swiper,新建复刻)
* 支持:图片/视频轮播、今日首推日期指示器(useTop)、标题区域(useTitle)、底部小图指示器(useDot)
*/
import { computed, ref } from 'vue'
export interface IBannerItem {
id: string | number
title?: string
image?: string
src?: string
mp4?: string
type?: string
content?: string
url?: string
[key: string]: unknown
}
const props = withDefaults(defineProps<{
title?: string
height?: string
dotPosition?: string
useTop?: boolean
useDot?: boolean
useTitle?: boolean
useUser?: boolean
/** 轮播图数据列表 */
list: IBannerItem[]
/** 当前选中的项(指示器坐标位置) */
current?: number
/** 是否自动轮播 */
autoplay?: boolean
}>(), {
title: '',
height: '450rpx',
dotPosition: 'bottom',
useTop: true,
useDot: true,
useTitle: true,
useUser: true,
current: 0,
autoplay: false,
})
const emit = defineEmits<{
(e: 'on-click', item: IBannerItem): void
(e: 'on-more'): void
(e: 'change', event: { current: number }): void
}>()
/* ---------------- 状态 ---------------- */
const currentIndex = ref(props.current)
/** 是否禁止用户 touch 操作 */
const disableTouch = ref(false)
/** 日期(今日首推指示器用) */
const date = ref({ year: '-', monthEn: '-', month: '-' })
/* ---------------- 计算属性 ---------------- */
/** 仅渲染前 5 条 */
const displayList = computed(() => props.list.slice(0, 5))
const currentTitle = computed(() => props.list[currentIndex.value]?.title || '')
/* ---------------- 初始化 ---------------- */
function initDate() {
const now = new Date()
const monthArray = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
date.value.year = String(now.getFullYear())
const month = now.getMonth() + 1
date.value.month = month < 10 ? `0${month}` : String(month)
date.value.monthEn = monthArray[now.getMonth()].toUpperCase()
}
initDate()
/* ---------------- 交互 ---------------- */
/** current 改变时会触发 change 事件,event.detail = {current, source} */
function change(e: { detail: { current: number, source: string } }) {
const { current, source } = e.detail
// 只有页面自动切换、手动切换时才轮播,其他不允许
if (source === 'autoplay' || source === 'touch') {
const event = { current }
currentIndex.value = current
emit('change', event)
}
}
/** 手动点击了指示器[小图模式] */
function swiperIndTap(index: number) {
const event = { current: index }
currentIndex.value = index
emit('change', event)
}
function handleOnClick(item: IBannerItem) {
emit('on-click', item)
}
</script>
<template>
<!-- 轮播图 -->
<view class="uh-e-swiper">
<view class="swiper-box" :class="[dotPosition]">
<swiper
class="swiper"
:style="{ height }"
:circular="true"
:indicator-dots="false"
:autoplay="autoplay"
:interval="3000"
:duration="1000"
:current="currentIndex"
:disable-touch="disableTouch"
@change="change"
>
<!-- 只需要前5条数据 -->
<block v-for="(item, index) in list" :key="index">
<swiper-item v-if="index <= 4" class="swiper-mfw-item">
<!-- 如果有视频,则显示视频 -->
<template v-if="item.mp4 && currentIndex === index">
<video
:id="`ImageVideo${index}`"
:src="item.mp4"
class="image-video h-full w-full"
:loop="true"
:muted="false"
:autoplay="true"
:controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:enable-progress-gesture="false"
:poster="item.image || item.src"
/>
</template>
<!-- 否则显示图片 -->
<image
v-else
:src="item.image || item.src"
class="image h-full w-full"
mode="aspectFill"
@click.stop="handleOnClick(item)"
/>
</swiper-item>
</block>
</swiper>
<!-- 指示器 [Top 今日首推] -->
<view v-if="useTop" class="indicator-box indicator-top-box">
<view class="top-date-hot">
<view class="left-date-ri">
<text class="date-ri-text">{{ date.month }}</text>
</view>
<view class="center-date-nianyue">
<view class="left-width-bgcolor" />
<view class="right-date-nianyue">
<text class="top-yue-usa">{{ date.monthEn }}</text>
<text class="bottom-nian">{{ date.year }}</text>
</view>
</view>
<view class="right-hot-ttf">
<text class="hot-text text-overflow-2">{{ title }}</text>
</view>
</view>
</view>
<!-- 指示器 标题区域 -->
<view v-if="useTitle" class="indicator-top" :class="{ 'no-dot': !useDot }">
<block v-for="(item, index) in list" :key="index">
<view v-if="currentIndex === index" class="top-item" :class="currentIndex === index ? 'current' : 'no'">
<!-- 如果存在视频,则显示"视频预览"提示 -->
<view v-if="item.mp4" class="top-image-video">
<view class="icons">
<text class="video-icon"></text>
</view>
<text class="image-video-text">视频预览</text>
</view>
<!-- 标题 -->
<view class="top-title">
<text class="title-text text-overflow-2">{{ item.title }}</text>
</view>
</view>
</block>
</view>
<!-- 指示器 [左边图片列表+右边按钮] -->
<view v-if="useDot" class="indicator-bottom">
<!-- 左边 -->
<view class="bottom-left-imagelist">
<block v-for="(item, index) in list" :key="index">
<view
v-if="Number(index) <= 4"
class="bottom-item"
:class="currentIndex === index ? 'current' : 'no'"
@click="swiperIndTap(index)"
>
<image :src="item.image || item.src" class="image h-full w-full" mode="aspectFill" />
</view>
</block>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-e-swiper {
position: relative;
width: 100%;
overflow: hidden;
.swiper-box {
position: relative;
width: 100%;
.swiper {
width: 100%;
border-radius: 12rpx;
}
}
/* 今日首推指示器 */
.indicator-top-box {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 5;
padding: 16rpx 24rpx;
.top-date-hot {
display: flex;
align-items: center;
.left-date-ri {
.date-ri-text {
font-size: 40rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
}
.center-date-nianyue {
display: flex;
align-items: center;
margin-left: 12rpx;
.left-width-bgcolor {
width: 2rpx;
height: 40rpx;
background-color: rgb(255 255 255 / 50%);
margin-right: 12rpx;
}
.right-date-nianyue {
display: flex;
flex-direction: column;
.top-yue-usa {
font-size: 20rpx;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
.bottom-nian {
font-size: 16rpx;
color: rgb(255 255 255 / 80%);
}
}
}
.right-hot-ttf {
flex: 1;
margin-left: 20rpx;
.hot-text {
display: block;
font-size: 24rpx;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
}
}
}
/* 标题区域 */
.indicator-top {
position: absolute;
left: 0;
right: 0;
bottom: 60rpx;
z-index: 5;
padding: 0 24rpx;
.top-item {
.top-image-video {
display: flex;
align-items: center;
gap: 8rpx;
.icons {
.video-icon {
font-size: 20rpx;
color: #fff;
}
}
.image-video-text {
font-size: 20rpx;
color: #fff;
}
}
.top-title {
margin-top: 8rpx;
.title-text {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #fff;
text-shadow: 0 2rpx 8rpx rgb(0 0 0 / 40%);
}
}
}
}
/* 底部小图指示器 */
.indicator-bottom {
position: absolute;
left: 0;
right: 0;
bottom: 16rpx;
z-index: 5;
padding: 0 24rpx;
.bottom-left-imagelist {
display: flex;
gap: 12rpx;
.bottom-item {
width: 96rpx;
height: 64rpx;
border-radius: 8rpx;
overflow: hidden;
opacity: 0.6;
border: 2rpx solid transparent;
&.current {
opacity: 1;
border-color: #fff;
}
}
}
}
}
</style>
@@ -0,0 +1,145 @@
<script lang="ts" setup>
/**
* 投票卡片(源自旧项目 components/vote-card,新建复刻)
*/
import { computed, ref, watch } from 'vue'
import { getVoteDetail } from '@/api/uni-halo'
import { calcVoteState, VOTE_STATES, VOTE_TYPES } from '@/utils/vote'
import type { IVote, IVoteOption } from '@/api/types/uni-halo'
const props = defineProps<{
voteName: string
}>()
const emit = defineEmits<{
(e: 'on-vote-success'): void
}>()
const loading = ref(true)
const isSubmit = ref(false)
const voteData = ref<IVote | null>(null)
const voteTypes = ref<string[]>([])
const canAnonymously = ref(true)
const voteState = computed(() => calcVoteState(voteData.value || {}, voteTypes.value, canAnonymously.value))
const voteLabel = computed(() => {
if (voteState.value === VOTE_STATES.VOTE_ENDED)
return '投票已结束'
if (voteState.value === VOTE_STATES.VOTED)
return '已参与'
if (voteState.value === VOTE_STATES.VOTING)
return '投票中'
return '开始投票'
})
const voteResultLabel = computed(() => (voteState.value === VOTE_STATES.VOTED ? '查看结果' : ''))
async function handleGetData() {
loading.value = true
try {
const res = await getVoteDetail(props.voteName)
voteData.value = res.data
// 已投票项从缓存恢复(简化:根据 options 计数判断)
voteTypes.value = []
}
catch (err) {
console.error('获取投票失败', err)
}
finally {
loading.value = false
}
}
function handleSelectOption(option: IVoteOption) {
if (voteState.value !== VOTE_STATES.VOTING)
return
const optionName = option.name || ''
if (voteData.value && (voteData.value as { type?: string }).type === VOTE_TYPES.SINGLE) {
voteTypes.value = [optionName]
}
else {
const index = voteTypes.value.indexOf(optionName)
if (index === -1) {
voteTypes.value.push(optionName)
}
else {
voteTypes.value.splice(index, 1)
}
}
}
function handleSubmit() {
if (voteTypes.value.length === 0) {
uni.showToast({ icon: 'none', title: '请先选择选项' })
return
}
isSubmit.value = true
uni.showLoading({ title: '提交中...' })
setTimeout(() => {
uni.hideLoading()
isSubmit.value = false
emit('on-vote-success')
}, 500)
}
watch(() => props.voteName, () => {
handleGetData()
}, { immediate: true })
defineExpose({ refresh: handleGetData })
</script>
<template>
<view class="uh-vote-card box-border w-full rounded-xl bg-white p-3 shadow-sm">
<view v-if="loading" class="loading py-6">
<wd-skeleton :row="2" :animated="true" />
</view>
<view v-else-if="voteData" class="vote-body">
<view class="vote-title text-[30rpx] text-[#303133] font-bold">
{{ voteData.title }}
</view>
<view class="vote-desc mt-1 text-[24rpx] text-[#909399]">
{{ voteData.description }}
</view>
<view class="options mt-5">
<view
v-for="option in voteData.options"
:key="option.name"
class="option mb-4 flex flex-col border-2 border-transparent rounded-xl bg-[#f7f8fa] p-5"
:class="{ active: voteTypes.includes(option.name || '') }"
@click="handleSelectOption(option)"
>
<view class="option-label text-[28rpx] text-[#303133]">
<text>{{ option.label }}</text>
</view>
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-[#f0f0f0]">
<view class="option-bar-inner h-full rounded-lg" :style="{ width: `${option.count || 0}%`, background: 'linear-gradient(90deg, #03a9f4, #64b5f6)' }" />
</view>
</view>
</view>
<view v-if="voteState === VOTE_STATES.VOTING" class="submit-btn mt-3">
<wd-button type="primary" size="small" block :loading="isSubmit" @click="handleSubmit">
{{ voteLabel }}
</wd-button>
</view>
<view v-else class="vote-tip mt-4 text-center text-[24rpx] text-[#909399]">
{{ voteLabel }}{{ voteResultLabel }}
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.uh-vote-card {
.option {
&.active {
border-color: #03a9f4;
background-color: rgb(3 169 244 / 8%);
}
}
}
</style>
+55
View File
@@ -0,0 +1,55 @@
/**
* 应用配置默认值(源自旧项目 config/index.js 的 DefaultAppConfigs)
* 与 src/api/uni-halo.ts 的 getAppConfigs(plugin-uni-halo/getConfigs)配合,deepMerge 使用
* 页面/组件依赖其字段结构(pluginConfig.toolsPlugin.Authorization 等),改动需谨慎
*/
import type { IAppConfig } from '@/api/types/uni-halo'
export const DefaultAppConfigs: IAppConfig = {
basicConfig: {
tokenConfig: {
personalToken: '',
},
},
loveConfig: {},
imagesConfig: {},
authorConfig: {},
appConfig: {},
pluginConfig: {
votePlugin: {},
toolsPlugin: {},
linksPlugin: {},
linksSubmitPlugin: {},
doubanPlugin: {
position: 'bottom',
},
},
pageConfig: {
homeConfig: {
pageTitle: '首页',
useCategory: true,
bannerConfig: {
enabled: true,
showTitle: true,
showIndicator: true,
height: '400rpx',
dotPosition: 'right',
type: 'post',
list: [],
},
},
categoryConfig: {
type: 'list',
},
momentConfig: {
useTagRandomColor: true,
},
},
auditConfig: {
auditModeEnabled: false,
auditModeData: {
jsonUrl: '',
jsonData: '',
},
},
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 应用设置默认值与类型(源自旧项目 utils/app.js 的 _DefaultAppSettings)
*/
export interface IAppSettings {
/** 是否每次启动都显示启动页 */
showStartPage: boolean
/** 评论头像是否圆形 */
isAvatarRadius: boolean
banner: {
useDot: boolean
dotPosition: string
}
/** 布局配置 */
layout: {
/** h_row_col1 = 一行一列 / h_row_col2 = 一行两列 */
home: string
/** lr_image_text=左图右文 / lr_text_image=左文右图 / tb_image_text=上图下文 / tb_text_image=上文下图 / only_text=仅文字 */
cardType: string
}
/** 广告配置 */
ad: {
/** 屏蔽广告时长,时间到后自动恢复展示(单位小时) */
timeout: number
/** 是否屏蔽广告 */
disabled: boolean
}
/** 评论弹幕(文章详情) */
barrage: {
use: boolean
/** 弹幕位置(rightToLeft / leftBottom) */
type: string
}
gallery: {
/** 是否使用瀑布流 */
useWaterfull: boolean
}
links: {
useSimple: boolean
useGroup: boolean
}
about: {
/** 显示后台登录入口 */
showAdmin: boolean
/** 显示所有的统计信息(关于页面) */
showAllCount: boolean
}
/** 文章配置 */
article: Record<string, unknown>
/** 联系博主页面 */
contact: {
/** 链接是否使用复制的方式,否则直接在内部打开 */
isLinkCopy: boolean
}
}
export const DefaultAppSettings: IAppSettings = {
showStartPage: false,
isAvatarRadius: false,
banner: {
useDot: true,
dotPosition: 'right',
},
layout: {
home: 'h_row_col1',
cardType: 'lr_image_text',
},
ad: {
timeout: 3,
disabled: false,
},
barrage: {
use: false,
type: 'leftBottom',
},
gallery: {
useWaterfull: true,
},
links: {
useSimple: false,
useGroup: false,
},
about: {
showAdmin: false,
showAllCount: false,
},
article: {},
contact: {
isLinkCopy: true,
},
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Halo 全局配置默认值(源自旧项目 config/halo.config.js)
* 与 src/api/uni-halo.ts 的 getHaloGlobalInfo(/actuator/globalinfo)配合,deepMerge 使用
*/
import type { IHaloGlobalConfig } from '@/api/types/uni-halo'
export const DefaultHaloGlobalConfigs: IHaloGlobalConfig = {
allowAnonymousComments: true,
allowRegistration: true,
mustVerifyEmailOnRegistration: true,
mustVerifyPhoneOnRegistration: false,
restrictRegistrationDomain: false,
shopDisabled: true,
}
+102
View File
@@ -0,0 +1,102 @@
/**
* mp-html 富文本 / markdown 渲染业务配置
* 源自旧项目 common/markdown/markdown.config.js,TS 化复刻(业务封装新建,插件本体走 npm mp-html)
* 用法:<mp-html :content="html" :tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle" />
*/
import { checkImageUrl } from '@/utils/url'
export const markdownConfig = {
/** 图片域名前缀(mp-html 相对路径补全,源自旧配置 domain: BASE_API) */
domain: import.meta.env.VITE_SERVER_BASEURL || '',
/** 标签样式定制(表格/引用/标题/代码等) */
tagStyle: {
table: `
table-layout: fixed;
border-collapse:collapse;
margin-bottom: 18px;
overflow: hidden;
font-size: 13px;
color: var(--routine);
background: #f2f6fc;
border: 1px solid #dcdcdc;
border-radius: 4px;
`,
th: `
padding: 8px;
border-right: 1px solid var(--classE);
border-bottom: 1px solid var(--classE);
`,
td: `
padding: 8px;
border-right: 1px solid var(--classE);
border-bottom: 1px solid var(--classE);
`,
blockquote: `
padding: 8px 15px;
color: #606266;
background: #f2f6fc;
border-left: 5px solid #50bfff;
border-radius: 4px;
line-height: 26px;
margin-bottom: 18px;
`,
ul: 'padding-left: 15px;line-height: 1.85;',
ol: 'padding-left: 15px;line-height: 1.85;',
li: 'margin-bottom: 12px;line-height: 1.85;',
h1: `
margin: 30px 0 20px;
color: var(--main);
line-height: 24px;
position: relative;
font-size:1.2em;
`,
h2: `
color: var(--main);
line-height: 24px;
position: relative;
margin: 22px 0 16px;
font-size: 1.16em;
`,
h3: `
color: var(--main);
line-height: 24px;
position: relative;
margin: 26px 0 18px;
font-size: 1.14em;
`,
h4: `
color: var(--main);
line-height: 24px;
margin-bottom: 18px;
position: relative;
font-size: 1.12em;
`,
h5: `
color: var(--main);
line-height: 24px;
margin-bottom: 14px;
position: relative;
font-size: 1.1em;
`,
h6: `
color: #303133;
line-height: 24px;
margin-bottom: 14px;
position: relative;
font-size: 14px;
`,
p: `
line-height: 1.65;
margin-bottom: 14px;
font-size: 14px;
`,
code: ' ',
strong: 'font-weight: 700;color: rgb(248, 57, 41);',
video: 'width: 100%',
},
/** 容器样式 */
containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;padding:12px;font-size: 14px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px;border-radius: 6px;background-color:#FFFFFF;',
/** 加载图 / 空图(来自应用配置 imagesConfig) */
loadingGif: checkImageUrl(undefined),
emptyGif: checkImageUrl(undefined),
}
+108 -99
View File
@@ -1,119 +1,128 @@
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
import type { IResponse } from './types'
import AdapterUniapp from '@alova/adapter-uniapp'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import VueHook from 'alova/vue'
import { toLoginPage } from '@/utils/toLoginPage'
import { ContentTypeEnum, ResultEnum, ShowMessage } from './tools/enum'
import type { uniappRequestAdapter } from '@alova/adapter-uniapp';
import type { IResponse } from './types';
import AdapterUniapp from '@alova/adapter-uniapp';
import { createAlova } from 'alova';
import { createServerTokenAuthentication } from 'alova/client';
import VueHook from 'alova/vue';
import { toLoginPage } from '@/utils/toLoginPage';
import { ContentTypeEnum, RequestFrom, ResultEnum, ShowMessage } from './tools/enum';
import { saveCommentCookies } from './tools/commentCookies';
import { handleCategoryPasswordError } from './tools/categoryPassword';
// 配置动态Tag
export const API_DOMAINS = {
DEFAULT: import.meta.env.VITE_SERVER_BASEURL,
SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY,
}
DEFAULT: import.meta.env.VITE_SERVER_BASEURL,
SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY
};
/**
* 创建请求实例
*/
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
typeof VueHook,
typeof uniappRequestAdapter
>({
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized
},
handler: async () => {
try {
// await authLogin();
}
catch (error) {
// 切换到登录页
toLoginPage({ mode: 'reLaunch' })
throw error
}
},
},
})
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<typeof VueHook, typeof uniappRequestAdapter>({
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized;
},
handler: async () => {
try {
// await authLogin();
} catch (error) {
// 切换到登录页
toLoginPage({ mode: 'reLaunch' });
throw error;
}
}
}
});
/**
* alova 请求实例
*/
const alovaInstance = createAlova({
baseURL: API_DOMAINS.DEFAULT,
...AdapterUniapp(),
timeout: 5000,
statesHook: VueHook,
baseURL: API_DOMAINS.DEFAULT,
...AdapterUniapp(),
timeout: 5000,
statesHook: VueHook,
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
ContentType: ContentTypeEnum.JSON,
Accept: 'application/json, text/plain, */*',
...method.config.headers,
}
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
ContentType: ContentTypeEnum.JSON,
Accept: 'application/json, text/plain, */*',
...method.config.headers
};
const { config } = method
const ignoreAuth = !config.meta?.ignoreAuth
console.log('ignoreAuth===>', ignoreAuth)
// 处理认证信息 自行处理认证问题
if (ignoreAuth) {
const token = 'getToken()'
if (!token) {
throw new Error('[请求错误]:未登录')
}
// method.config.headers.token = token;
}
const { config } = method;
const ignoreAuth = !config.meta?.ignoreAuth;
console.log('ignoreAuth===>', ignoreAuth);
// 处理认证信息 自行处理认证问题
if (ignoreAuth) {
const token = 'getToken()';
if (!token) {
throw new Error('[请求错误]:未登录');
}
// method.config.headers.token = token;
}
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain
console.log('当前域名', method.baseURL)
}
}),
if (config.meta?.personalToken) {
config.headers['Authorization'] = `Bearer ${config.meta.personalToken}`;
}
responded: onResponseRefreshToken((response, method) => {
const { config } = method
const { requestType } = config
const {
statusCode,
data: rawData,
errMsg,
} = response as UniNamespace.RequestSuccessCallbackResult
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain;
console.log('当前域名', method.baseURL);
}
}),
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response
}
responded: onResponseRefreshToken((response, method) => {
console.log('response===>', response);
console.log('method===>', method);
const { config } = method;
const { requestType } = config;
const { statusCode, data: rawData, errMsg, header } = response as UniNamespace.RequestSuccessCallbackResult;
// 处理 HTTP 状态码错误
if (statusCode !== 200) {
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
console.error('errorMessage===>', errorMessage)
uni.showToast({
title: errorMessage,
icon: 'error',
})
throw new Error(`${errorMessage}${errMsg}`)
}
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response;
}
// 处理业务逻辑错误
const { code, message, data } = rawData as IResponse
// 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
if (config.meta?.toast !== false) {
uni.showToast({
title: message,
icon: 'none',
})
}
throw new Error(`请求错误[${code}]${message}`)
}
// 处理成功响应,返回业务数据
return data
}),
})
// 保存评论验证码 cookie
saveCommentCookies(method.url, header as Record<string, string> | undefined);
export const http = alovaInstance
// 分类加密密码处理(Halo 私密分类 401/403
// 注意:此处需在 alova 统一 401 刷新之前消费,避免分类密码请求被误判为登录过期
const isCategoryConsumed = handleCategoryPasswordError(statusCode, method.url, rawData as { status?: number });
if (isCategoryConsumed) {
throw new Error('分类访问受限');
}
// 处理 HTTP 状态码错误
if (statusCode !== 200) {
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`;
console.error('errorMessage===>', errorMessage);
uni.showToast({
title: errorMessage,
icon: 'error'
});
throw new Error(`${errorMessage}${errMsg}`);
}
// 归一化响应结构:Halo 来源的原始数据统一封装为 { code, data, message },
// 与标准接口走同一条解析路径,保证响应模型一致
let responseData: IResponse;
if (config.meta?.requestFrom === RequestFrom.Halo) {
responseData = {
code: ResultEnum.Success200,
data: rawData,
message: '请求成功'
};
} else {
responseData = rawData as IResponse;
}
return responseData;
})
});
export const http = alovaInstance;
+66 -60
View File
@@ -1,69 +1,75 @@
import type { CustomRequestOptions } from '@/http/types'
import { useTokenStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { stringifyQuery } from './tools/queryString'
import type { CustomRequestOptions } from '@/http/types';
import { useTokenStore } from '@/store';
import { getEnvBaseUrl } from '@/utils';
import { stringifyQuery } from './tools/queryString';
import qs from 'qs';
// 请求基准地址
const baseUrl = getEnvBaseUrl()
const baseUrl = getEnvBaseUrl();
// 拦截器配置
const httpInterceptor = {
// 拦截前触发
invoke(options: CustomRequestOptions) {
// 如果您使用了alova,则请把下面的代码放开注释
// alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
// return options
// 拦截前触发
invoke(options: CustomRequestOptions) {
// 如果您使用了alova,则请把下面的代码放开注释
// alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
// return options
// 非 alova 请求,正常执行
// 接口请求支持通过 query 参数配置 queryString
if (options.query) {
const queryStr = stringifyQuery(options.query)
if (options.url.includes('?')) {
options.url += `&${queryStr}`
}
else {
options.url += `?${queryStr}`
}
}
// 非 http 开头需拼接地址
if (!options.url.startsWith('http')) {
// #ifdef H5
if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
// 自动拼接代理前缀
options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url
}
else {
options.url = baseUrl + options.url
}
// #endif
// 非H5正常拼接
// #ifndef H5
options.url = baseUrl + options.url
// #endif
// TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
}
// 1. 请求超时
options.timeout = 60000 // 60s
// 2. (可选)添加小程序端请求头标识
options.header = {
...options.header,
}
// 3. 添加 token 请求头标识
const tokenStore = useTokenStore()
const token = tokenStore.updateNowTime().validToken
// 非 alova 请求,正常执行
// 接口请求支持通过 query 参数配置 queryString
if (options.query) {
const queryStr = stringifyQuery(options.query)
// const queryStr = qs.stringify(options.query, {
// allowDots: true,
// encodeValuesOnly: true,
// skipNulls: true,
// encode: true,
// arrayFormat: 'repeat'
// });
if (options.url.includes('?')) {
options.url += `&${queryStr}`;
} else {
options.url += `?${queryStr}`;
}
}
// 非 http 开头需拼接地址
if (!options.url.startsWith('http')) {
// #ifdef H5
if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
// 自动拼接代理前缀
options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url;
} else {
options.url = baseUrl + options.url;
}
// #endif
// 非H5正常拼接
// #ifndef H5
options.url = baseUrl + options.url;
// #endif
// TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
}
// 1. 请求超时
options.timeout = 60000; // 60s
// 2. (可选)添加小程序端请求头标识
options.header = {
...options.header
};
// 3. 添加 token 请求头标识
const tokenStore = useTokenStore();
const token = tokenStore.updateNowTime().validToken;
if (token) {
options.header.Authorization = `Bearer ${token}`
}
return options
},
}
if (token) {
options.header.Authorization = `Bearer ${token}`;
}
return options;
}
};
export const requestInterceptor = {
install() {
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)
},
}
install() {
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor);
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor);
}
};
+97
View File
@@ -0,0 +1,97 @@
/**
* 分类加密密码逻辑(源自旧项目 common/http/interceptors.js 的
* showCategoryInputPasswordModal / 401/403 分类密码分支)
* Halo 私密分类:请求分类文章接口 401/403 时触发密码输入
*/
import { delCache, setCache } from '@/utils/storage'
/** 分类密码缓存前缀 */
const CATEGORY_PWD_PREFIX = 'APP_CATEGORY_PWD_'
/** 分类文章接口路径特征(用于判断请求是否为分类相关) */
const CATEGORY_API_REG = /\/api\/content\/categories\/.+\/posts/
/**
* 从分类接口 URL 提取分类 code
*/
function getCategoryNameByUrl(url: string): string {
const reg = '(?<=/api/content/categories/).+(?=/posts)'
const m = url.match(reg)
return m?.[0] || '无分类名'
}
/**
* 分类密码输入弹窗
*/
function showCategoryInputPasswordModal(url: string, category: string): void {
uni.showModal({
title: `[ ${category} ] 分类已加密`,
content: '',
editable: true,
placeholderText: '请输入分类密码后访问',
confirmText: '验证密码',
cancelText: '暂不访问',
showCancel: true,
cancelColor: '#999999',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
if (!res.content) {
uni.showToast({
title: '提示:请输入密码',
icon: 'none',
success: () => {
setTimeout(() => {
showCategoryInputPasswordModal(url, category)
}, 800)
},
})
return
}
// 按分类 code 缓存密码,解决多分类加密时密码串用问题
setCache(CATEGORY_PWD_PREFIX + category, res.content)
uni.reLaunch({
url: '/pages/tabbar/category/category',
})
}
},
})
}
/**
* 处理分类加密相关 401/403 响应
* @param statusCode HTTP 状态码
* @param url 请求 URL
* @param data 响应体(含业务 status)
* @returns 是否已由本模块消费该错误(调用方应直接终止)
*/
export function handleCategoryPasswordError(statusCode: number, url: string, data: { status?: number }): boolean {
const isCategoryApi = CATEGORY_API_REG.test(url)
if (!isCategoryApi)
return false
const category = getCategoryNameByUrl(url)
if (statusCode === 401) {
// 密码错误:清除该分类密码,下次点击再弹窗
delCache(CATEGORY_PWD_PREFIX + category)
uni.showToast({
title: '提示:密码不正确',
icon: 'none',
success: () => {
setTimeout(() => {
showCategoryInputPasswordModal(url, category)
}, 800)
},
})
return true
}
if (statusCode === 403) {
// 私密分类,需要输入密码
showCategoryInputPasswordModal(url, category)
return true
}
return false
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 评论验证码 cookie 保存(源自旧项目 common/http/interceptors.js 的 saveCookies)
* 仅 APP/微信小程序端需要手动存取 cookie(H5 浏览器自动管理)
*/
import { setCache } from '@/utils/storage'
import { extractCookieItem, getHeaderCaseInsensitive } from '@/utils/cookies'
/** 评论验证码 cookie key */
export const COMMENT_WIDGET_CAPTCHA_COOKIES = 'comment-widget-captcha'
/** 需要保存 cookie 的评论相关接口 */
const MATCH_COMMENT_APIS = [
'/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate',
'/apis/api.halo.run/v1alpha1/comments',
]
/**
* 从响应头保存评论验证码 cookie
* @param url 请求地址
* @param header 响应头
*/
export function saveCommentCookies(url: string, header: Record<string, string> | undefined): void {
// app/小程序 将 cookies 存储起来
// #ifdef MP-WEIXIN || APP-PLUS
if (MATCH_COMMENT_APIS.some(api => url.includes(api))) {
const cookies = getHeaderCaseInsensitive(header, 'set-cookie')
if (cookies) {
setCache(COMMENT_WIDGET_CAPTCHA_COOKIES, extractCookieItem(cookies, COMMENT_WIDGET_CAPTCHA_COOKIES))
}
}
// #endif
}
+10
View File
@@ -30,6 +30,16 @@ export enum HttpErrorType {
Network = 'network',
}
/**
* 请求来源(通过请求 meta.requestFrom 标记,决定响应归一化方式)
* Halo: Halo 官方/插件接口,响应为原始数据(rawData),拦截器统一封装为 { code, data, message } 后透传
* Standard: 标准接口,响应本身即为 { code, data, message } 结构
*/
export enum RequestFrom {
Halo = 'halo',
Standard = 'standard',
}
export function isSuccessResultCode(code: number): boolean {
return [ResultEnum.Success0, ResultEnum.Success200].includes(code)
}
+16
View File
@@ -1,8 +1,24 @@
{
"tabbar.home": "Home",
"tabbar.category": "Category",
"tabbar.gallery": "Gallery",
"tabbar.moments": "Moments",
"tabbar.about": "About",
"tabbar.me": "Me",
"tabbar.i18n": "I18n",
"page.home.title": "Home",
"page.category.title": "Category",
"page.gallery.title": "Gallery",
"page.moments.title": "Moments",
"page.about.title": "About",
"common.loading": "Loading...",
"common.loadMore": "Pull up to load more",
"common.noMore": "No more data~",
"common.loadFailed": "Load failed, please pull down to refresh!",
"common.loadFailedShort": "Load failed!",
"common.loadFailedRetry": "Failed to load data, please try again!",
"common.empty": "No data",
"common.noMoreData": "No more data",
"i18n.title": "En Title",
"alova.title": "Alova Request",
"weight": "{heavy}KG",
+16
View File
@@ -1,8 +1,24 @@
{
"tabbar.home": "首页",
"tabbar.category": "分类",
"tabbar.gallery": "图库",
"tabbar.moments": "瞬间",
"tabbar.about": "关于",
"tabbar.me": "我的",
"tabbar.i18n": "多语言",
"page.home.title": "首页",
"page.category.title": "分类",
"page.gallery.title": "图库",
"page.moments.title": "瞬间",
"page.about.title": "关于",
"common.loading": "加载中...",
"common.loadMore": "上拉加载更多",
"common.noMore": "呜呜,没有更多数据啦~",
"common.loadFailed": "加载失败,请下拉刷新!",
"common.loadFailedShort": "加载失败!",
"common.loadFailedRetry": "数据加载失败,请重试!",
"common.empty": "暂无数据",
"common.noMoreData": "没有更多数据了",
"i18n.title": "中文标题",
"alova.title": "Alova 请求",
"weight": "{heavy}公斤",
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts" setup>
/**
* 关于项目页(源自旧项目 pagesA/about,新建复刻)
*/
import { onLoad } from '@dcloudio/uni-app'
definePage({
style: {
navigationBarTitleText: '关于项目',
},
})
const links = [
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!' },
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制' },
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制' },
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制' },
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制' },
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制' },
]
function copyText(content: string, tips: string) {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
onLoad(() => {
uni.setNavigationBarTitle({ title: '关于项目' })
})
</script>
<template>
<view class="app-page box-border h-screen w-screen flex flex-col items-center bg-white pt-10">
<view class="logo mt-10 pt-10">
<image class="logo-img h-[160rpx] w-[160rpx] rounded-xl" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
</view>
<view class="mt-3 text-[36rpx] font-bold">
uni-halo
</view>
<view class="list-group mt-12 w-full">
<view
v-for="link in links"
:key="link.title"
class="list-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7"
@click="copyText(link.copy, link.tip)"
>
<text class="list-title w-[160rpx] shrink-0 text-[28rpx] text-[#303133]">{{ link.title }}</text>
<text class="list-value max-w-[480rpx] overflow-hidden text-ellipsis whitespace-nowrap text-right text-[24rpx] text-[#909399]">{{ link.value }}</text>
</view>
</view>
<view class="copyright fixed bottom-0 left-0 box-border w-screen bg-white p-9 text-center text-[22rpx] text-[#909399]">
<view>根据 AGPL-3.0 协议开源</view>
<view class="mt-2">
2022 uni-halo 开源项目丨巷子工坊@小莫唐尼
</view>
</view>
</view>
</template>
+380
View File
@@ -0,0 +1,380 @@
<script lang="ts" setup>
/**
* 归档页(源自旧项目 pagesA/archives,新建复刻)
* 按月份/年份分组展示文章时间线
*/
import { computed, ref } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkThumbnailUrl } from '@/utils/url'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '归档',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const calcAuditModeEnabled = computed(() => !!appConfigStore.configs.auditConfig?.auditModeEnabled)
const mockJson = computed(() => appConfigStore.mockJson)
const globalAppSettings = computed(() => settingStore.settings)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const tab = ref({ activeIndex: 0, list: ['按月份查看', '按年份查看'] })
const queryParams = ref({ size: 10, page: 1 })
const result = ref<{ hasNext: boolean }>({ hasNext: false })
const cacheDataList = ref<IPost[]>([])
const dataList = ref<{
sort: number
key: string
year: string
month: string
posts: IPost[]
}[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref('加载中...')
const postLabelYearKey = 'content.halo.run/archive-year'
const postLabelMonthKey = 'content.halo.run/archive-month'
/* ---------------- 数据处理 ---------------- */
/** 按 tab 分组文章 */
function handleGetPosts(list: IPost[]): Record<string, IPost[]> {
const posts: Record<string, IPost[]> = {}
list.forEach((item) => {
const labels = item.metadata.labels || {}
let postItemKey = ''
if (tab.value.activeIndex === 0) {
postItemKey = `${labels[postLabelYearKey]}-${labels[postLabelMonthKey]}`
}
else {
postItemKey = `${labels[postLabelYearKey]}`
}
if (posts[postItemKey]) {
posts[postItemKey].push(item)
}
else {
posts[postItemKey] = [item]
}
})
return posts
}
/** 处理成显示数据(分组 + 排序) */
function handleGetShowDataList(posts: Record<string, IPost[]>): typeof dataList.value {
const listResult: typeof dataList.value = []
Object.keys(posts).forEach((key) => {
const postData = {
sort: 0,
key,
year: key,
month: '',
posts: posts[key],
}
if (tab.value.activeIndex === 0) {
const splitDate = key.split('-')
postData.year = splitDate[0]
postData.month = splitDate[1]
postData.sort = Number(key.replace('-', ''))
}
else {
postData.sort = Number(key)
}
listResult.push(postData)
})
listResult.sort((a, b) => Number(b.sort) - Number(a.sort))
return listResult
}
/** 去重缓存列表 */
function handleUniqueCacheDatalist(list: IPost[]): IPost[] {
const seen = new Set<string>()
return list.filter((item) => {
return seen.has(item.metadata.name) ? false : (seen.add(item.metadata.name), true)
})
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
const archivesMock = mockJson.value.archives as { list?: { time?: string, cover?: string, title?: string, desc?: string }[] } | undefined
const dataListMock: IPost[] = (archivesMock?.list || []).map((item) => {
const date = new Date(item.time || Date.now())
const year = date.getFullYear()
const month = date.getMonth() + 1
return {
metadata: {
name: String(Date.now() * Math.random()),
labels: {
[postLabelYearKey]: String(year),
[postLabelMonthKey]: String(month),
},
},
spec: {
title: item.title || '',
slug: '',
cover: item.cover,
pinned: false,
publishTime: item.time,
deleted: false,
publish: true,
allowComment: true,
visible: 'PUBLIC',
priority: 0,
categories: [],
tags: [],
},
status: { permalink: '', inProgress: false, excerpt: item.desc },
stats: { visit: 0 },
}
})
const posts = handleGetPosts(dataListMock)
dataList.value = handleGetShowDataList(posts)
cacheDataList.value = dataListMock
loading.value = 'success'
loadMoreText.value = '呜呜,没有更多数据啦~'
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
if (isLoadMore.value) {
uni.showLoading({ title: '加载中...' })
}
else {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const data = await getPostList({ ...queryParams.value })
result.value = { hasNext: data.hasNext }
const posts = handleGetPosts(data.items)
const showDataList = handleGetShowDataList(posts)
if (isLoadMore.value) {
cacheDataList.value = handleUniqueCacheDatalist([...cacheDataList.value, ...data.items])
// 合并增量数据
showDataList.forEach((item) => {
const find = dataList.value.find(x => x.key === item.key)
if (find) {
item.posts.forEach((post) => {
if (!find.posts.some(x => x.metadata.name === post.metadata.name)) {
find.posts.push(post)
}
})
}
})
showDataList.forEach((post) => {
if (!dataList.value.some(x => x.key === post.key)) {
dataList.value.push(post)
}
})
dataList.value.sort((a, b) => Number(b.sort) - Number(a.sort))
}
else {
dataList.value = showDataList
cacheDataList.value = data.items
}
loading.value = 'success'
loadMoreText.value = data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
function handleOnTabChange(index: number) {
tab.value.activeIndex = index
queryParams.value.page = 1
dataList.value = handleGetShowDataList(handleGetPosts(cacheDataList.value))
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
}
function handleToArticleDetail(article: IPost) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function formatTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
handleGetData()
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
return
}
if (result.value.hasNext) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<!-- 顶部 tab -->
<view class="archive-tabs fixed inset-x-0 top-0 z-6 bg-white">
<wd-tabs
v-model="tab.activeIndex"
:tabs="tab.list.map(title => ({ title }))"
align="center"
@change="handleOnTabChange"
/>
</view>
<view class="h-[90rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<!-- 内容区域 -->
<block v-else>
<view v-if="dataList.length === 0" class="list-empty h-screen w-screen flex items-center justify-center">
<wd-empty :description="calcAuditModeEnabled ? '暂无归档的内容' : '暂无归档的文章'" />
</view>
<view v-else class="timeline mt-6 px-6">
<view v-for="(item, index) in dataList" :key="item.key" class="timeline-item flex">
<view class="timeline-left w-[160rpx] flex shrink-0 flex-col items-center">
<view class="timeline-dot mt-1 h-6 w-6 rounded-full" style="background-color: #64b5f6; box-shadow: 0 4rpx 12rpx rgb(100 181 246 / 40%);" />
<view v-if="index !== dataList.length - 1" class="timeline-line mt-1 w-0.5 flex-1 bg-[#e0e0e0]" />
</view>
<view class="timeline-content flex-1 pb-12 pl-6">
<view class="time mb-6 flex items-center font-bold">
<text class="time-text text-[30rpx]">{{ item.year }}</text>
<text v-if="tab.activeIndex === 0" class="time-text text-[30rpx]">{{ item.month }}</text>
<text class="time-count ml-3 text-[22rpx] text-[#999] font-normal"> {{ item.posts.length }} {{ calcAuditModeEnabled ? '内容' : '文章' }}</text>
</view>
<view v-if="item.posts.length !== 0">
<view
v-for="post in item.posts"
:key="post.metadata.name"
class="post mb-6 flex rounded-xl bg-white p-6 shadow-sm"
:class="[globalAppSettings.layout.cardType]"
@click="handleToArticleDetail(post)"
>
<image class="post-thumbnail h-[170rpx] w-[200rpx] shrink-0 rounded-lg" :src="checkThumbnailUrl(post.spec.cover)" mode="aspectFill" lazy-load />
<view class="post-info w-0 flex-1 pl-5">
<view class="post-info-title text-overflow text-[28rpx] text-[#303133] font-bold">
{{ post.spec.title }}
</view>
<view class="post-info-summary line-clamp-2 mt-3 text-[24rpx] text-[#909399]">
{{ post.status?.excerpt }}
</view>
<view class="post-info-time mt-3 text-[24rpx] text-[#909399]">
发布时间{{ formatTime(post.spec.publishTime) }}
</view>
</view>
</view>
</view>
<view v-else class="post-empty py-6 text-[26rpx] text-[#909399]">
该日期下暂无归档文章
</view>
</view>
</view>
</view>
<view class="load-text pb-6 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.timeline {
.post {
&.tb_image_text,
&.tb_text_image {
flex-direction: column;
.post-thumbnail {
width: 100%;
height: 220rpx;
}
.post-info {
width: 100%;
padding-left: 0;
}
}
&.lr_text_image {
.post-thumbnail {
order: 2;
}
.post-info {
order: 1;
padding-left: 0;
padding-right: 24rpx;
}
}
&.only_text {
.post-thumbnail {
display: none;
}
.post-info {
padding: 6rpx;
}
}
}
}
</style>
@@ -0,0 +1,716 @@
<script lang="ts" setup>
/**
* 文章详情页(源自旧项目 pagesA/article-detail/article-detail.vue,新建复刻)
* 功能:文章头部(标题/作者/封面/统计) + 分类标签 + mp-html 内容渲染 + 受限阅读 + 点赞 + 评论
* TODO: 投票(article-vote)、豆瓣(article-douban)、分享海报(liu-poster)待阶段2/3 补充
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getPostByName, getPostCommentReplyList, postTrackersCounter, submitUpvote } from '@/api/halo'
import { createVerificationCode, requestRestrictReadCheck } from '@/api/uni-halo'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { RestrictReadType } from '@/api/types/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl, checkIsUrl } from '@/utils/url'
import { checkPostRestrictRead, copyToClipboard, getRestrictReadTypeName, getShowableContent } from '@/utils/restrictRead'
import { getDomainOnly } from '@/utils/urlParams'
import { markdownConfig } from '@/config/markdown'
import type { IComment, IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '内容详情',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryName = ref('')
const result = ref<IPost & {
_voteIds?: string[]
_doubanUrls?: string[]
owner?: { displayName?: string, avatar?: string }
stats?: { visit?: number, upvote?: number, comment?: number }
} | null>(null)
const showContentArr = ref<string[]>([])
const restrictReadInputCode = ref('')
const commentListScrollTop = ref(0)
const passwordModal = ref({ show: false })
const verificationCodeModal = ref({
show: false,
type: '',
imgUrl: '',
})
const commentModal = ref({
show: false,
isComment: false,
postName: '',
title: '',
})
const commentDetail = ref({
show: false,
loading: 'loading' as 'loading' | 'success' | 'error',
comment: {} as IComment,
postName: '',
list: [] as IComment[],
})
/* ---------------- 计算属性 ---------------- */
const postDetailConfig = computed(() => (haloConfigs.value.basicConfig as { postDetailConfig?: Record<string, unknown> } | undefined)?.postDetailConfig)
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(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcIsShowComment = computed(() => !!postDetailConfig.value?.showComment)
const doubanPluginConfig = computed(() => (haloConfigs.value.pluginConfig?.doubanPlugin as { position?: string } | undefined) || {})
/** 原文链接(annotation 配置) */
const originalURL = computed(() => result.value?.metadata.annotations?.unihalo_originalURL || '')
/* ---------------- 工具 ---------------- */
function calcUrl(url: string): string {
if (checkIsUrl(url))
return url
return import.meta.env.VITE_SERVER_BASEURL + url
}
/** 从 HTML 提取投票块 id */
function extractVoteBlockIds(html: string): string[] {
const regex = /<vote-block\s+id="(vote-\w+)"\s*\/?>/g
const ids: string[] = []
for (const match of html.matchAll(regex)) {
ids.push(match[1])
}
return ids
}
/** 从 HTML 提取豆瓣块 url */
function extractDoubanBlockUrls(html: string): string[] {
const regex = /<douban\s+src="([^"]+)"\s*\/?>/g
const urls: string[] = []
for (const match of html.matchAll(regex)) {
urls.push(match[1])
}
return urls
}
/** 移除内容中的 tag 链接 */
function removeTagLinksCompletely(html: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return html.replace(regex, '')
}
/** 获取 openid(微信端) */
function handleGetOpenid() {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: (loginRes) => {
try {
uni.setStorageSync('openid', loginRes.code)
}
catch (error) {
console.error(error)
}
},
})
// #endif
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getPostByName(queryName.value)
const tempResult = res.data as typeof result.value
if (tempResult) {
tempResult._voteIds = extractVoteBlockIds(res.data.content?.raw || res.data.content?.content || '')
tempResult._doubanUrls = extractDoubanBlockUrls(res.data.content?.raw || res.data.content?.content || '')
const openid = uni.getStorageSync('openid')
if (openid === '' || openid === null) {
handleGetOpenid()
}
// 受限阅读:拆分可展示内容
if (checkPostRestrictRead(res.data)) {
showContentArr.value = getShowableContent(res.data)
}
else {
showContentArr.value = []
}
}
result.value = tempResult
uni.setNavigationBarTitle({ title: '文章详情' })
loading.value = 'success'
handleTrackersCounter()
}
catch (err) {
console.error('获取文章失败', err)
loading.value = 'error'
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
/** 访问计数埋点 */
async function handleTrackersCounter() {
if (!result.value)
return
const winInfo = uni.getWindowInfo()
const appBaseInfo = uni.getAppBaseInfo()
const baseUrl = import.meta.env.VITE_SERVER_BASEURL || ''
try {
await postTrackersCounter({
group: 'content.halo.run',
plural: 'posts',
name: result.value.metadata.name,
hostname: getDomainOnly(baseUrl),
screen: `${winInfo.screenWidth}x${winInfo.screenHeight}`,
language: appBaseInfo.language,
url: `/archives/${baseUrl}`,
referrer: `${baseUrl}/`,
})
}
catch (err) {
console.error('埋点失败', err)
}
}
/* ---------------- 点赞 ---------------- */
const upvotedNames = ref<string[]>([])
function hasUpvoted(): boolean {
return upvotedNames.value.includes(result.value?.metadata.name || '')
}
async function handleDoLikes() {
if (!result.value)
return
if (hasUpvoted()) {
uni.showToast({ icon: 'none', title: '已经点过赞啦!' })
return
}
try {
await submitUpvote({
group: 'content.halo.run',
plural: 'posts',
name: result.value.metadata.name,
})
uni.showToast({ icon: 'none', title: '点赞成功!' })
upvotedNames.value.push(result.value.metadata.name)
if (result.value.stats) {
result.value.stats.upvote = (result.value.stats.upvote || 0) + 1
}
}
catch (err) {
console.error('点赞失败', err)
uni.showToast({ icon: 'none', title: '点赞失败' })
}
}
/* ---------------- 受限阅读 ---------------- */
function readMore() {
const annotations = result.value?.metadata?.annotations
const restrictReadEnable = annotations?.restrictReadEnable
if (restrictReadEnable === 'password') {
passwordModal.value.show = true
}
else if (restrictReadEnable === 'code') {
verificationCodeModal.value.show = true
verificationCodeModal.value.type = 'scan'
verificationCodeModal.value.imgUrl = checkImageUrl((haloConfigs.value.pluginConfig?.toolsPlugin as { scanCodeUrl?: string } | undefined)?.scanCodeUrl)
}
else if (restrictReadEnable === 'comment') {
handleToComment()
}
else if (restrictReadEnable === 'login') {
uni.showToast({ title: '前往web端登录后访问', icon: 'none' })
}
else if (restrictReadEnable === 'pay') {
uni.showToast({ title: '前往web端支付后访问', icon: 'none' })
}
// 两秒后复制原文链接
setTimeout(() => {
if (result.value?.status?.permalink) {
copyToClipboard(import.meta.env.VITE_SERVER_BASEURL + result.value.status.permalink)
}
}, 2000)
}
/** 校验密码/验证码 */
async function restrictReadCheck() {
if (!result.value)
return
if (!restrictReadInputCode.value) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
try {
const res = await requestRestrictReadCheck(
(result.value.metadata.annotations?.restrictReadEnable || 'password') as RestrictReadType,
restrictReadInputCode.value,
result.value.metadata.name,
)
if (res.code === 200) {
passwordModal.value.show = false
verificationCodeModal.value.show = false
handleGetData()
}
else {
uni.showToast({ title: '密码错误', icon: 'none' })
}
}
catch (err) {
console.error(err)
}
}
/** 获取验证码(受限阅读 code 模式) */
async function getVerificationCode() {
uni.showLoading({ title: '正在获取...' })
try {
const res = await createVerificationCode()
if (res.code === 200) {
verificationCodeModal.value.show = false
restrictReadInputCode.value = res.data as string || ''
restrictReadCheck()
}
else {
uni.showToast({ icon: 'none', title: '操作失败,请重试!' })
}
}
catch (err) {
uni.showToast({ icon: 'none', title: (err as Error).message || '操作失败' })
}
finally {
uni.hideLoading()
}
}
/* ---------------- 评论 ---------------- */
function handleToComment() {
if (!result.value)
return
if (!calcIsShowComment.value)
return
if (!result.value.spec.allowComment) {
uni.showToast({ icon: 'none', title: '文章已开启禁止评论!' })
return
}
commentModal.value = {
show: true,
isComment: true,
postName: result.value.metadata.name,
title: '新增评论',
}
}
function handleOnComment(data: { isComment: boolean, postName: string, title: string }) {
commentModal.value = {
show: true,
isComment: data.isComment,
postName: data.postName,
title: data.title,
}
}
function handleOnCommentModalClose(data: { refresh: boolean, isSubmit: boolean }) {
if (result.value?.metadata.annotations?.restrictReadEnable === 'comment') {
handleGetData()
}
if (data.refresh && data.isSubmit) {
// 评论成功后刷新(通过 uni.$emit 广播给 comment-list)
uni.$emit('comment_list_refresh')
}
commentModal.value.show = false
}
function handleOnShowCommentDetail(data: { postName: string, comment: IComment }) {
commentDetail.value = {
show: true,
loading: 'loading',
comment: data.comment,
postName: data.postName,
list: [],
}
}
async function handleGetChildComments() {
commentDetail.value.loading = 'loading'
try {
const res = await getPostCommentReplyList(commentDetail.value.postName, {
page: 1,
size: 100,
})
commentDetail.value.loading = 'success'
commentDetail.value.list = res.data.items
}
catch (err) {
console.error(err)
commentDetail.value.loading = 'error'
}
}
/* ---------------- 跳转 ---------------- */
function handleToCate(category: { metadata: { name: string }, spec: { displayName: string } }) {
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToTag(tag: { metadata: { name: string }, spec: { displayName: string } }) {
uni.navigateTo({
url: `/pages-blog/tag-detail/tag-detail?name=${tag.metadata.name}&title=${tag.spec.displayName}`,
})
}
function handleToWebview(data: { title: string, url: string }) {
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: data.title,
url: encodeURIComponent(data.url),
})}`,
})
}
function handleToOriginal(originalURLValue: string) {
handleToWebview({
title: result.value?.spec.title || '',
url: originalURLValue,
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
/* ---------------- 格式化 ---------------- */
function formatPublishTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
uni.setNavigationBarTitle({ title: '文章加载中...' })
queryName.value = options?.name || ''
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
onShareAppMessage(() => {
const cover = result.value?.spec.cover ? calcUrl(result.value.spec.cover) : ''
return {
path: `/pages-blog/article-detail/article-detail?name=${result.value?.metadata.name}`,
title: result.value?.spec.title || '',
imageUrl: cover,
}
})
onShareTimeline(() => {
const cover = result.value?.spec.cover ? calcUrl(result.value.spec.cover) : ''
return {
title: result.value?.spec.title || '',
query: result.value ? `name=${result.value.metadata.name}` : '',
imageUrl: cover,
}
})
watch(haloConfigs, () => {
// 配置就绪后触发
}, { deep: true })
const globalAppSettings = computed(() => settingStore.settings)
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-[120rpx]" style="background-color: #fafafd;">
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap bg-white p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<!-- 顶部信息 -->
<view class="head mx-6 mt-6 flex flex-col items-center rounded-xl bg-white px-6 py-9 shadow-sm">
<view class="title text-center text-[36rpx] font-semibold">
{{ result?.spec.title }}
</view>
<view class="detail mt-6 w-full text-[26rpx]">
<view class="author text-center text-[24rpx] text-[#666]">
<text class="author-name">作者{{ result?.owner?.displayName || bloggerInfo.nickname }}</text>
<text class="author-time ml-9">时间{{ formatPublishTime(result?.spec.publishTime) }}</text>
</view>
<view v-if="result?.spec.cover" class="cover mt-6 h-[280rpx] w-full">
<image
class="cover-img h-full w-full rounded-xl"
mode="aspectFill"
:src="calcUrl(result.spec.cover)"
@click="handlePreview(0, [{ url: calcUrl(result.spec.cover) }])"
/>
</view>
<view class="count mt-6 flex justify-between" :class="{ 'no-thumbnail border-t-2 border-[#f2f2f2] pt-3': !result?.spec.cover }">
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.stats?.visit ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">阅读</text>
</view>
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.stats?.upvote ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">喜欢</text>
</view>
<view v-if="calcIsShowComment" class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.stats?.comment ?? 0 }}</text>
<text class="label pl-2 text-[24rpx]">评论</text>
</view>
<view class="count-item flex flex-1 items-end justify-center text-[#666]">
<text class="value text-[32rpx]">{{ result?.content?.raw.length || 0 }}</text>
<text class="label pl-2 text-[24rpx]">字数</text>
</view>
</view>
</view>
</view>
<!-- 分类标签 -->
<view class="category mx-6 mt-6 rounded-xl bg-white p-6 text-[28rpx] shadow-sm">
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">分类</text>
<text v-if="!result?.categories?.length" class="category-tag is-empty rounded-md bg-[#607d8b] px-1.5 py-0.5 text-[24rpx] text-white">未选择分类</text>
<text v-for="(item, index) in result?.categories" v-else :key="index" class="category-tag mr-3 rounded-md bg-[#5bb8fa] px-1.5 py-0.5 text-[24rpx] text-white" @click="handleToCate(item)">
{{ item.spec.displayName }}
</text>
</view>
<view class="category-type leading-[55rpx]">
<text class="category-label font-bold">标签</text>
<text v-if="!result?.tags?.length" class="category-tag is-empty rounded-md bg-[#607d8b] px-1.5 py-0.5 text-[24rpx] text-white">未选择标签</text>
<text
v-for="(item, index) in result?.tags"
v-else
:key="index"
class="category-tag mr-3 rounded-md px-1.5 py-0.5 text-[24rpx] text-white"
:style="{ backgroundColor: item.spec.color || '#5bb8fa' }"
@click="handleToTag(item)"
>
{{ item.spec.displayName }}
</text>
</view>
<view v-if="originalURL" class="category-type flex leading-[55rpx]">
<view class="original-url-left w-[84rpx] shrink-0 font-bold">
原文
</view>
<view class="original-url-right inline-flex flex-1 items-center">
<text class="original-url-link inline-block w-[410rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[#909399]" @click.stop="handleToOriginal(originalURL)">{{ originalURL }}</text>
<text class="original-url-btn flex-1 text-right text-[#03a9f4]" @click.stop="handleToOriginal(originalURL)">阅读原文</text>
</view>
</view>
</view>
<!-- 内容区域 -->
<view class="content mx-6 mt-6">
<view class="markdown-wrap overflow-hidden rounded-xl bg-white p-1.5 shadow-sm">
<!-- 受限阅读 -->
<template v-if="checkPostRestrictRead(result!)">
<view v-if="showContentArr.length === 0">
<uh-restrict-read-skeleton
:loading="true"
:lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)"
button-color="#1890ff"
@refresh="readMore"
/>
</view>
<view v-for="(showContent, showContentIndex) in showContentArr" v-else :key="showContentIndex">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="showContent"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
<uh-restrict-read-skeleton
:loading="true"
:lines="3"
:tip-text="`此处内容已隐藏,「${getRestrictReadTypeName(result!)}可见」`"
:button-text="getRestrictReadTypeName(result!)"
button-color="#1890ff"
@refresh="readMore"
/>
</view>
</template>
<!-- 正常渲染 -->
<template v-else>
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="result?.content?.raw || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</template>
</view>
<!-- 版权声明 -->
<view v-if="postDetailConfig?.copyrightEnabled" class="card-wrap mt-6 rounded-xl bg-white p-6 shadow-sm">
<view class="card-title relative box-border pl-6 text-[30rpx] font-bold">
<text class="absolute left-0 top-2 h-[26rpx] w-2 rounded-lg bg-[#03aefc]" />
版权声明
</view>
<view class="copyright-content mt-3 rounded-xl bg-[#fafafa] px-6 py-1.5">
<view v-if="postDetailConfig.copyrightAuthor" class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]">
版权归属{{ postDetailConfig.copyrightAuthor }}
</view>
<view v-if="postDetailConfig.copyrightDesc" class="copyright-text text-[26rpx] text-[#606266] leading-[1.7]">
版权说明{{ postDetailConfig.copyrightDesc }}
</view>
<view v-if="postDetailConfig.copyrightViolation" class="copyright-text text-[26rpx] text-[#f56c6c] leading-[1.7]">
侵权处理{{ postDetailConfig.copyrightViolation }}
</view>
</view>
</view>
<!-- 评论区域 -->
<view v-if="calcIsShowComment && result" class="card-wrap mt-6 rounded-xl bg-white p-6 shadow-sm">
<uh-comment-list
:disallow-comment="!result.spec.allowComment"
:post-name="result.metadata.name"
:post="result"
@on-comment="handleOnComment"
@on-comment-detail="handleOnShowCommentDetail"
/>
</view>
</view>
<!-- 悬浮操作 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" :class="{ active: hasUpvoted() }" @click="handleDoLikes">
<wd-icon :name="hasUpvoted() ? 'heart' : 'heart-outline'" size="20px" :color="hasUpvoted() ? '#f44336' : '#03a9f4'" />
</view>
<view v-if="calcIsShowComment" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToComment">
<wd-icon name="chat" size="20px" color="#4caf50" />
</view>
</view>
</block>
<!-- 密码弹窗 -->
<wd-dialog
v-model="passwordModal.show"
title="验证提示"
:show-cancel="true"
show-confirm-button
confirm-text="确定"
@confirm="restrictReadCheck"
>
<view class="modal-body py-4">
<wd-input v-model="restrictReadInputCode" placeholder="请输入密码" />
</view>
</wd-dialog>
<!-- 验证码弹窗 -->
<wd-dialog
v-model="verificationCodeModal.show"
title="验证提示"
:show-cancel="true"
confirm-text="确定"
@confirm="restrictReadCheck"
>
<view class="modal-body py-4">
<image v-if="verificationCodeModal.imgUrl" :src="verificationCodeModal.imgUrl" class="modal-code-img mb-4 h-[200rpx] w-full" mode="aspectFit" />
<wd-input v-model="restrictReadInputCode" placeholder="请输入验证码" class="mt-2" />
</view>
</wd-dialog>
<!-- 评论弹窗 -->
<uh-comment-modal
v-if="commentModal.show"
:show="commentModal.show"
:is-comment="commentModal.isComment"
:title="commentModal.title"
:post-name="commentModal.postName"
@on-close="handleOnCommentModalClose"
/>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.head {
.detail {
.author {
.author-time {
margin-left: 36rpx;
}
}
}
}
.fab-btn {
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
&.active {
background-color: #fef0f0;
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script lang="ts" setup>
/**
* 内容搜索页(源自旧项目 pagesA/articles,新建复刻)
* 功能:关键词搜索文章/瞬间,结果列表展示
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getPostListByKeyword } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
definePage({
style: {
navigationBarTitleText: '内容搜索',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const calcAuditModeEnabled = computed(() => !!appConfigStore.configs.auditConfig?.auditModeEnabled)
/** 依赖插件(plugin-search-widget) */
const uniHaloPluginId = 'plugin-search-widget'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({
keyword: '',
limit: 50,
highlightPreTag: '',
highlightPostTag: '',
})
const dataList = ref<{
metadataName?: string
type?: string
title?: string
description?: string
content?: string
updateTimestamp?: string
}[]>([])
/* ---------------- 搜索 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value)
return
loading.value = 'loading'
try {
const res = await getPostListByKeyword({ ...queryParams.value })
loading.value = 'success'
dataList.value = (res.data as unknown as { hits?: typeof dataList.value }).hits || []
}
catch (err) {
console.error(err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 800)
}
}
function handleOnSearch() {
if (!queryParams.value.keyword) {
dataList.value = []
loading.value = 'success'
}
else {
handleGetData()
}
}
function isArticle(item: { type?: string }): boolean {
return item.type === 'post.content.halo.run'
}
function handleToDetail(item: { metadataName?: string, type?: string }) {
if (calcAuditModeEnabled.value)
return
if (isArticle(item)) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${item.metadataName}`,
animationType: 'slide-in-right',
})
}
else {
uni.navigateTo({
url: `/pages-blog/moment-detail/moment-detail?name=${item.metadataName}`,
animationType: 'slide-in-right',
})
}
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
if (!queryParams.value.keyword) {
loading.value = 'success'
}
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleOnSearch()
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用搜索功能哦请联系管理员"
@on-refresh="handleOnSearch"
/>
<template v-else>
<!-- 顶部搜索框 -->
<view class="search-bar fixed inset-x-0 top-0 z-6 bg-white px-3 py-2 shadow-sm">
<view class="search-input h-[68rpx] flex items-center gap-3 rounded-[34rpx] bg-[#f5f5f5] px-6">
<wd-icon name="search" size="16px" color="#999" />
<input
v-model="queryParams.keyword"
class="search-field flex-1 text-[26rpx]"
placeholder="搜索内容..."
confirm-type="search"
@confirm="handleOnSearch"
>
<view v-if="queryParams.keyword" class="clear-btn flex items-center" @click="queryParams.keyword = ''; handleOnSearch()">
<wd-icon name="close" size="14px" color="#999" />
</view>
</view>
</view>
<view class="h-[100rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="搜索异常" />
</view>
<!-- 内容区域 -->
<view v-else class="content pt-6">
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty v-if="!queryParams.keyword" description="请输入关键词搜索" />
<wd-empty v-else :description="`未搜到 ${queryParams.keyword} 相关内容`" />
</view>
<block v-else>
<view v-for="(item, index) in dataList" :key="index" class="article-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm" @click="handleToDetail(item)">
<view class="card-head mb-3 flex items-center">
<view class="type-tag mr-3 shrink-0 rounded-md px-1.5 py-0.5 text-[22rpx] text-white" :class="isArticle(item) ? 'bg-[#2196f3]' : 'bg-[#4caf50]'">
{{ isArticle(item) ? '文章' : '瞬间' }}
</view>
<text class="card-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[28rpx] text-[#333] font-bold">{{ item.title }}</text>
</view>
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="item.description || item.content || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
<view class="card-foot mt-3 flex items-center">
<text class="text-[24rpx] text-[#888]">{{ item.updateTimestamp ? `最近更新:${formatTimeUtil({ d: item.updateTimestamp, f: 'yyyy年MM月dd日 HH点mm分ss秒' })}` : '' }}</text>
</view>
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
@@ -0,0 +1,142 @@
<script lang="ts" setup>
/**
* 分类详情页(源自旧项目 pagesA/category-detail,新建复刻)
* 展示某分类下的文章列表,分页加载
*/
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getCategoryPostList } from '@/api/halo'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '分类详情',
enablePullDownRefresh: true,
},
})
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 0 })
const name = ref('')
const pageTitle = ref('加载中...')
const hasNext = ref(false)
const dataList = ref<IPost[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref('')
async function handleGetData() {
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const res = await getCategoryPostList(name.value, { ...queryParams.value })
uni.setNavigationBarTitle({ title: `${pageTitle.value} (共${res.data.total}篇)` })
hasNext.value = res.data.hasNext
dataList.value = isLoadMore.value
? dataList.value.concat(res.data.items)
: res.data.items
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
setTimeout(() => {
loading.value = 'success'
}, 500)
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
}
function handleToArticleDetail(article: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
onLoad((options) => {
name.value = options?.name || ''
pageTitle.value = options?.title || '分类详情'
handleGetData()
})
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 0
handleGetData()
})
onReachBottom(() => {
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
onShareAppMessage(() => ({
title: pageTitle.value,
path: `/pages-blog/category-detail/category-detail?name=${name.value}&title=${pageTitle.value}`,
}))
onShareTimeline(() => ({
title: pageTitle.value,
path: `/pages-blog/category-detail/category-detail?name=${name.value}&title=${pageTitle.value}`,
}))
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="dataList.length === 0" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="该分类下暂无文章" />
</view>
<block v-else>
<uh-article-card
v-for="(article, index) in dataList"
:key="index"
:article="article"
@on-click="handleToArticleDetail"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts" setup>
/**
* 联系博主页(源自旧项目 pagesA/contact,新建复刻)
* 展示博主社交联系方式,点击复制
*/
import { computed, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '联系博主',
},
})
const appConfigStore = useAppConfigStore()
const authorConfig = computed(() => appConfigStore.configs.authorConfig)
const bloggerInfo = computed(() => {
const blogger = authorConfig.value?.blogger as { nickname?: string, avatar?: string, description?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
description: blogger?.description || '',
}
})
const socialConfig = computed(() => (authorConfig.value?.social as Record<string, unknown> | undefined) || {})
/** 联系方式列表(从配置填充) */
const result = ref<{ key: string, name: string, value: string }[]>([
{ key: 'qq', name: '企鹅号', value: '' },
{ key: 'wechat', name: '微信号', value: '' },
{ key: 'github', name: 'Github', value: '' },
{ key: 'gitee', name: 'Gitee', value: '' },
{ key: 'bilibili', name: 'Bilibili', value: '' },
{ key: 'csdn', name: 'CSDN', value: '' },
{ key: 'blog', name: '博客地址', value: '' },
{ key: 'juejin', name: '掘金地址', value: '' },
{ key: 'weibo', name: '微博地址', value: '' },
{ key: 'email', name: '邮箱地址', value: '' },
])
const calcIsNotEmpty = computed(() => result.value.some(item => item.value !== ''))
function handleGetData() {
for (const key in socialConfig.value) {
if (key === 'enabled')
continue
const item = result.value.find(x => x.key === key)
if (item) {
item.value = String(socialConfig.value[key] || '')
}
}
}
function handleOnClick(item: { value: string, name: string }) {
if (!item.value)
return
uni.setClipboardData({
data: item.value,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: `${item.name} 已复制!` })
},
})
}
watch(socialConfig, () => {
handleGetData()
}, { deep: true, immediate: true })
onLoad(() => {
uni.setNavigationBarTitle({ title: '联系博主' })
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col items-center bg-white pt-[160rpx]">
<!-- 博主信息 -->
<view class="profile flex flex-col items-center p-9">
<view class="avatar relative box-border h-[170rpx] w-[170rpx] overflow-hidden border-6 border-white rounded-full shadow-sm">
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
</view>
<view class="nickname mt-6 text-[38rpx] font-bold">
{{ bloggerInfo.nickname }}
</view>
<view class="desc mt-6 text-center text-[26rpx] text-[#666]">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
</view>
<!-- 联系方式列表 -->
<view class="contact box-border w-full px-12 pt-12" style="border-top: 2rpx solid #f2f2f2;">
<block v-if="calcIsNotEmpty">
<view
v-for="item in result.filter(i => i.value)"
:key="item.key"
class="item mt-6 box-border rounded-xl bg-[#fafafa] p-4"
@click="handleOnClick(item)"
>
<view class="left box-border w-[160rpx] flex items-center">
<text class="name text-[24rpx] text-[#555]">{{ item.name }}</text>
</view>
<view class="right box-border w-0 flex flex-1 flex-wrap items-center break-all pl-3 text-[24rpx] text-[#333]">
{{ item.value }}
</view>
</view>
</block>
<view v-else class="empty pt-12">
<wd-empty description="暂无联系方式" />
</view>
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script lang="ts" setup>
/**
* 数据看板页(源自旧项目 pagesA/data-visual,新建复刻)
* 标签统计/分类统计(环形图)、文章发布趋势(热度图)、评论活跃用户(柱状图)、热门文章 Top10(柱状图)
*/
import { ref } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getChartData } from '@/api/uni-halo'
import { usePluginAvailable } from '@/utils/plugin'
import type { IDataStatistics } from '@/api/uni-halo'
definePage({
style: {
navigationBarTitleText: '数据看板',
enablePullDownRefresh: true,
},
})
/** 依赖插件(plugin-data-statistics) */
const uniHaloPluginId = 'plugin-data-statistics'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const statistics = ref<IDataStatistics>({
tags: [],
categories: [],
articles: [],
comments: [],
top10Articles: [],
})
/* ---------------- 图表配置 ---------------- */
const chartColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#ea7ccc', '#0EA5E9']
/** 标签统计(环形图) */
const tagChart = ref({
isExpand: true,
type: 'ring',
data: { series: [{ data: [] as { name: string, value: number }[] }] },
})
/** 分类统计(柱状图) */
const categoryChart = ref({
isExpand: true,
type: 'column',
data: { categories: [] as string[], series: [{ name: '分类', data: [] as number[] }] },
})
/** 文章发布趋势(热度图) */
const trandArticleChart = ref({
isExpand: true,
type: 'hotmap',
data: [] as { date: string, count: number }[],
})
/** 评论活跃用户(柱状图) */
const userCommentsChart = ref({
isExpand: true,
type: 'column',
data: { categories: [] as string[], series: [{ name: '评论', data: [] as number[] }] },
})
/** 热门文章 Top10(柱状图) */
const top10ArticlesChart = ref({
isExpand: true,
type: 'column',
data: { categories: [] as string[], series: [{ name: '访问量', data: [] as number[] }] },
})
/* ---------------- 数据处理 ---------------- */
function handleTagChart() {
const data = [...statistics.value.tags].sort((a, b) => b.count - a.count)
tagChart.value.data = {
series: [
{
data: data.map(item => ({ name: item.name, value: item.count })),
},
],
}
}
function handleCategoriesChart() {
const data = [...statistics.value.categories].sort((a, b) => b.total - a.total)
categoryChart.value.data = {
categories: data.map(item => item.name),
series: [{ name: '分类', data: data.map(item => item.total) }],
}
}
function handleTrendArticlesChart() {
trandArticleChart.value.data = statistics.value.articles.map(item => ({
date: item.date,
count: item.count,
}))
}
function handleUserCommentsChart() {
const data = [...statistics.value.comments].sort((a, b) => b.count - a.count).slice(0, 10)
userCommentsChart.value.data = {
categories: data.map(item => item.username),
series: [{ name: '评论', data: data.map(item => item.count) }],
}
}
function handleTop10ArticlesChart() {
const data = [...statistics.value.top10Articles].sort((a, b) => b.views - a.views).slice(0, 10)
top10ArticlesChart.value.data = {
categories: data.map(item => item.name),
series: [{ name: '访问量', data: data.map(item => item.views) }],
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
uni.showLoading({ mask: true, title: '加载中...' })
loading.value = 'loading'
try {
const res = await getChartData()
statistics.value = res.data
handleTagChart()
handleCategoriesChart()
handleTrendArticlesChart()
handleUserCommentsChart()
handleTop10ArticlesChart()
loading.value = 'success'
}
catch (err) {
console.error(err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 100)
}
}
/* ---------------- 生命周期 ---------------- */
async function init() {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
}
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
init()
</script>
<template>
<view class="app-page box-border min-h-screen w-screen p-6 text-[#353437]" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="阿偶检测到当前插件没有安装或者启用无法使用功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<!-- 加载/错误 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="加载异常" />
</view>
<!-- 内容区域 -->
<view v-else class="content flex flex-col gap-6">
<!-- 标签统计 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="tagChart.isExpand = !tagChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">标签统计</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">全部标签的文章数量占比</text>
</view>
<wd-icon :name="tagChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="tagChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="ring"
:chart-data="tagChart.data"
:opts="{ color: chartColors, padding: [5, 5, 5, 5], dataLabel: false, legend: { show: false }, extra: { ring: { ringWidth: 36, offsetAngle: -90, border: true, borderWidth: 1, borderColor: '#FFFFFF' } } }"
/>
</view>
</view>
<!-- 分类统计 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="categoryChart.isExpand = !categoryChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">分类统计</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">全部分类的文章数量占比</text>
</view>
<wd-icon :name="categoryChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="categoryChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="column"
:chart-data="categoryChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 15], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 6 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view>
</view>
<!-- 文章发布趋势 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="trandArticleChart.isExpand = !trandArticleChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">文章发布趋势</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按日期统计文章发布数量</text>
</view>
<wd-icon :name="trandArticleChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="trandArticleChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<uh-heatmap :chart-data="trandArticleChart.data" />
</view>
</view>
<!-- 评论活跃用户 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head flex items-center justify-between" @click="userCommentsChart.isExpand = !userCommentsChart.isExpand">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">评论活跃用户</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按评论作者统计评论数量</text>
</view>
<wd-icon :name="userCommentsChart.isExpand ? 'arrow-up' : 'arrow-down'" size="16px" color="#909399" />
</view>
<view v-show="userCommentsChart.isExpand" class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="column"
:chart-data="userCommentsChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view>
</view>
<!-- 热门文章 Top10 -->
<view class="card box-border rounded-xl bg-white/95 p-6" style="box-shadow: 0 0 12rpx rgb(226 232 240 / 35%);">
<view class="card-head">
<view class="card-head-title flex items-baseline gap-2">
<text class="card-head-text relative box-border pl-6 text-[30rpx] font-bold">热门文章前10</text>
<text class="card-head-subtext text-[26rpx] text-[#6b7280] font-normal">按访问量排序的热门文章</text>
</view>
</view>
<view class="card-body mt-6 box-border w-full overflow-hidden border-2 border-[#e9eef3] rounded-xl bg-[#fcfdfe] p-3">
<qiun-data-charts
type="column"
:chart-data="top10ArticlesChart.data"
:opts="{ color: chartColors, padding: [20, 15, 10, 10], legend: { show: false }, xAxis: { disableGrid: true, fontSize: 10, itemCount: 5 }, yAxis: { gridType: 'dash', dashLength: 4 }, extra: { column: { type: 'group', width: 22, linearType: 'custom', seriesGap: 5, barBorderCircle: true, customColor: ['#F59E0B'] } } }"
/>
</view>
</view>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.card-head-text {
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 70%;
background-color: #03a9f4;
border-radius: 12rpx;
}
}
</style>
@@ -0,0 +1,82 @@
<script lang="ts" setup>
/**
* 免责声明页(源自旧项目 pagesA/disclaimers,新建复刻)
*/
import { computed } from 'vue'
import { useAppConfigStore } from '@/store/appConfig'
definePage({
style: {
navigationBarTitleText: '免责声明',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const disclaimersContent = computed(() => {
const basicConfig = haloConfigs.value.basicConfig as { disclaimers?: { content?: string } } | undefined
return basicConfig?.disclaimers?.content || ''
})
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, email?: string } | undefined
return {
nickname: blogger?.nickname || '',
email: blogger?.email || '',
}
})
function copyText(content: string, tips = '复制成功') {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
</script>
<template>
<view class="app-page box-border min-h-screen bg-white p-12 text-[30rpx] text-[#303133] leading-[1.65]">
<!-- 通过配置 -->
<view v-if="disclaimersContent" style="min-height: 100%;" v-html="disclaimersContent" />
<!-- 静态写法 -->
<block v-else>
<view class="title text-center text-[34rpx] font-bold">
本博客免责声明
</view>
<view class="item mt-6">
1本博客属于个人非盈利性质的网站所有转载的文章都以遵循原作者的版权声明注明了文章来源
</view>
<view class="item mt-6">
2如果原文没有版权声明按照目前互联网开放的原则本博客将在不通知作者的情况下转载文章
</view>
<view class="item mt-6">
3如果原文明确注明"禁止转载"本博客将不会转载
</view>
<view class="item mt-6">
4如果本博客转载的文章不符合作者的版权声明或者作者不想让本博客转载您的文章请邮件告知
<text class="email mx-3 text-[#03a9f4]" @click="copyText(bloggerInfo.email, '电子邮箱已复制到剪贴板!')">{{ bloggerInfo.email }}</text>
博主将会在第一时间删除相关信息
</view>
<view class="item mt-6">
5本博客转载文章仅为留做备份和知识点分享的目的
</view>
<view class="item mt-6">
6本博客将尽力确保所提供信息的准确性及可靠性但不保证信息的正确性和完整性且不对因信息的不正确或遗漏导致的任何损失或损害承担相关责任
</view>
<view class="item mt-6">
7本博客所发布转载的文章其版权均归原作者所有如其他自媒体网站或个人从本博客下载使用请在转载有关文章时务必尊重该文章的著作权保留本博客注明的"原文来源"或者自行去原文处复制版权声明并自负版权等法律责任
</view>
<view class="item mt-6">
8本博客的所有原创文章皆可以任意转载但转载时务必请注明出处
</view>
<view class="item mt-6">
9尊重原创知识共享
</view>
</block>
</view>
</template>
@@ -0,0 +1,274 @@
<script lang="ts" setup>
/**
* 友情链接页(源自旧项目 pagesA/friend-links,新建复刻)
* 展示友链列表(色彩版/简洁版),支持分组名解析、详情弹窗、申请入口
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getFriendLinkGroupList, getFriendLinkList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { usePluginAvailable } from '@/utils/plugin'
import type { ILink, ILinkGroup } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '友情链接',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloPluginConfigs = computed(() => appConfigStore.configs.pluginConfig)
const globalAppSettings = computed(() => settingStore.settings)
/** 依赖插件(plugin-links) */
const uniHaloPluginId = 'plugin-links'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const detail = ref<{ show: boolean, data: ILink | null }>({ show: false, data: null })
const hasNext = ref(false)
const isLoadMore = ref(false)
const loadMoreText = ref('')
const linkGroupList = ref<ILinkGroup[]>([])
const dataList = ref<ILink[]>([])
/* ---------------- 数据加载 ---------------- */
function findLinkGroupDisplayNameByGroupMetadataName(groupName?: string): string {
if (linkGroupList.value.length === 0)
return groupName || '未分组'
const found = linkGroupList.value.find(item => item.metadata.name === groupName)
return found?.spec.displayName || groupName || '未分组'
}
async function handleGetLinkGroupData() {
try {
const res = await getFriendLinkGroupList({ page: 1, size: 0 })
linkGroupList.value = res.data.items || []
handleGetData()
}
catch (err) {
console.error(err)
loading.value = 'error'
}
}
async function handleGetData() {
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = ''
try {
const res = await getFriendLinkList({ ...queryParams.value })
hasNext.value = res.data.hasNext
const list = res.data.items.map(item => ({
...item,
spec: {
...item.spec,
logo: checkAvatarUrl(item.spec.logo),
groupName: findLinkGroupDisplayNameByGroupMetadataName(item.spec.groupName),
},
}))
dataList.value = dataList.value.concat(list)
setTimeout(() => {
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}, 500)
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
}
/* ---------------- 交互 ---------------- */
function handleOnLinkEvent(link: ILink) {
detail.value = { show: true, data: link }
}
function handleCopyLink(link: ILink) {
uni.setClipboardData({
data: `${link.spec.displayName}:${link.spec.url}`,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '链接复制成功!' })
},
fail: () => {
uni.showToast({ icon: 'none', title: '复制失败!' })
},
})
}
function toSubmitLinkPage() {
uni.navigateTo({ url: '/pages-blog/submit-link/submit-link' })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function calcSiteThumbnail(val?: string): string {
if (!val)
return ''
const _val = val.endsWith('/') ? val : `${val}/`
return `https://image.thum.io/get/width/1000/crop/800/${_val}`
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetLinkGroupData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
dataList.value = []
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用友情链接功能哦请联系管理员"
@on-refresh="handleGetLinkGroupData"
/>
<template v-else>
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen p-3">
<wd-skeleton :row="5" :animated="true" />
</view>
<view v-else class="content pt-6" :class="{ 'bg-white': dataList.length !== 0 }">
<view v-if="dataList.length === 0" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="啊偶,博主还没有朋友呢~" />
</view>
<!-- 友链列表 -->
<view v-else class="link-list px-6">
<view v-for="(link, index) in dataList" :key="index">
<!-- 色彩版 -->
<view
v-if="!globalAppSettings.links.useSimple"
class="info flex bg-white p-3"
:class="{ 'border-b-2 border-[#f5f5f5]': index !== dataList.length - 1 }"
@click="handleOnLinkEvent(link)"
>
<image class="link-logo h-[140rpx] w-[140rpx] shrink-0 rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="info-detail flex flex-1 flex-col justify-center pl-7">
<view class="link-card-name text-[30rpx] text-[#f44336] font-bold">
<text class="group-tag mr-3 rounded-md px-1.5 py-0.5 text-[20rpx] text-white font-normal" style="background: linear-gradient(135deg, #64b5f6, #2196f3);">{{ link.spec.groupName || '暂未分组' }}</text>
{{ link.spec.displayName }}
</view>
<view class="link-card-url mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#666]">
站点地址{{ link.spec.url }}
</view>
<view class="link-card-desc mt-2 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#303133]">
博客简介{{ link.spec.description || '这个博主很懒,没写简介~' }}
</view>
</view>
</view>
<!-- 简洁版 -->
<view v-else class="link-card mb-6 flex items-center rounded-xl bg-white p-6 shadow-sm" @click="handleOnLinkEvent(link)">
<image class="logo h-[80rpx] w-[80rpx] shrink-0 border-6 border-white rounded-xl" :src="link.spec.logo" mode="aspectFill" />
<view class="link-info flex-1 pl-6">
<view class="name text-[30rpx] text-[#303133] font-bold">
{{ link.spec.displayName }}
</view>
<view class="desc mt-3 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-[#909399]">
{{ link.spec.description }}
</view>
</view>
</view>
</view>
</view>
<!-- 悬浮按钮 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view v-if="(haloPluginConfigs?.linksSubmitPlugin as { enabled?: boolean } | undefined)?.enabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="toSubmitLinkPage">
<wd-icon name="edit" size="20px" color="#ff9800" />
</view>
</view>
<!-- 详情弹窗 -->
<wd-popup v-model="detail.show" position="center" custom-style="width:640rpx;border-radius:12rpx;">
<view v-if="detail.data" class="poup p-9">
<view class="info flex">
<image class="poup-logo h-[140rpx] w-[140rpx] shrink-0 rounded-full" :src="checkImageUrl(detail.data.spec.logo)" mode="aspectFill" />
<view class="poup-info ml-6 flex flex-1 flex-col justify-center">
<view class="poup-name text-[34rpx] font-bold">
{{ detail.data.spec.displayName }}
</view>
<view class="poup-tag mt-2 text-[24rpx] text-[#999]">
{{ detail.data.spec.groupName }}
</view>
<view class="poup-link mt-3" @click="handleCopyLink(detail.data)">
<text class="poup-url text-[24rpx] text-[#ff9800]">{{ detail.data.spec.url }}</text>
</view>
</view>
</view>
<view class="poup-desc mt-5 text-[28rpx] text-[#555] leading-[1.6]">
博客简介{{ detail.data.spec.description || '这个博主很懒,没写简介~' }}
</view>
<image class="poup-img mt-6 h-[320rpx] w-[568rpx] rounded-xl" :src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
</view>
</wd-popup>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</view>
</template>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<script lang="ts" setup>
/**
* 恋爱相册页(源自旧项目 pagesA/love/album.vue,新建复刻)
* 相册列表(两列网格)+ 加密相册密码解锁 + 图片查看弹窗
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { getCache, setCache } from '@/utils/storage'
import type { ILoveAlbum } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '恋爱相册',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const loveConfig = computed(() => appConfigStore.configs.loveConfig)
/** 已解锁相册本地缓存 key */
const UNLOCKED_ALBUMS_CACHE_KEY = 'unlocked_albums'
/** 解锁 token 有效期(后端默认 30 分钟) */
const ALBUM_TOKEN_TTL_SECONDS = 30 * 60
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const dataList = ref<(ILoveAlbum & { image?: string, takeTime?: string })[]>([])
const unlockedAlbums = ref<Record<string, string>>({})
/** 密码解锁弹窗 */
const showUnlockModal = ref(false)
const currentUnlockAlbum = ref<(ILoveAlbum & { image?: string }) | null>(null)
/** 图片查看弹窗 */
const showPhotoViewer = ref(false)
const currentViewerAlbum = ref<(ILoveAlbum & { image?: string }) | null>(null)
const viewerLoading = ref(false)
const unlockAlbumName = computed(() => currentUnlockAlbum.value?.displayName || '')
const unlockAlbumKey = computed(() => currentUnlockAlbum.value?.name || '')
const viewerAlbumName = computed(() => currentViewerAlbum.value?.displayName || '')
const viewerPhotos = computed(() => currentViewerAlbum.value?.photos || [])
/* ---------------- 缓存 ---------------- */
function handleRestoreUnlockedAlbums() {
try {
const saved = getCache<Record<string, string>>(UNLOCKED_ALBUMS_CACHE_KEY)
if (saved) {
unlockedAlbums.value = saved
}
}
catch (e) {
console.error('恢复解锁状态失败', e)
}
}
function handleSaveUnlockedAlbums() {
try {
setCache(UNLOCKED_ALBUMS_CACHE_KEY, unlockedAlbums.value, ALBUM_TOKEN_TTL_SECONDS)
}
catch (e) {
console.error('保存解锁状态失败', e)
}
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getLoveAlbums({})
if (res.data && (res.data as unknown as { items?: unknown[] }).items) {
dataList.value = ((res.data as unknown as { items: ILoveAlbum[] }).items || []).map((item) => {
const creationTimestamp = (item.metadata as unknown as { creationTimestamp?: string } | undefined)?.creationTimestamp
return {
...item,
image: checkImageUrl(item.cover),
takeTime: creationTimestamp ? dayjs(creationTimestamp).format('DD/MM/YYYY') : '',
}
})
loading.value = 'success'
handleLoadUnlockedAlbumPhotos()
}
else {
dataList.value = []
loading.value = 'success'
}
}
catch (e) {
console.error('获取相册失败', e)
loading.value = 'error'
uni.showToast({ icon: 'none', title: '加载失败,请下拉刷新重试!' })
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
/** 加载已解锁相册的照片 */
async function handleLoadUnlockedAlbumPhotos() {
for (const item of dataList.value) {
if (item.locked && unlockedAlbums.value[item.name || '']) {
const token = unlockedAlbums.value[item.name || '']
try {
const detail = await getLoveAlbumByName(item.name || '', { token })
if (detail.locked) {
delete unlockedAlbums.value[item.name || '']
handleSaveUnlockedAlbums()
}
else if (detail.photos) {
item.photos = detail.photos
item.locked = false
}
}
catch (e) {
console.error('加载相册照片失败', e)
}
}
}
}
/* ---------------- 交互 ---------------- */
function handleOnAlbumClick(item: ILoveAlbum & { image?: string }) {
if (item.locked && !unlockedAlbums.value[item.name || '']) {
currentUnlockAlbum.value = item
showUnlockModal.value = true
return
}
handleOpenPhotoViewer(item)
}
async function handleOpenPhotoViewer(item: ILoveAlbum & { image?: string }) {
currentViewerAlbum.value = item
showPhotoViewer.value = true
if (item.photos && item.photos.length > 0)
return
viewerLoading.value = true
try {
const token = unlockedAlbums.value[item.name || ''] || ''
const detail = await getLoveAlbumByName(item.name || '', { token })
if (detail) {
if (detail.locked) {
delete unlockedAlbums.value[item.name || '']
handleSaveUnlockedAlbums()
}
else if (detail.photos) {
item.photos = detail.photos
item.locked = false
}
}
}
catch (e) {
console.error('获取相册照片失败', e)
uni.showToast({ icon: 'none', title: '照片加载失败,请稍后重试' })
}
finally {
viewerLoading.value = false
}
}
function handleOnUnlockSuccess(data: { albumKey: string, token: string, photos: unknown[] }) {
unlockedAlbums.value[data.albumKey] = data.token
handleSaveUnlockedAlbums()
const albumIndex = dataList.value.findIndex(a => a.name === data.albumKey)
if (albumIndex !== -1) {
dataList.value[albumIndex].photos = data.photos as typeof dataList.value[number]['photos']
dataList.value[albumIndex].locked = false
}
currentUnlockAlbum.value = null
if (albumIndex !== -1) {
handleOpenPhotoViewer(dataList.value[albumIndex])
}
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱相册' })
handleRestoreUnlockedAlbums()
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-[144rpx]" style="background: linear-gradient(-135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));">
<view v-if="loading !== 'success'" class="loading-wrap box-border h-[60vh] w-screen flex flex-col items-center justify-center p-9">
<view v-if="loading === 'loading'" class="loading">
<view class="loadig-text mt-7 text-[28rpx] text-[#56bbf9]">
相册正在努力加载中啦~
</view>
</view>
<view v-else class="loading-error w-full">
<wd-empty description="啊偶,加载失败了呢~">
<wd-button size="small" plain type="danger" @click="handleGetData()">
刷新试试
</wd-button>
</wd-empty>
</view>
</view>
<!-- 内容区域 -->
<view v-else class="app-page-content">
<view v-if="dataList.length === 0" class="h-[60vh] w-full flex items-center justify-center content-empty">
<wd-empty description="相册暂时还没有数据~">
<wd-button size="small" plain type="primary" @click="handleGetData()">
刷新试试
</wd-button>
</wd-empty>
</view>
<!-- 相册列表(两列网格) -->
<view v-else class="album-list box-border flex flex-wrap px-6">
<view v-for="(item, index) in dataList" :key="index" class="album-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm" :class="index % 2 === 0 ? 'mr-6 w-[calc((100%-24rpx)/2)]' : 'w-[calc((100%-24rpx)/2)]'" @click="handleOnAlbumClick(item)">
<view class="album-cover-wrap relative h-[320rpx] w-full">
<image class="album-cover h-full w-full" :src="item.image" mode="aspectFill" lazy-load />
<view v-if="item.locked && !unlockedAlbums[item.name || '']" class="album-lock-mask absolute left-0 top-0 h-full w-full flex flex-col items-center justify-center bg-black/45">
<view class="lock-icon text-[64rpx]">
🔒
</view>
<view class="lock-tip mt-3 text-[26rpx] text-white">
已加密
</view>
</view>
</view>
<view class="album-info box-border p-5">
<view class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold">
{{ item.displayName }}
</view>
<view class="album-count mt-1 text-[24rpx] text-[#999]">
{{ item.photoCount || 0 }} 张照片
</view>
</view>
</view>
</view>
</view>
<!-- 密码解锁弹窗 -->
<uh-album-unlock-modal
v-if="currentUnlockAlbum"
:show="showUnlockModal"
:album-name="unlockAlbumName"
:album-key="unlockAlbumKey"
@update:show="showUnlockModal = $event"
@success="handleOnUnlockSuccess"
/>
<!-- 相册图片查看弹窗 -->
<uh-album-photo-viewer
v-if="currentViewerAlbum"
:show="showPhotoViewer"
:album-name="viewerAlbumName"
:photos="viewerPhotos"
:loading="viewerLoading"
@update:show="showPhotoViewer = $event"
/>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+281
View File
@@ -0,0 +1,281 @@
<script lang="ts" setup>
/**
* 恋爱故事页(源自旧项目 pagesA/love/journey.vue,新建复刻)
* 时间轴展示恋爱故事,点击查看故事详情弹窗
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveStories } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import type { ILoveStory } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '恋爱故事',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const scrollTop = ref(0)
const stories = ref<ILoveStory[]>([])
const showDetail = ref(false)
const currentStory = ref<ILoveStory>({})
const currentStoryHtml = ref('')
const storyImageIndex = ref(0)
/* ---------------- 计算属性 ---------------- */
/** 弹窗故事图片(预处理路径) */
const currentStoryImages = computed(() => {
const images = (currentStory.value as unknown as { spec?: { images?: string[] } }).spec?.images || []
return images.map(img => checkImageUrl(img || ''))
})
/* ---------------- 数据加载 ---------------- */
async function handleGetStories() {
loading.value = 'loading'
try {
const res = await getLoveStories({})
if (res.data && (res.data as unknown as { items?: unknown[] }).items && ((res.data as unknown as { items: unknown[] }).items.length > 0)) {
// 按 priority 排序
stories.value = ((res.data as unknown as { items: ILoveStory[] }).items).sort((a, b) => {
const priorityA = (a as unknown as { spec?: { priority?: number } }).spec?.priority || 0
const priorityB = (b as unknown as { spec?: { priority?: number } }).spec?.priority || 0
return priorityB - priorityA
})
loading.value = 'success'
}
else {
// 降级:从旧配置读取单条故事
handleLoadFromLegacy()
}
}
catch (e) {
console.error('获取故事失败', e)
handleLoadFromLegacy()
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
function handleLoadFromLegacy() {
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as { ourStory?: { content?: string } } | undefined
if (loveModuleConfig?.ourStory?.content) {
stories.value = [{
name: 'legacy-story',
spec: {
title: '我们的故事',
content: loveModuleConfig.ourStory.content,
date: '',
images: [],
},
}]
loading.value = 'success'
return
}
stories.value = []
loading.value = 'success'
}
/* ---------------- 交互 ---------------- */
function handleOnStoryClick(story: ILoveStory) {
currentStory.value = story
currentStoryHtml.value = (story as unknown as { spec?: { content?: string } }).spec?.content || ''
storyImageIndex.value = 0
showDetail.value = true
}
function handleOnStoryImageChange(e: { detail: { current: number } }) {
storyImageIndex.value = e.detail.current
}
/** 时间轴封面图:最多 3 张,预处理路径 */
function storyCoverImages(story: ILoveStory): string[] {
const images = (story as unknown as { spec?: { images?: string[] } }).spec?.images || []
return images.slice(0, 3).map(img => checkImageUrl(img || ''))
}
/** 预览时间轴封面图 */
function handlePreviewStoryImages(story: ILoveStory, index: number) {
const images = (story as unknown as { spec?: { images?: string[] } }).spec?.images || []
const urls = images.map(img => checkImageUrl(img || ''))
if (urls.length === 0)
return
uni.previewImage({ current: urls[index], urls })
}
function handlePreviewImage(index: number) {
const images = (currentStory.value as unknown as { spec?: { images?: string[] } }).spec?.images || []
const urls = images.map(img => checkImageUrl(img || ''))
if (urls.length > 0) {
uni.previewImage({ current: urls[index], urls })
}
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱故事' })
handleGetStories()
})
onPullDownRefresh(() => {
handleGetStories()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen p-6 pb-[144rpx]" style="background: linear-gradient(-45deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%)); color: rgb(26 26 26);">
<view v-if="loading === 'loading'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<view class="loadig-text mt-7 text-[28rpx] text-[#56bbf9]">
故事正在努力加载中啦~
</view>
</view>
<view v-else-if="loading === 'error'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<wd-empty description="啊偶,加载失败了呢~">
<wd-button size="small" plain type="danger" @click="handleGetStories()">
刷新试试
</wd-button>
</wd-empty>
</view>
<view v-else class="content-wrap">
<!-- 空状态 -->
<view v-if="stories.length === 0" class="empty-state h-[60vh] w-full flex items-center justify-center">
<wd-empty description="还没有故事,等待你们来书写...">
<wd-button size="small" plain type="primary" @click="handleGetStories()">
刷新试试
</wd-button>
</wd-empty>
</view>
<!-- 时间轴 -->
<view v-else class="timeline relative pl-10">
<view v-for="(story, index) in stories" :key="String((story as unknown as { name?: string })?.name ?? index)" class="timeline-item relative pb-10" @click="handleOnStoryClick(story)">
<view class="timeline-dot absolute left-[-32rpx] top-4 z-2 h-5 w-5 rounded-full" style="background-color: #f88ca2; box-shadow: 0 0 0 6rpx rgb(248 140 162 / 20%);" />
<view class="timeline-card rounded-xl bg-white p-6 shadow-sm">
<view v-if="(story as unknown as { spec?: { date?: string } }).spec?.date" class="timeline-date mb-2 text-[24rpx] text-[#f88ca2]">
{{ (story as unknown as { spec?: { date?: string } }).spec?.date }}
</view>
<view class="timeline-title text-[32rpx] text-[#333] font-bold">
{{ (story as unknown as { spec?: { title?: string } }).spec?.title || '' }}
</view>
<view v-if="(story as unknown as { spec?: { location?: string } }).spec?.location" class="timeline-location mt-2 flex items-center text-[24rpx] text-[#999]">
<text class="location-text ml-1">{{ (story as unknown as { spec?: { location?: string } }).spec?.location }}</text>
</view>
<view
v-if="(story as unknown as { spec?: { images?: string[] } }).spec?.images?.length"
class="timeline-covers mt-4 flex flex-wrap gap-2"
>
<view
v-for="(img, imgIndex) in storyCoverImages(story)"
:key="imgIndex"
class="timeline-cover h-[180rpx] w-[calc((100%-16rpx)/3)] overflow-hidden rounded-lg"
@click.stop="handlePreviewStoryImages(story, imgIndex)"
>
<image class="timeline-cover-img h-full w-full" :src="img" mode="aspectFill" lazy-load />
</view>
<view
v-if="((story as unknown as { spec?: { images?: string[] } }).spec?.images?.length || 0) > 3"
class="timeline-cover timeline-cover-more h-[180rpx] w-[calc((100%-16rpx)/3)] flex items-center justify-center bg-black/50"
@click.stop="handleOnStoryClick(story)"
>
<text class="more-text text-[32rpx] text-white font-bold">
+{{ ((story as unknown as { spec?: { images?: string[] } }).spec?.images?.length || 0) - 3 }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<!-- 故事详情弹窗 -->
<wd-popup v-model="showDetail" position="center" custom-style="width:90vw;height:80vh;border-radius:12rpx;">
<view class="story-detail h-full w-full flex flex-col overflow-hidden rounded-xl bg-white">
<view class="story-detail-header box-border shrink-0 border-b border-black/5 px-7 py-6">
<view class="story-detail-title text-[32rpx] text-[#333] font-bold">
{{ (currentStory as unknown as { spec?: { title?: string } }).spec?.title || '' }}
</view>
<view
v-if="(currentStory as unknown as { spec?: { date?: string; location?: string } }).spec?.date
|| (currentStory as unknown as { spec?: { date?: string; location?: string } }).spec?.location"
class="story-detail-meta mt-2 flex items-center text-[24rpx] text-[#999]"
>
<text v-if="(currentStory as unknown as { spec?: { date?: string } }).spec?.date" class="story-detail-date">
{{ (currentStory as unknown as { spec?: { date?: string } }).spec?.date }}
</text>
<text v-if="(currentStory as unknown as { spec?: { location?: string } }).spec?.location" class="story-detail-location ml-6">
{{ (currentStory as unknown as { spec?: { location?: string } }).spec?.location }}
</text>
</view>
</view>
<!-- 故事图片:多图 swiper 轮播 -->
<view v-if="currentStoryImages.length > 0" class="story-images shrink-0">
<swiper
v-if="currentStoryImages.length > 1"
class="story-images-swiper h-[360rpx] w-full"
circular
indicator-dots
indicator-color="rgba(255,255,255,0.4)"
indicator-active-color="#f88ca2"
:current="storyImageIndex"
@change="handleOnStoryImageChange"
>
<swiper-item v-for="(img, imgIndex) in currentStoryImages" :key="imgIndex" class="story-images-item h-full w-full">
<image :src="img" mode="aspectFill" class="story-image h-full w-full" @click="handlePreviewImage(imgIndex)" />
</swiper-item>
</swiper>
<image v-else :src="currentStoryImages[0]" mode="aspectFill" class="story-image story-image-single h-[360rpx] w-full" @click="handlePreviewImage(0)" />
</view>
<scroll-view scroll-y class="story-detail-content box-border min-h-0 flex-1 px-7 py-6">
<view class="story-html text-[28rpx] text-[#333] leading-[1.8]" v-html="currentStoryHtml" />
</scroll-view>
<view class="story-detail-close box-border shrink-0 border-t border-black/5 px-7 py-5">
<text class="close-text block h-20 rounded-[40rpx] text-center text-[30rpx] text-white font-bold" style="background: linear-gradient(135deg, #f88ca2, #ff6b9d); line-height: 80rpx;" @click="showDetail = false">关闭</text>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped lang="scss">
.timeline {
.timeline-item {
&::before {
content: '';
position: absolute;
left: -20rpx;
top: 12rpx;
bottom: 0;
width: 2rpx;
background-color: rgb(248 140 162 / 40%);
}
&:last-child::before {
display: none;
}
}
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script lang="ts" setup>
/**
* 恋爱清单页(源自旧项目 pagesA/love/list.vue,新建复刻)
* 恋爱清单卡片列表(未开始/进行中/已完成),展开查看详情与回忆图片
*/
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveDailyItems } from '@/api/uni-halo'
import { checkImageUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '恋爱清单',
enablePullDownRefresh: true,
},
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const list = ref<(ILoveItem & { open: boolean })[]>([])
interface ILoveItem {
name?: string
title?: string
content?: string
status?: 'wait' | 'doing' | 'complete'
planDate?: string
completeDate?: string
completeRemark?: string
images?: string[]
[key: string]: unknown
}
/* ---------------- 数据加载 ---------------- */
async function handleGetList() {
loading.value = 'loading'
try {
const res = await getLoveDailyItems({})
if (res.data && (res.data as unknown as { items?: unknown[] }).items) {
list.value = ((res.data as unknown as { items: unknown[] }).items as unknown as {
spec?: ILoveItem
metadata?: { name?: string }
name?: string
}[]).map(item => ({
...item.spec,
name: item.metadata?.name || item.name || '',
open: false,
}))
loading.value = 'success'
}
else {
list.value = []
loading.value = 'success'
}
}
catch (e) {
console.error('获取清单失败', e)
loading.value = 'error'
uni.showToast({ icon: 'none', title: '加载失败,请下拉刷新重试!' })
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
/* ---------------- 交互 ---------------- */
function handleOnItemOpen(item: ILoveItem & { open: boolean }) {
item.open = !item.open
}
function handlePreviewImages(images: string[] | undefined, index: number) {
const urls = (images || []).map(img => checkImageUrl(img || ''))
if (urls.length === 0)
return
uni.previewImage({ current: urls[index], urls })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱清单' })
handleGetList()
})
onPullDownRefresh(() => {
handleGetList()
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen p-6 pb-[144rpx]" style="background: linear-gradient(135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));">
<view v-if="loading === 'loading'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<view class="loadig-text mt-7 text-[28rpx] text-[#56bbf9]">
清单正在努力加载中啦~
</view>
</view>
<view v-else-if="loading === 'error'" class="loading-wrap box-border h-[60vh] w-full flex items-center justify-center p-9">
<wd-empty description="啊偶,加载失败了呢~">
<wd-button size="small" plain type="danger" @click="handleGetList()">
刷新试试
</wd-button>
</wd-empty>
</view>
<view v-else class="list-wrap w-full">
<view v-if="list.length === 0" class="list h-[60vh] flex flex-col items-center justify-center">
<wd-empty description="暂时还没有恋爱清单,快去制定你们的恋爱清单吧~">
<wd-button size="small" plain type="primary" @click="handleGetList()">
刷新试试
</wd-button>
</wd-empty>
</view>
<view v-else class="list">
<view class="list-tip mb-7 w-full text-center text-[26rpx] text-[#999]">
看看我们的恋爱清单都完成了哪些吧
</view>
<block v-for="(item, index) in list" :key="item.name || index">
<view class="card mb-6 box-border w-full flex flex-col items-center rounded-3xl bg-white p-6 shadow-sm">
<view class="head box-border w-full flex items-center" @click="handleOnItemOpen(item)">
<view class="status w-[100rpx] flex">
<view v-if="item.status === 'wait'" class="text h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #ffc6ba; color: #55423b;">
未开始
</view>
<view v-else-if="item.status === 'doing'" class="text doing h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #ffe9a8; color: #55423b;">
进行中
</view>
<view v-else class="text finish h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #bfe9ef; color: #55423b;">
已完成
</view>
</view>
<view class="title box-border w-0 flex-1 px-7">
<view class="title-name text-[30rpx] text-[#333] font-bold">
{{ item.title }}
</view>
<view v-if="item.content" class="title-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#555]">
{{ item.content }}
</view>
</view>
<view class="actions w-[50rpx]">
<text class="icon inline-block h-[45rpx] w-[45rpx] rounded-full bg-black/20 text-center text-[32rpx] font-bold leading-[45rpx]">{{ item.open ? '-' : '+' }}</text>
</view>
</view>
<view v-if="item.open" class="body mt-6 box-border w-full rounded-3xl bg-black/5 px-6 pb-3 pt-6 text-[26rpx]">
<view v-if="item.planDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
计划时间
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.planDate || '-' }}
</view>
</view>
<view v-if="item.completeDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
完成时间
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.completeDate || '-' }}
</view>
</view>
<view v-if="item.completeRemark" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
完成感想
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.completeRemark || '-' }}
</view>
</view>
<view v-if="item.images && item.images.length > 0" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
回忆图片
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
<view class="images flex flex-wrap">
<view
v-for="(img, imgIndex) in item.images"
:key="imgIndex"
class="image mb-3 mr-3 h-[180rpx] w-[calc((100%-24rpx)/3)] overflow-hidden rounded-lg"
@click="handlePreviewImages(item.images, imgIndex)"
>
<image class="image-src h-full w-full" :src="checkImageUrl(img)" mode="aspectFill" lazy-load />
</view>
</view>
</view>
</view>
</view>
</view>
</block>
</view>
</view>
<view class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script lang="ts" setup>
/**
* 恋爱主页(源自旧项目 pagesA/love/love.vue,新建复刻)
* 情侣信息 + 恋爱计时 + 功能导航(恋爱故事/相册/清单)
*/
import { computed, onBeforeUnmount, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getLoveConfig } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '恋爱日记',
},
})
const appConfigStore = useAppConfigStore()
/* ---------------- 恋爱配置 ---------------- */
interface ILoveConfigPage {
enabled: boolean
loveDateTitle: string
loveDate: string
loveInfo: {
boyNickname: string
boyAvatar: string
girlNickname: string
girlAvatar: string
}
pageImages: {
bgImageUrl: string
waveImageUrl: string
heartImageUrl: string
}
ourStory: { enabled: boolean, iconUrl: string }
lovePhoto: { enabled: boolean, iconUrl: string }
loveDaily: { enabled: boolean, iconUrl: string }
[key: string]: unknown
}
const loveConfig = ref<ILoveConfigPage>({
enabled: false,
loveDateTitle: '',
loveDate: '',
loveInfo: {
boyNickname: '',
boyAvatar: '',
girlNickname: '',
girlAvatar: '',
},
pageImages: {
bgImageUrl: '',
waveImageUrl: '',
heartImageUrl: '',
},
ourStory: { enabled: false, iconUrl: '' },
lovePhoto: { enabled: false, iconUrl: '' },
loveDaily: { enabled: false, iconUrl: '' },
})
const loveDayCount = ref({ d: 0, h: 0, m: 0, s: 0 })
let loveDayTimer: ReturnType<typeof setTimeout> | null = null
const navList = ref<{ key: string, use: boolean, iconImageUrl: string, title: string, desc: string }[]>([])
/* ---------------- 计算属性 ---------------- */
const loveWrapStyle = computed(() => ({
backgroundImage: `url(${checkImageUrl(loveConfig.value.pageImages.bgImageUrl)})`,
}))
/* ---------------- 数据加载 ---------------- */
async function handleGetLoveConfig() {
try {
const loveConfigRes = await getLoveConfig()
if (loveConfigRes) {
loveConfig.value = {
...loveConfig.value,
...loveConfigRes,
}
}
// 同时从 getConfigs 获取模块开关和图片配置
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as Partial<ILoveConfigPage> | undefined
if (loveModuleConfig) {
loveConfig.value = {
...loveConfig.value,
pageImages: loveModuleConfig.pageImages || loveConfig.value.pageImages,
ourStory: loveModuleConfig.ourStory || loveConfig.value.ourStory,
lovePhoto: loveModuleConfig.lovePhoto || loveConfig.value.lovePhoto,
loveDaily: loveModuleConfig.loveDaily || loveConfig.value.loveDaily,
}
}
initList()
handleInitLoveDayCount()
}
catch (e) {
console.error('获取恋爱配置失败', e)
// 降级:从旧配置读取
const appConfigs = appConfigStore.configs
const loveModuleConfig = appConfigs.loveConfig as ILoveConfigPage | undefined
if (loveModuleConfig) {
loveConfig.value = loveModuleConfig
initList()
handleInitLoveDayCount()
}
}
}
function initList() {
const configs = loveConfig.value
navList.value = [
{
key: 'journey',
use: configs.ourStory.enabled,
iconImageUrl: configs.ourStory.iconUrl,
title: '恋爱故事',
desc: '我们一起度过的那些经历',
},
{
key: 'album',
use: configs.lovePhoto.enabled,
iconImageUrl: configs.lovePhoto.iconUrl,
title: '恋爱相册',
desc: '定格了我们的那些小美好',
},
{
key: 'list',
use: configs.loveDaily.enabled,
iconImageUrl: configs.loveDaily.iconUrl,
title: '恋爱清单',
desc: '你我之间的约定我们都在努力实现',
},
]
}
/* ---------------- 恋爱计时 ---------------- */
function handleInitLoveDayCount() {
if (loveDayTimer) {
clearTimeout(loveDayTimer)
}
const countDownFn = () => {
loveDayTimer = setTimeout(countDownFn, 1000)
const formatStartDate = loveConfig.value.loveDate.replace(/-/g, '/')
const start = new Date(formatStartDate)
const now = new Date()
const T = now.getTime() - start.getTime()
const i = 24 * 60 * 60 * 1000
const d = T / i
const D = Math.floor(d)
const h = (d - D) * 24
const H = Math.floor(h)
const m = (h - H) * 60
const M = Math.floor(m)
const s = (m - M) * 60
const S = Math.floor(s)
loveDayCount.value = { d: D, h: H, m: M, s: S }
}
countDownFn()
}
/* ---------------- 跳转 ---------------- */
function handleToPage(pageName: string) {
uni.navigateTo({
url: `/pages-blog/love/${pageName}`,
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: '恋爱日记' })
handleGetLoveConfig()
})
onBeforeUnmount(() => {
if (loveDayTimer) {
clearTimeout(loveDayTimer)
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen">
<!-- 情侣信息 -->
<view class="lover-wrap relative h-[50vh] w-screen flex items-center justify-center" :style="[loveWrapStyle]">
<view class="lover-card absolute left-1/2 top-[58%] z-2 w-[90vw] flex items-center justify-around rounded-xl -translate-x-1/2 -translate-y-1/2">
<view class="boy">
<image class="avatar box-border h-[180rpx] w-[180rpx] border-8 rounded-full" :style="{ borderColor: 'rgb(58 184 228 / 70%)' }" :src="checkAvatarUrl(loveConfig.loveInfo.boyAvatar)" mode="aspectFit" />
<view class="name mt-2 text-center text-[32rpx] text-white font-bold tracking-[2rpx]">
{{ loveConfig.loveInfo.boyNickname }}
</view>
</view>
<image class="like h-[120rpx] w-[120rpx]" :src="checkImageUrl(loveConfig.pageImages.heartImageUrl)" mode="scaleToFill" />
<view class="girl">
<image class="avatar box-border h-[180rpx] w-[180rpx] border-8 rounded-full" :style="{ borderColor: 'rgb(245 122 179 / 70%)' }" :src="checkAvatarUrl(loveConfig.loveInfo.girlAvatar)" mode="aspectFit" />
<view class="name mt-2 text-center text-[32rpx] text-white font-bold tracking-[2rpx]">
{{ loveConfig.loveInfo.girlNickname }}
</view>
</view>
</view>
<image class="wave-image absolute bottom-0 left-0 h-[120rpx] w-full" :src="checkImageUrl(loveConfig.pageImages.waveImageUrl)" mode="scaleToFill" />
</view>
<!-- 恋爱记时 -->
<view class="love-time-wrap mt-20 w-screen flex flex-col items-center justify-center">
<view class="title text-[42rpx] text-[#333] font-bold">
{{ loveConfig.loveDateTitle }}
</view>
<view class="content mt-6 flex items-center justify-center">
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.d }}</text>
</text>
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.h }}</text>
小时
</text>
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.m }}</text>
分钟
</text>
<text class="text text-[28rpx]">
<text class="number mx-2 text-[46rpx] text-[#f83856] font-bold">{{ loveDayCount.s }}</text>
</text>
</view>
</view>
<!-- 功能导航 -->
<view class="list-wrap mt-[75rpx] box-border flex flex-col items-center justify-center px-9">
<block v-for="(nav, index) in navList" :key="index">
<view v-if="nav.use" class="mb-8 box-border list-item w-full flex items-center justify-around rounded-[50rpx] bg-white px-8 py-7 shadow-sm" :class="`list-item-${index + 1}`" @click="handleToPage(nav.key)">
<view class="left h-[120rpx] w-[120rpx]">
<image class="icon h-full w-full" :src="checkImageUrl(nav.iconImageUrl)" mode="aspectFit" />
</view>
<view class="right box-border flex flex-1 flex-col justify-center pl-10">
<view class="name text-[32rpx] text-[#333] font-bold">
{{ nav.title }}
</view>
<view class="desc mt-2 text-[26rpx] text-[#777]">
{{ nav.desc }}
</view>
</view>
</view>
</block>
</view>
</view>
</template>
<style scoped lang="scss">
.app-page {
background: linear-gradient(
-45deg,
rgb(247 149 51 / 10%),
rgb(243 112 85 / 10%) 15%,
rgb(239 78 123 / 10%) 30%,
rgb(161 102 171 / 10%) 44%,
rgb(80 115 184 / 10%) 58%,
rgb(16 152 173 / 10%) 72%,
rgb(7 179 155 / 10%) 86%,
rgb(109 186 130 / 10%)
);
}
.lover-wrap {
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
&::before {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
content: '';
background-color: rgb(255 255 255 / 10%);
z-index: 0;
backdrop-filter: blur(4rpx);
overflow: hidden;
}
&::after {
content: '';
position: absolute;
left: 0;
bottom: -60rpx;
width: 100vw;
height: 60rpx;
background-image: linear-gradient(to bottom, rgb(255 255 255), rgb(255 255 255 / 0%));
}
.like {
animation: likeani 1s ease-in-out infinite;
}
.wave-image {
mix-blend-mode: screen;
}
}
/* 列表项漂浮动画(无法用 UnoCSS 表达) */
.list-item {
&:nth-child(1) {
animation: listItemAni1 3s ease-in-out infinite;
}
&:nth-child(2) {
animation: listItemAni1 3s ease-in-out infinite;
animation-delay: 1.5s;
}
&:nth-child(3) {
animation: listItemAni1 3s ease-in-out infinite;
animation-delay: 2s;
}
}
@keyframes likeani {
0% {
transform: scale(1);
}
25% {
transform: scale(1.2);
}
50% {
transform: scale(1.1);
}
75% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
@keyframes listItemAni1 {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-10rpx);
}
100% {
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,299 @@
<script lang="ts" setup>
/**
* 瞬间详情页(源自旧项目 pagesA/moment-detail,新建复刻)
* 展示瞬间内容(mp-html) + 图片/音频/视频附件
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getMomentByName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间详情',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryName = ref('')
const moment = ref<(IMoment & {
images?: { type?: string, url: string }[]
videos?: { id?: string, url: string }[]
audios?: { type?: string, url: string }[]
spec: IMoment['spec'] & { newHtml?: string }
}) | null>(null)
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 移除 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return htmlString.replace(regex, '')
}
function tagColor(): string {
if (!calcUseTagRandomColor.value)
return 'blue'
return randomTagColor()
}
function formatTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
try {
const res = await getMomentByName(queryName.value)
uni.setNavigationBarTitle({ title: '瞬间详情' })
const medium = (res.data.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
const tempResult = {
...res.data,
spec: {
...res.data.spec,
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
newHtml: removeTagLinksCompletely((res.data.spec as unknown as { content?: { html?: string } }).content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
moment.value = tempResult
loading.value = 'success'
nextTick(() => {
createVideoContexts(tempResult.videos || [])
})
}
catch (err) {
console.error('获取瞬间详情失败', err)
loading.value = 'error'
}
finally {
uni.stopPullDownRefresh()
}
}
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(videos: { id?: string }[]) {
stopAllVideos()
videos.forEach((item) => {
if (item.id) {
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
}
})
}
function stopAllVideos(excludesVideoId: string | null = null) {
Object.keys(videoContexts.value).forEach((videoId) => {
if (!excludesVideoId || excludesVideoId !== videoId) {
videoContexts.value[videoId]?.pause()
}
})
}
function onVideoPlay(videoId: string) {
currentVideoId.value = videoId
stopAllVideos(videoId)
}
function onVideoPause(videoId: string) {
if (currentVideoId.value === videoId) {
currentVideoId.value = null
}
}
function onVideoEnded() {
currentVideoId.value = null
}
/* ---------------- 交互 ---------------- */
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
uni.setNavigationBarTitle({ title: '瞬间加载中...' })
queryName.value = options?.name || ''
handleGetData()
})
onPullDownRefresh(() => {
videoContexts.value = {}
currentVideoId.value = null
handleGetData()
})
onShareAppMessage(() => ({
path: `/pages-blog/moment-detail/moment-detail?name=${moment.value?.metadata.name}`,
title: moment.value?.spec.owner?.displayName || '',
}))
onShareTimeline(() => ({
title: moment.value?.spec.owner?.displayName || '',
query: moment.value ? `name=${moment.value.metadata.name}` : '',
}))
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap h-screen bg-white px-6">
<wd-skeleton :row="3" :animated="true" />
</view>
<block v-else>
<view v-if="moment" class="moment-card flex flex-col gap-6 p-6">
<!-- 用户信息 -->
<view class="card flex items-center rounded-xl bg-white p-6 shadow-sm">
<image class="avatar h-[80rpx] w-[80rpx] shrink-0 rounded-full" :src="moment.spec.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
{{ moment.spec.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1.5 text-[24rpx] text-[#666]">
{{ formatTime(moment.spec.releaseTime) }}
</view>
</view>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<text class="tags-label text-[26rpx] text-[#606266]">标签列表</text>
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag mr-4 text-[24rpx]" :style="{ color: tagColor() }">
{{ tag }}
</text>
</view>
<!-- 内容 -->
<view class="card overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片附件 -->
<view v-if="moment.images && moment.images.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
图片附件
</view>
<view class="images flex flex-wrap items-start pt-6" :class="`images-${moment.images.length}`">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1.5" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
</view>
<!-- 音频附件 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
音频附件
</view>
<view class="audio-list flex flex-col gap-3 pt-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
</view>
<!-- 视频附件 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="card rounded-xl bg-white p-6 shadow-sm">
<view class="card-head mb-1.5 text-[28rpx] text-[#606266]">
视频附件
</view>
<view class="video-list mt-6 w-full flex flex-col gap-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts" setup>
/**
* 应用设置页(源自旧项目 pagesA/setting,新建复刻)
* 布局设置(首页布局/文章卡片样式)+ 功能设置(瀑布流/友链简洁/圆头像/轮播指示器)+ 保存/恢复默认
*/
import { reactive, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { DefaultAppSettings } from '@/config/appSettings'
import { useSettingStore } from '@/store/setting'
import type { IAppSettings } from '@/config/appSettings'
definePage({
style: {
navigationBarTitleText: '应用设置',
},
})
const settingStore = useSettingStore()
/* ---------------- 状态 ---------------- */
const loading = ref(true)
const isSaved = ref(true)
const firstLoad = ref(true)
/** 本地编辑副本(不与 store 直接双向绑定,保存时提交) */
const appSettings = reactive<IAppSettings>(JSON.parse(JSON.stringify(DefaultAppSettings)))
/* ---------------- 选择器配置 ---------------- */
const homeLayout = reactive({
list: [
{ name: '一行一列', value: 'h_row_col1' },
{ name: '一行两列', value: 'h_row_col2' },
],
selectLabel: '一行一列',
selectValue: 'h_row_col1',
})
const articleCardStyle = reactive({
list: [
{ name: '左图右文', value: 'lr_image_text' },
{ name: '左文右图', value: 'lr_text_image' },
{ name: '上图下文', value: 'tb_image_text' },
{ name: '上文下图', value: 'tb_text_image' },
{ name: '只有文字', value: 'only_text' },
],
selectLabel: '左图右文',
selectValue: 'lr_image_text',
})
const dotPositionList = reactive([
{ name: '右边', value: 'right', checked: true },
{ name: '下边', value: 'bottom', checked: false },
])
/* ---------------- 工具 ---------------- */
function handleFindObjInList<T extends Record<string, unknown>>(list: T[], key: string, value: unknown): T {
return list.find(x => x[key] === value) || list[0]
}
/** 统一处理选择框回显 */
function handleHandleFormatSelect() {
const _homeLayout = handleFindObjInList(homeLayout.list, 'value', appSettings.layout.home)
homeLayout.selectLabel = _homeLayout.name
homeLayout.selectValue = _homeLayout.value
const _cardStyle = handleFindObjInList(articleCardStyle.list, 'value', appSettings.layout.cardType)
articleCardStyle.selectLabel = _cardStyle.name
articleCardStyle.selectValue = _cardStyle.value
const _dot = handleFindObjInList(dotPositionList, 'value', appSettings.banner.dotPosition)
dotPositionList.forEach((item) => {
item.checked = item.value === _dot.value
})
}
/* ---------------- 交互 ---------------- */
function handleOnHomeLayoutConfirm() {
const _select = handleFindObjInList(homeLayout.list, 'value', appSettings.layout.home)
homeLayout.selectLabel = _select.name
homeLayout.selectValue = _select.value
}
function handleOnArticleCardStyleConfirm() {
const _select = handleFindObjInList(articleCardStyle.list, 'value', appSettings.layout.cardType)
articleCardStyle.selectLabel = _select.name
articleCardStyle.selectValue = _select.value
}
function handleOnBannerDotChange(e: { value?: string }) {
const value = e.value || 'right'
appSettings.banner.dotPosition = value
}
/** 保存设置 */
function handleOnSave() {
isSaved.value = true
settingStore.settings = JSON.parse(JSON.stringify(appSettings))
uni.showToast({ icon: 'none', title: '保存成功,部分设置在重启后生效!' })
}
/** 恢复默认设置 */
function handleOnSaveDefault() {
uni.showModal({
title: '提示',
content: '您确定要恢复为默认的设置吗?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
isSaved.value = true
settingStore.updateDefaultAppSettings()
Object.assign(appSettings, JSON.parse(JSON.stringify(DefaultAppSettings)))
handleHandleFormatSelect()
uni.showToast({ icon: 'none', title: '系统设置已恢复为默认配置,部分设置在重启后生效!' })
}
},
})
}
function handleOnBack() {
if (isSaved.value) {
uni.navigateBack()
return
}
uni.showModal({
title: '提示',
content: '您当前可能有未保存的数据,确定返回吗?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
isSaved.value = true
}
},
})
}
/* ---------------- 监听 ---------------- */
watch(appSettings, () => {
if (firstLoad.value) {
firstLoad.value = false
}
else {
isSaved.value = false
}
}, { deep: true })
onLoad(() => {
uni.setNavigationBarTitle({ title: '应用设置' })
Object.assign(appSettings, JSON.parse(JSON.stringify(settingStore.settings)))
handleHandleFormatSelect()
uni.showLoading({ title: '加载中...', mask: true })
setTimeout(() => {
loading.value = false
uni.hideLoading()
}, 500)
})
</script>
<template>
<view class="app-page box-border min-h-screen pb-[140rpx]" style="background-color: #fafafd;">
<view v-if="!loading">
<!-- 布局设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
<text class="title-text text-[30rpx] text-[#303133] font-bold">布局</text>
<text class="title-desc ml-3 text-[22rpx] text-[#999]">应用以及文章列表布局设置</text>
</view>
<view class="sheet-content">
<!-- 首页布局 -->
<view class="pick-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7" @click="handleOnHomeLayoutConfirm">
<text class="row-label text-[28rpx] text-[#333]">首页文章布局</text>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ homeLayout.selectLabel }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
</view>
</view>
<!-- 文章卡片样式 -->
<view class="pick-row flex items-center justify-between px-8 py-7" @click="handleOnArticleCardStyleConfirm">
<text class="row-label text-[28rpx] text-[#333]">文章卡片样式</text>
<view class="row-value flex items-center gap-2">
<text class="value-text text-[26rpx] text-[#999]">{{ articleCardStyle.selectLabel }}</text>
<wd-icon name="arrow-right" size="12px" color="#999" />
</view>
</view>
</view>
</view>
<!-- 功能设置 -->
<view class="setting-sheet mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<view class="sheet-title border-b-2 border-[#f5f5f5] px-6 py-1.5">
<text class="title-text text-[30rpx] text-[#303133] font-bold">功能</text>
<text class="title-desc ml-3 text-[22rpx] text-[#999]">一些常用的功能性设置</text>
</view>
<view class="sheet-content">
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">图库瀑布流模式</text>
<wd-switch v-model="appSettings.gallery.useWaterfull" />
</view>
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">友链简洁模式</text>
<wd-switch v-model="appSettings.links.useSimple" />
</view>
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">是否圆形头像</text>
<wd-switch v-model="appSettings.isAvatarRadius" />
</view>
<view class="switch-row flex items-center justify-between border-b-2 border-[#f5f5f5] px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">轮播图指示器</text>
<wd-switch v-model="appSettings.banner.useDot" />
</view>
<!-- 指示器位置 -->
<view v-if="appSettings.banner.useDot" class="switch-row flex items-center justify-between px-8 py-7">
<text class="row-label text-[28rpx] text-[#333]">指示器位置</text>
<view class="radio-group flex gap-4">
<view
v-for="item in dotPositionList"
:key="item.value"
class="radio-item rounded-3xl px-6 py-1 text-[24rpx] text-[#999]"
:class="item.checked ? 'bg-[#03a9f4] text-white' : 'bg-[#f5f5f5]'"
@click="item.checked = true; handleOnBannerDotChange({ value: item.value })"
>
{{ item.name }}
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 操作区域 -->
<view v-if="!loading" class="btn-bar fixed bottom-0 left-0 box-border w-screen flex gap-6 bg-white p-6 shadow-sm">
<wd-button type="primary" size="medium" @click="handleOnSave">
保存设置
</wd-button>
<wd-button type="danger" size="medium" @click="handleOnSaveDefault">
恢复默认设置
</wd-button>
<wd-button plain size="medium" @click="handleOnBack">
返回
</wd-button>
</view>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.btn-bar {
:deep(wd-button) {
flex: 1;
}
}
</style>
+232
View File
@@ -0,0 +1,232 @@
<script lang="ts" setup>
/**
* 友链申请页(源自旧项目 pagesA/submit-link,新建复刻)
* 提交友链交换信息表单
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { submitLink } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '友链申请',
},
})
const appConfigStore = useAppConfigStore()
const haloPluginConfigs = computed(() => appConfigStore.configs.pluginConfig)
const blogDetail = computed(() => (haloPluginConfigs.value?.linksSubmitPlugin as {
blogName?: string
blogUrl?: string
blogLogo?: string
blogDesc?: string
} | undefined) || {})
const blogDetailPoupShow = ref(false)
const form = ref({
url: '',
name: '',
logo: '',
linkPageUrl: '',
email: '',
rssUrl: '',
description: '',
})
const calcBlogContent = computed(() => `
博客名称:${blogDetail.value.blogName}
博客地址:${blogDetail.value.blogUrl}
博客logo${checkAvatarUrl(blogDetail.value.blogLogo)}
博客简介:${blogDetail.value.blogDesc}
`)
function calcSiteThumbnail(val?: string): string {
if (!val)
return ''
const _val = val.endsWith('/') ? val : `${val}/`
return `https://image.thum.io/get/width/1000/crop/800/${_val}`
}
function checkIsUrl(url: string): boolean {
return /^https?:\/\//i.test(url)
}
function checkIsEmail(email: string): boolean {
return /^[\w.-]+@[\w-]+(?:\.[\w-]+)+$/.test(email)
}
async function handleHandle() {
if (!form.value.name.trim()) {
uni.showToast({ icon: 'none', title: '请输入网站名称!' })
return
}
if (!checkIsUrl(form.value.url)) {
uni.showToast({ icon: 'none', title: '请输入正确的网站地址!' })
return
}
if (form.value.logo && !checkIsUrl(form.value.logo)) {
uni.showToast({ icon: 'none', title: '请输入正确的Logo地址!' })
return
}
if (form.value.email && !checkIsEmail(form.value.email)) {
uni.showToast({ icon: 'none', title: '请输入正确的邮箱地址!' })
return
}
uni.showLoading({ title: '正在提交...' })
try {
const res = await submitLink({
name: form.value.name,
url: form.value.url,
logo: form.value.logo,
description: form.value.description,
email: form.value.email,
linkPageUrl: form.value.linkPageUrl,
rssUrl: form.value.rssUrl,
})
uni.hideLoading()
const code = res.code
const msg = res.data?.msg || res.data?.message || res.message || '提交成功'
uni.showToast({ icon: 'none', title: msg })
if (code === 200 || code === undefined) {
setTimeout(() => {
uni.navigateTo({
url: '/pages-blog/friend-links/friend-links',
})
}, 1000)
}
}
catch (err) {
console.error(err)
uni.hideLoading()
uni.showToast({ icon: 'none', title: '提交失败,请重试!' })
}
}
function handleCopyLink() {
uni.setClipboardData({
data: calcBlogContent.value,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: '复制成功!' })
},
fail: () => {
uni.showToast({ icon: 'none', title: '复制失败!' })
},
})
}
onLoad(() => {
uni.setNavigationBarTitle({ title: '友链申请' })
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen bg-[#fafafd] p-8">
<!-- 博客详情卡片 -->
<view class="blog-coupon mb-6 flex items-center rounded-xl p-6" style="background: linear-gradient(135deg, #2196f3, #64b5f6);" @click="blogDetailPoupShow = true">
<image class="coupon-img h-[80rpx] w-[80rpx] shrink-0 rounded-xl" :src="checkAvatarUrl(blogDetail.blogLogo)" mode="aspectFill" />
<view class="coupon-info ml-5 flex-1">
<view class="coupon-title text-[30rpx] text-white font-bold">
{{ blogDetail.blogName }}
</view>
<view class="coupon-desc mt-1 text-[24rpx] text-white/80">
{{ blogDetail.blogDesc }}
</view>
</view>
<view class="coupon-btn border-2 border-white/60 rounded-3xl px-5 py-1 text-[24rpx] text-white">
友链详情
</view>
</view>
<!-- 友链信息提交表单 -->
<view class="form-wrap rounded-xl bg-white p-6 shadow-sm">
<view class="form-title mb-6 text-[26rpx] font-bold">
友链信息提交
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">名称</text>
<input v-model="form.name" class="input flex-1 text-[26rpx]" placeholder="请输入网站名称">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">网址</text>
<input v-model="form.url" class="input flex-1 text-[26rpx]" placeholder="请输入网站地址">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">Logo</text>
<input v-model="form.logo" class="input flex-1 text-[26rpx]" placeholder="请输入网站Logo">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">邮箱</text>
<input v-model="form.email" class="input flex-1 text-[26rpx]" placeholder="请输入邮箱">
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">友链页面</text>
<input v-model="form.linkPageUrl" class="input flex-1 text-[26rpx]" placeholder="请输入友链页面地址">
</view>
<view class="form-tip py-1 pl-[160rpx] text-[22rpx] text-[#999]">
贵站友情链接页面地址即包含本站链接的页面
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">RSS地址</text>
<input v-model="form.rssUrl" class="input flex-1 text-[26rpx]" placeholder="请输入RSS地址">
</view>
<view class="form-tip py-1 pl-[160rpx] text-[22rpx] text-[#999]">
用于抓取文章
</view>
<view class="form-item flex items-center border-b-2 border-[#f5f5f5] py-5">
<text class="label w-[160rpx] shrink-0 text-[26rpx] text-[#333]">网站描述</text>
<textarea v-model="form.description" class="textarea h-[100rpx] flex-1 text-[26rpx]" :maxlength="30" placeholder="请输入网站描述,不超过30字符" />
</view>
<view class="submit-btn mt-6">
<wd-button type="primary" block size="medium" @click="handleHandle">
提交数据
</wd-button>
<view class="submit-tip py-8 text-center text-[24rpx] text-[#999]">
友链申请
</view>
</view>
</view>
<!-- 博客详情弹窗 -->
<wd-popup v-model="blogDetailPoupShow" position="center" custom-style="width:640rpx;border-radius:12rpx;">
<view class="poup p-9">
<view class="info flex">
<image class="poup-logo h-[140rpx] w-[140rpx] rounded-3xl" :src="checkAvatarUrl(blogDetail.blogLogo)" mode="aspectFill" />
<view class="info-detail ml-6 flex flex-1 flex-col justify-center">
<view class="poup-name text-[34rpx] font-bold">
{{ blogDetail.blogName }}
</view>
<view class="poup-tag mt-2.5 text-[24rpx] text-[#999]">
{{ blogDetail.blogDesc }}
</view>
</view>
</view>
<view class="poup-desc mt-6 whitespace-pre-wrap text-[28rpx] text-[#555] leading-[1.8]">
<text>{{ calcBlogContent }}</text>
</view>
<image v-if="blogDetail.blogUrl" class="poup-img mt-6 h-[320rpx] w-[568rpx] rounded-xl" :src="calcSiteThumbnail(blogDetail.blogUrl)" mode="aspectFill" />
<view class="poup-link my-6 flex justify-center gap-6">
<wd-button size="small" plain type="primary" @click="handleCopyLink">
复制友链交换信息
</wd-button>
<wd-button size="small" plain @click="blogDetailPoupShow = false">
关闭
</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts" setup>
/**
* 标签详情页(源自旧项目 pagesA/tag-detail,新建复刻)
* 展示某标签下的文章列表,分页加载
*/
import { ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getPostByTagName } from '@/api/halo'
import type { IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '标签详情',
enablePullDownRefresh: true,
},
})
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 0 })
const name = ref('')
const pageTitle = ref('加载中...')
const dataList = ref<IPost[]>([])
const hasNext = ref(false)
const isLoadMore = ref(false)
const loadMoreText = ref('')
async function handleGetData() {
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const res = await getPostByTagName(name.value, { ...queryParams.value })
uni.setNavigationBarTitle({ title: `${pageTitle.value} (共${res.data.total}篇)` })
hasNext.value = res.data.hasNext
dataList.value = isLoadMore.value
? dataList.value.concat(res.data.items)
: res.data.items
loadMoreText.value = res.data.hasNext ? '上拉加载更多' : '呜呜,没有更多数据啦~'
setTimeout(() => {
loading.value = 'success'
}, 500)
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
}
}
function handleToArticleDetail(article: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
onLoad((options) => {
name.value = options?.name || ''
pageTitle.value = options?.title || '标签详情'
handleGetData()
})
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 0
handleGetData()
})
onReachBottom(() => {
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="dataList.length === 0" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="该标签下暂无文章" />
</view>
<block v-else>
<uh-article-card
v-for="(article, index) in dataList"
:key="index"
:article="article"
@on-click="handleToArticleDetail"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts" setup>
/**
* 文章标签页(源自旧项目 pagesA/tags,新建复刻)
*/
definePage({
style: {
navigationBarTitleText: '标签',
},
})
</script>
<template>
<view class="text-[30rpx] text-[#303133]">
文章标签页面
</view>
</template>
<style scoped>
.app-page {
/* 无额外样式 */
}
</style>
+416
View File
@@ -0,0 +1,416 @@
<script lang="ts" setup>
/**
* 投票详情页(源自旧项目 pagesA/vote-detail,新建复刻)
* 支持 single/multiple/pk 三种投票类型,已投票展示结果
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getVoteDetail, submitVote } from '@/api/uni-halo'
import { calcVotePercent, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteOption } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票详情',
enablePullDownRefresh: true,
},
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const submitLoading = ref(false)
const pageTitle = ref('加载中...')
const safeAreaBottom = ref(24)
const name = ref('')
const detail = ref<unknown>(null)
const vote = ref<(IVote & {
spec?: {
title?: string
remark?: string
type?: string
maxVotes?: number
startDate?: string
endDate?: string
timeLimit?: string
canAnonymously?: boolean
options?: (IVoteOption & {
id?: string
title?: string
count?: number
checked?: boolean
isVoted?: boolean
disabled?: boolean
_uh_percent?: number
})[]
isVoted?: boolean
hasEnded?: boolean
disabled?: boolean
_uh_type?: string
_uh_state?: { state: string, color: string }
}
stats?: { voteCount?: number }
}) | null>(null)
const submitForm = ref<{ voteData: string[] }>({ voteData: [] })
/* ---------------- 计算属性 ---------------- */
const isVoted = computed(() => voteCacheUtil.has(name.value))
const isEnded = computed(() => vote.value?.spec?.hasEnded || false)
/* ---------------- 工具 ---------------- */
function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
// 与旧项目一致:yyyy-MM-dd HH:mm
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
function showToast(content: string) {
uni.showToast({ icon: 'none', title: content, mask: true })
}
function handleCalcIsChecked(option: { id?: string }): boolean {
const data = voteCacheUtil.get(name.value)
if (!data)
return false
return data.selected.includes(option.id || '')
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
pageTitle.value = '加载中...'
try {
const res = await getVoteDetail(name.value)
const tempVote = res.data as typeof vote.value
if (tempVote) {
pageTitle.value = `投票详情(${VOTE_TYPES[(tempVote.spec?.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec?.type}`
tempVote.spec = tempVote.spec || {}
tempVote.spec.isVoted = isVoted.value
tempVote.spec.disabled = isVoted.value
tempVote.spec._uh_type = VOTE_TYPES[(tempVote.spec.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec.type
// 计算状态
const startTime = tempVote.spec.startDate ? new Date(tempVote.spec.startDate).getTime() : Date.now()
const endTime = tempVote.spec.endDate ? new Date(tempVote.spec.endDate).getTime() : Date.now()
const now = Date.now()
if (endTime < now) {
tempVote.spec._uh_state = { state: '已结束', color: 'red' }
tempVote.spec.hasEnded = true
}
else if (startTime > now) {
tempVote.spec._uh_state = { state: '未开始', color: 'orange' }
}
else {
tempVote.spec._uh_state = { state: '进行中', color: 'green' }
}
// 选项计算
tempVote.spec.options = (tempVote.spec.options || []).map((option) => {
const checked = handleCalcIsChecked(option)
return {
...option,
value: option.id,
label: option.title,
isVoted: isVoted.value,
checked,
disabled: isVoted.value,
_uh_percent: calcVotePercent(tempVote, option),
}
})
}
vote.value = tempVote
detail.value = res
setTimeout(() => {
loading.value = 'success'
}, 200)
}
catch (err) {
console.error(err)
loading.value = 'error'
pageTitle.value = '加载失败,请重试...'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
uni.setNavigationBarTitle({ title: pageTitle.value })
}, 200)
}
}
/* ---------------- 交互 ---------------- */
function handleSelectSingleOption(option: { id?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
vote.value!.spec!.options!.forEach((item) => {
item.checked = option.id === item.id
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
function handleSelectCheckboxOption(option: { id?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
const checkedList = vote.value!.spec!.options!.filter(x => x.checked && x.id !== option.id)
if (vote.value?.spec?.type === 'multiple' && checkedList.length >= (vote.value.spec.maxVotes || 0)) {
showToast(`最多选择 ${vote.value.spec.maxVotes}`)
return
}
vote.value!.spec!.options!.forEach((item) => {
if (option.id === item.id) {
item.checked = !item.checked
}
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
function handleSubmitTip(text: string) {
showToast(text)
}
async function handleSubmit() {
if (!vote.value?.spec?.canAnonymously) {
uni.showModal({
title: '提示',
content: '该投票不支持匿名,请到博主的 网站端 进行投票!',
cancelColor: '#666666',
cancelText: '关闭',
confirmText: '复制地址',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: import.meta.env.VITE_SERVER_BASEURL || '',
showToast: false,
success: () => {
showToast('复制成功')
},
})
}
},
})
return
}
submitLoading.value = true
uni.showLoading({ title: '正在保存...' })
try {
await submitVote(name.value, submitForm.value, vote.value.spec.canAnonymously)
showToast('提交成功')
voteCacheUtil.set(name.value, {
selected: [...submitForm.value.voteData],
data: vote.value,
})
setTimeout(() => {
uni.startPullDownRefresh()
submitLoading.value = false
}, 1500)
}
catch (err) {
console.error(err)
showToast('提交失败,请重试')
submitLoading.value = false
}
finally {
uni.hideLoading()
}
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
name.value = options?.name || ''
// #ifndef H5
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom ? systemInfo.safeAreaInsets.bottom + 12 : 24
// #endif
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
onShareAppMessage(() => ({
path: `/pages-blog/vote-detail/vote-detail?name=${name.value}`,
title: vote.value?.spec?.title || '来投个票吧',
imageUrl: '',
}))
onShareTimeline(() => ({
title: vote.value?.spec?.title || '来投个票吧',
query: name.value ? `name=${name.value}` : '',
imageUrl: '',
}))
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6 pb-[160rpx]" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="!vote" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="未查询到数据" />
</view>
<block v-else>
<!-- 投票信息 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票信息
</view>
<view class="vote-card-body flex flex-col gap-3 rounded-xl bg-[#f3f4f6] p-6 text-[28rpx] text-[#3f3f3f]">
<view class="info-row">
<text>投票类型</text>
<text class="tag">{{ vote.spec?._uh_type }}</text>
</view>
<view class="info-row">
<text>投票状态</text>
<text class="tag" :style="{ color: vote.spec?._uh_state?.color }">{{ vote.spec?._uh_state?.state }}</text>
</view>
<view class="info-row">
<text>投票方式</text>
<text class="tag" :class="vote.spec?.canAnonymously ? 'text-[#03a9f4]' : 'text-[#f44336]'">
{{ vote.spec?.canAnonymously ? '匿名' : '不匿名' }}
</text>
</view>
<view class="info-row">
<text>开始时间{{ formatTime(vote.spec?.startDate) }}</text>
</view>
<view class="info-row">
<text v-if="vote.spec?.timeLimit === 'permanent'">结束时间永久有效</text>
<text v-else>结束时间{{ formatTime(vote.spec?.endDate) }}</text>
</view>
</view>
</view>
<!-- 投票内容 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票内容
</view>
<view class="sub-content mb-3 pt-3 text-[30rpx] text-[#2b2f33] font-bold">
{{ vote.spec?.title }}
</view>
<view v-if="vote.spec?.remark" class="sub-remark mb-9 pt-3 text-[28rpx] text-[#3f3f3f]">
{{ vote.spec.remark }}
</view>
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票选项
<text v-if="vote.spec?.type === 'multiple'" class="sub-title-count text-[24rpx] font-normal">最多选择 {{ vote.spec?.maxVotes }} </text>
</view>
<view class="options mt-6 flex flex-col gap-4">
<template v-if="isVoted || isEnded">
<view
v-for="(option, optionIndex) in vote.spec?.options"
:key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-[#03a9f4]/35 text-white' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${option._uh_percent}%` }"
>
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ option.title }}
</view>
<view class="shrink-0">
{{ option._uh_percent }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view
v-for="(option, optionIndex) in vote.spec?.options"
:key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f3f4f6] px-6 py-5 text-[24rpx]"
:class="option.checked ? 'border-2 border-[#03a9f4] bg-[#03a9f4]/15 text-[#03a9f4]' : ''"
@click="vote.spec?.type === 'multiple' ? handleSelectCheckboxOption(option) : handleSelectSingleOption(option)"
>
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
</view>
</template>
</view>
</view>
<!-- 投票统计 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票统计
</view>
<view class="stat-text mt-3 text-[26rpx] text-[#606266]">
{{ vote.stats?.voteCount || 0 }} 人已参与
</view>
</view>
<!-- 提交按钮 -->
<view class="vote-submit fixed bottom-0 left-0 z-99 box-border w-screen border-t-2 border-[#eee] bg-white/98 px-9 py-6 shadow-sm" :style="{ paddingBottom: `${safeAreaBottom}rpx` }">
<wd-button v-if="isVoted" disabled block>
您已参与投票
</wd-button>
<wd-button v-else-if="vote.spec?._uh_state?.state === '未开始'" plain block type="warning" @click="handleSubmitTip('投票未开始')">
投票未开始
</wd-button>
<wd-button v-else-if="vote.spec?._uh_state?.state === '已结束'" plain block type="danger" @click="handleSubmitTip('投票已结束')">
投票已结束
</wd-button>
<wd-button v-else-if="!vote.spec?.canAnonymously" plain block type="danger" @click="handleSubmit()">
不支持匿名投票
</wd-button>
<wd-button v-else-if="submitForm.voteData.length === 0" plain block @click="handleSubmitTip('请选择选项')">
提交投票请选择选项
</wd-button>
<wd-button v-else block type="primary" :loading="submitLoading" :disabled="submitLoading" @click="handleSubmit()">
提交投票
</wd-button>
</view>
</block>
</block>
</view>
</template>
<style scoped lang="scss">
.vote-card {
.sub-title {
&::before {
content: '';
width: 8rpx;
height: 28rpx;
position: absolute;
left: 0;
top: 6rpx;
background: #03a9f4;
border-radius: 6rpx;
}
}
.is-voted-item {
&::before {
content: '';
width: var(--percent);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: #d0d0d0;
z-index: 0;
border-radius: 6rpx;
}
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts" setup>
/**
* 投票列表页(源自旧项目 pagesA/votes,新建复刻)
* 展示投票列表,每个投票项用 uh-vote-card 渲染
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getVoteList } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import type { IVoteItem } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票中心',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const calcAuditModeEnabled = computed(() => !!appConfigStore.configs.auditConfig?.auditModeEnabled)
/** 依赖插件(plugin-vote) */
const uniHaloPluginId = 'plugin-vote'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const dataList = ref<IVoteItem[]>([])
const hasNext = ref(false)
const queryParams = ref({ page: 1, size: 10 })
const isLoadMore = ref(false)
const loadMoreText = ref('加载中...')
async function handleGetData() {
if (calcAuditModeEnabled.value) {
loading.value = 'success'
loadMoreText.value = '呜呜,没有更多数据啦~'
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: '加载中...' })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = '加载中...'
try {
const res = await getVoteList({ ...queryParams.value })
loading.value = 'success'
hasNext.value = (res.data as unknown as { hasNext?: boolean }).hasNext || false
dataList.value = isLoadMore.value
? dataList.value.concat(res.data as IVoteItem[])
: (res.data as IVoteItem[])
loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
function handleOnVoteSuccess() {
uni.showToast({ icon: 'none', title: '投票成功!' })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col" style="background-color: #fafafd;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用投票功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<view v-if="loading !== 'success'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="content flex flex-col gap-6 p-3">
<view v-if="dataList.length === 0" class="min-h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="博主还未发布投票~" />
</view>
<block v-else>
<uh-vote-card
v-for="vote in dataList"
:key="vote.name"
:vote-name="vote.name"
@on-vote-success="handleOnVoteSuccess"
/>
<view class="load-text py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
<view class="to-top-btn fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts" setup>
/**
* 网站浏览页(源自旧项目 pagesC/website,新建复刻)
* 内嵌 web-view 展示外部链接
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
definePage({
style: {
navigationBarTitleText: '加载中...',
},
})
const webUrl = ref('')
onLoad((options) => {
try {
const data = JSON.parse(options?.data || '{}')
const { title, url } = data
webUrl.value = decodeURIComponent(url || '')
if (title) {
uni.setNavigationBarTitle({ title })
}
}
catch (err) {
console.error('解析网站参数失败', err)
}
})
</script>
<template>
<view class="app-page w-screen">
<web-view v-if="webUrl" :src="webUrl" />
<view v-else class="text-grey h-[60vh] flex items-center justify-center">
<wd-empty description="链接地址为空" />
</view>
</view>
</template>
<style scoped>
.app-page {
width: 100vw;
}
</style>
+147 -14
View File
@@ -1,7 +1,15 @@
<script lang="ts" setup>
defineOptions({
name: 'Index',
})
/**
* 入口页(源自旧项目 pages/index/index.vue,新建复刻)
* 职责:检查插件可用性 → 获取配置 → 二维码 scene 跳文章 → 审计模式 mock → 启动页/首页分流
*/
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getQRCodeInfo } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig'
import { usePluginAvailable } from '@/utils/plugin'
import { checkJsonAndParse } from '@/utils/json'
definePage({
// 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
type: 'home',
@@ -11,23 +19,148 @@ definePage({
navigationBarTitleText: '初始页面',
},
})
console.log('index/index 首页打印了')
function toHome() {
uni.switchTab({
url: '/pages/tabbar/home/home',
})
/* ---------------- 常量 ---------------- */
const homePagePath = '/pages/tabbar/home/home'
const startPagePath = '/pages/start/start'
const articleDetailPath = '/pages-blog/article-detail/article-detail'
// 本地开发快速跳转页面,发布请置为 false
const DEV_MODE = false
const DEV_TO_TYPE = 'page' as 'page' | 'tabbar'
const DEV_TO_PATH = '/pages-blog/data-visual/data-visual'
/* ---------------- 状态 ---------------- */
const appConfigStore = useAppConfigStore()
const uniHaloPluginId = 'plugin-uni-halo'
const uniHaloPluginAvailableError = '阿偶,检测到当前插件没有安装或者启用,无法启动 uni-halo 哦,请联系管理员'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 逻辑 ---------------- */
/** 检查插件可用性 */
async function handleCheckPluginAvailable(): Promise<boolean> {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
return uniHaloPluginAvailable.value
}
onLoad(() => {
console.log('测试 uni API 自动引入: onLoad')
/** 通过二维码 scene 获取文章 id */
async function getPostIdByQRCode(key: string): Promise<string | null> {
try {
const response = await getQRCodeInfo(key)
if (response.data?.postId)
return response.data.postId as string
}
catch (err) {
console.error('二维码解析失败', err)
}
return null
}
/** 处理审计模式 mock 数据 */
async function handleAuditMode(res: Record<string, unknown>) {
const auditConfig = (res?.auditConfig ?? {}) as {
auditModeEnabled?: boolean
auditModeData?: { jsonUrl?: string, jsonData?: string }
}
if (!auditConfig.auditModeEnabled)
return
if (auditConfig.auditModeData?.jsonUrl) {
await appConfigStore.fetchMockJson()
}
else {
const mockJson = checkJsonAndParse(auditConfig.auditModeData?.jsonData || '')
if (mockJson.ok) {
appConfigStore.setMockJson(mockJson.jsonData as Record<string, unknown>)
}
}
}
/** 启动页/首页分流 */
function handleCheckShowStarted() {
const appConfig = (appConfigStore.configs.appConfig ?? {}) as {
startConfig?: { enabled?: boolean, alwaysShow?: boolean }
}
const startConfig = appConfig.startConfig
// 未开启启动页,直接进首页
if (!startConfig?.enabled) {
uni.switchTab({ url: homePagePath })
return
}
// 是否每次都显示启动页
if (startConfig.alwaysShow) {
uni.removeStorageSync('APP_HAS_STARTED')
uni.redirectTo({ url: startPagePath })
return
}
// 只显示一次启动页
if (uni.getStorageSync('APP_HAS_STARTED')) {
uni.switchTab({ url: homePagePath })
}
else {
uni.redirectTo({ url: startPagePath })
}
}
onLoad(async (options) => {
// 本地开发,快速跳转页面,发布请设置 DEV_MODE = false
if (DEV_MODE && DEV_TO_PATH) {
if (DEV_TO_TYPE === 'tabbar') {
uni.switchTab({ url: DEV_TO_PATH })
}
else {
uni.navigateTo({ url: DEV_TO_PATH })
}
return
}
// 检查插件
if (!(await handleCheckPluginAvailable()))
return
// 获取配置
try {
const res = await appConfigStore.fetchConfigs()
if (!res) {
uni.switchTab({ url: homePagePath })
return
}
// 二维码 scene 进入:解析 postId 跳文章详情
if (options.scene && options.scene !== '') {
const postId = await getPostIdByQRCode(decodeURIComponent(options.scene))
if (postId) {
uni.redirectTo({
url: `${articleDetailPath}?name=${postId}`,
animationType: 'slide-in-right',
})
return
}
}
// 审计模式 mock
await handleAuditMode(res as Record<string, unknown>)
// 启动页分流
handleCheckShowStarted()
}
catch (err) {
console.error('入口页初始化失败', err)
uni.switchTab({ url: homePagePath })
}
})
</script>
<template>
<view class="box-border bg-white px-4 pt-safe w-screen h-screen flex flex-col items-center justify-center">
uni-halo
<wd-button @click="toHome()">去首页</wd-button>
<view class="app-page h-screen w-screen flex items-center justify-center" style="background-color: #fff;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
:error-text="uniHaloPluginAvailableError"
:use-border="false"
:use-decoration="false"
/>
</view>
</template>
+299
View File
@@ -0,0 +1,299 @@
<script lang="ts" setup>
/**
* 启动页(源自旧项目 pagesA/start,新建复刻)
* 支持颜色/图片/视频/星空四种背景类型 + logo/标题/描述 + 开始按钮 + 波浪
*/
import { computed } from 'vue'
import { checkImageUrl, checkUrl } from '@/utils/url'
import { useAppConfigStore } from '@/store/appConfig'
definePage({
style: {
navigationBarTitleText: 'uni-halo',
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const startConfig = computed(() => (haloConfigs.value.appConfig?.startConfig as {
title?: string
logo?: string
desc1?: string
desc2?: string
btnText?: string
btnClass?: string
btnStyle?: string
titleStyle?: string
descStyle?: string
backgroundType?: string
bg?: string
bgImage?: string
bgImageFit?: string
bgVideo?: string
bgVideoFit?: string
useWave?: boolean
} | undefined) || {})
const calcBackgroundType = computed(() => startConfig.value.backgroundType || 'star')
const calcPageClass = computed(() => {
if (calcBackgroundType.value === 'color') {
return [startConfig.value.bg]
}
return []
})
const calcPageStyle = computed(() => {
if (calcBackgroundType.value === 'color') {
return {}
}
if (calcBackgroundType.value === 'image') {
return {
backgroundImage: `url(${checkImageUrl(startConfig.value.bgImage)}) !important`,
backgroundSize: startConfig.value.bgImageFit || 'cover',
}
}
if (calcBackgroundType.value === 'video') {
return {
background: '#ffffff',
}
}
return {}
})
function handleStart() {
uni.switchTab({
url: '/pages/tabbar/home/home',
success: () => {
uni.setStorageSync('APP_HAS_STARTED', true)
},
})
}
</script>
<template>
<view class="app-page relative h-screen w-screen" :class="calcPageClass" :style="[calcPageStyle]">
<!-- 星空背景 -->
<view v-if="calcBackgroundType !== 'video'" class="star-bg fixed z-998 h-[600px] w-full shrink-0 overflow-hidden">
<view class="stars absolute z-1 h-[400px] w-full">
<view class="falling-stars">
<view class="star-fall" />
<view class="star-fall" />
<view class="star-fall" />
<view class="star-fall" />
</view>
<view class="small-stars">
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
<view class="star" />
</view>
</view>
</view>
<!-- 视频背景 -->
<video
v-else
class="video-bg absolute left-0 top-0 z-0 h-screen w-screen"
:object-fit="(startConfig.bgVideoFit as 'contain' | 'cover') || 'cover'"
:src="checkUrl(startConfig.bgVideo)"
:loop="true"
:autoplay="true"
:muted="true"
:controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:show-loading="false"
:enable-progress-gesture="false"
:show-progress="false"
/>
<!-- 标题区域 -->
<view v-if="startConfig.title || startConfig.logo" class="title-container absolute left-0 top-[20vh] z-999 w-screen flex flex-col items-center justify-center">
<view v-if="startConfig.logo" class="app-logo h-[200rpx] w-[200rpx]">
<view class="app-logo-border box-border h-full w-full overflow-hidden border-8 border-white/35 rounded-full">
<image class="app-logo-image h-full w-full rounded-full" :src="checkImageUrl(startConfig.logo)" mode="aspectFill" />
</view>
</view>
<view v-if="startConfig.title" class="app-title mt-6 text-center text-[36rpx] text-white font-semibold" :style="startConfig.titleStyle">
{{ startConfig.title }}
</view>
</view>
<!-- 底部区域 -->
<view class="bottom-container absolute bottom-[50rpx] left-1/2 z-999 flex flex-col items-center -translate-x-1/2">
<view class="desc-area pt-[60vh] text-white" :style="startConfig.descStyle">
<view v-show="startConfig.desc1" class="desc1 text-center text-[44rpx]">
{{ startConfig.desc1 }}
</view>
<view v-show="startConfig.desc2" class="desc2 mt-8 text-center text-[26rpx]">
{{ startConfig.desc2 }}
</view>
</view>
<view class="start-btn mb-[120rpx] mt-[60rpx] box-border border-2 border-white rounded-[50rpx] px-12 py-4 text-center text-[28rpx] text-white" :class="[startConfig.btnClass]" :style="[startConfig.btnStyle]" @click="handleStart">
{{ startConfig.btnText || '开始体验' }}
</view>
</view>
<!-- 波浪效果 -->
<image v-if="startConfig.useWave" class="wave-img absolute bottom-0 left-0 z-99 h-[100rpx] w-full" src="/static/wave/wave-1.png" mode="scaleToFill" />
</view>
</template>
<style scoped lang="scss">
.app-page {
background-size: cover;
background-repeat: no-repeat;
background: linear-gradient(180deg, #0f1e3d 0%, #1a3a6b 100%);
}
/* 星空背景(动画无法用 UnoCSS 表达,保留样式) */
.star-bg {
.star {
border-radius: 50%;
background: #fff;
box-shadow: 0 0 6px 0 rgb(255 255 255 / 80%);
}
.small-stars .star {
position: absolute;
width: 3px;
height: 3px;
opacity: 0;
animation: star-blink 1.2s linear infinite alternate;
&:nth-child(1) {
left: 40px;
bottom: 50px;
}
&:nth-child(2) {
left: 200px;
bottom: 40px;
}
&:nth-child(3) {
left: 60px;
bottom: 120px;
}
&:nth-child(4) {
left: 140px;
bottom: 250px;
}
&:nth-child(5) {
left: 400px;
bottom: 300px;
}
&:nth-child(6) {
left: 170px;
bottom: 80px;
}
&:nth-child(7) {
left: 200px;
bottom: 360px;
animation-delay: 0.2s;
}
&:nth-child(8) {
left: 250px;
bottom: 320px;
}
&:nth-child(9) {
left: 300px;
bottom: 340px;
}
&:nth-child(10) {
left: 130px;
bottom: 320px;
animation-delay: 0.5s;
}
&:nth-child(11) {
left: 230px;
bottom: 330px;
animation-delay: 0.7s;
}
&:nth-child(12) {
left: 300px;
bottom: 360px;
animation-delay: 0.3s;
}
}
.star-fall {
position: relative;
border-radius: 2px;
width: 80px;
height: 2px;
overflow: hidden;
transform: rotate(-20deg);
&::after {
content: '';
position: absolute;
width: 50px;
height: 2px;
background: linear-gradient(to left, rgb(0 0 0 / 0%) 0%, rgb(255 255 255 / 40%) 100%);
left: 100%;
animation: star-fall 3.6s linear infinite;
}
&:nth-child(1) {
left: 80px;
bottom: -100px;
&::after {
animation-delay: 2.4s;
}
}
&:nth-child(2) {
left: 200px;
bottom: -200px;
&::after {
animation-delay: 2s;
}
}
&:nth-child(3) {
left: 430px;
bottom: -50px;
&::after {
animation-delay: 3.6s;
}
}
&:nth-child(4) {
left: 400px;
bottom: 100px;
&::after {
animation-delay: 0.2s;
}
}
}
}
@keyframes star-blink {
50% {
opacity: 1;
}
}
@keyframes star-fall {
20% {
left: -100%;
}
100% {
left: -100%;
}
}
/* 波浪混合模式(无法用 UnoCSS 表达) */
.wave-img {
mix-blend-mode: screen;
}
</style>
+323 -2
View File
@@ -1,13 +1,334 @@
<script lang="ts" setup>
/**
* 关于页(源自旧项目 pages/tabbar/about/about.vue,新建复刻)
* 功能:博主信息 + 站点统计 + 功能导航 + 版权
*/
import { computed, ref, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getBlogStatistics } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkImageUrl } from '@/utils/url'
import { checkHasAdminLogin } from '@/utils/auth'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { IBlogStats } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '关于',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
/* ---------------- 计算属性 ---------------- */
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as
| { nickname?: string, avatar?: string, description?: string }
| undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
description: blogger?.description || '',
}
})
const pageConfig = computed(() => haloConfigs.value.pageConfig?.aboutConfig as
| { bgImageUrl?: string, waveImageUrl?: string }
| undefined)
const calcProfileStyle = computed(() => ({
backgroundImage: `url(${checkImageUrl(pageConfig.value?.bgImageUrl)})`,
}))
const calcWaveUrl = computed(() => checkImageUrl(pageConfig.value?.waveImageUrl))
const basicConfig = computed(() => haloConfigs.value.basicConfig as
| {
copyrightConfig?: { enabled?: boolean, content?: string }
disclaimers?: { enabled?: boolean }
showAboutSystem?: boolean
}
| undefined)
const copyrightConfig = computed(() => basicConfig.value?.copyrightConfig)
const loveEnabled = computed(() => !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean } | undefined)?.loveEnabled)
const socialEnabled = computed(() => !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled)
/* ---------------- 状态 ---------------- */
const statisticsShowMore = ref(false)
const statistics = ref<IBlogStats>({ post: 0, comment: 0, category: 0, visit: 0, upvote: 0 })
const navList = ref<{
key: string
title: string
icon: string
iconColor: string
rightText: string
path: string | null
isAdmin?: boolean
openType?: string
show: boolean
}[]>([])
/* ---------------- 功能导航 ---------------- */
async function handleGetNavList() {
let isWx = false
// #ifdef MP-WEIXIN
isWx = true
// #endif
const dataVisualAvailable = await usePluginAvailable('plugin-data-statistics')
navList.value = [
{
key: 'data-visual',
title: '数据看板',
icon: 'chart',
iconColor: '#2196f3',
rightText: '站点数据可视化',
path: '/pages-blog/data-visual/data-visual',
show: dataVisualAvailable,
},
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
icon: 'folder',
iconColor: '#f44336',
rightText: calcAuditModeEnabled.value ? '全部已归档内容' : '全部已归档文章',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'love',
title: '恋爱日记',
icon: 'heart',
iconColor: '#f44336',
rightText: '博主的恋爱日记',
path: '/pages-blog/love/love',
show: loveEnabled.value,
},
{
key: 'vote',
title: '投票中心',
icon: 'box',
iconColor: '#f44336',
rightText: '查看和进行投票',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
},
{
key: 'friend-links',
title: '友情链接',
icon: 'link',
iconColor: '#2196f3',
rightText: '看看博主朋友们吧',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
},
{
key: 'disclaimers',
title: '免责声明',
icon: 'map',
iconColor: '#f44336',
rightText: '博客内容免责声明',
path: '/pages-blog/disclaimers/disclaimers',
show: !!basicConfig.value?.disclaimers?.enabled,
},
{
key: 'contact-blogger',
title: '联系博主',
icon: 'message',
iconColor: '#ff9800',
rightText: '博主常用联系方式',
path: '/pages-blog/contact/contact',
show: socialEnabled.value,
},
{
key: 'about',
title: '关于项目',
icon: 'info',
iconColor: '#2196f3',
rightText: '小莫唐尼开源项目',
path: '/pages-blog/about/about',
show: !!basicConfig.value?.showAboutSystem,
},
]
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
try {
const res = await getBlogStatistics()
statistics.value = res.data
}
catch (err) {
console.error('获取统计失败', err)
uni.showToast({ icon: 'none', title: t('common.loadFailedRetry') })
}
finally {
uni.stopPullDownRefresh()
}
}
/* ---------------- 交互 ---------------- */
function handleOnNav(data: { path: string | null, isAdmin?: boolean }) {
const { path, isAdmin } = data
if (!path)
return
// 拦截后台管理页面(需超管登录)
if (isAdmin && !checkHasAdminLogin()) {
uni.showModal({
title: '提示',
content: '未登录超管账号或登录状态已过期,是否立即登录?',
showCancel: true,
cancelText: '否',
cancelColor: '#999999',
confirmText: '是',
confirmColor: '#03a9f4',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/auth/login' })
}
},
})
return
}
uni.navigateTo({ url: path })
}
/* ---------------- 生命周期 ---------------- */
watch(haloConfigs, () => {
handleGetNavList()
}, { deep: true, immediate: true })
handleGetData()
onPullDownRefresh(() => {
handleGetData()
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
关于页面
<view class="app-page min-h-screen w-screen pb-6">
<!-- 博主信息 -->
<view class="blogger-info relative h-[600rpx] w-full" :style="[calcProfileStyle]">
<image class="avatar absolute left-1/2 top-[200rpx] z-2 h-[130rpx] w-[130rpx] border-6 border-white rounded-full -translate-x-1/2" :src="bloggerInfo.avatar" mode="aspectFill" />
<view class="profile absolute left-0 top-[340rpx] z-6 w-full text-center text-white">
<view class="author text-[34rpx] font-bold">
{{ bloggerInfo.nickname }}
</view>
<view class="desc mt-4 px-12 text-[26rpx] opacity-90">
{{ bloggerInfo.description || '这个博主很懒,竟然没写介绍~' }}
</view>
</view>
<image v-if="calcWaveUrl" :src="calcWaveUrl" mode="scaleToFill" class="gif-wave absolute bottom-0 left-0 z-99 h-[100rpx] w-full" style="mix-blend-mode: screen;" />
</view>
<!-- 统计信息 -->
<view class="statistics-wrap overflow-hidden rounded-b-3xl bg-white shadow-sm">
<view class="statistics flex pb-3 pt-3">
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
{{ statistics.post }}
</view>
<view class="mt-1 text-center text-[24rpx] text-[#999]">
内容数量
</view>
</view>
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #4caf50;">
{{ statistics.visit }}
</view>
<view class="mt-1 text-[24rpx] text-[#999]">
访客数量
</view>
</view>
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
{{ statistics.category }}
</view>
<view class="mt-1 text-center text-[24rpx] text-[#999]">
分类总数
</view>
</view>
</view>
<view v-if="statisticsShowMore" class="statistics flex border-t-2 border-[#fafafa] pb-3 pt-3">
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #ff9800;">
{{ statistics.comment }}
</view>
<view class="mt-1 text-center text-[24rpx] text-[#999]">
评论数量
</view>
</view>
<view class="item flex-1 py-6 text-center">
<view class="number text-[40rpx] font-bold" style="color: #2196f3;">
{{ statistics.upvote }}
</view>
<view class="mt-1 text-[24rpx] text-[#999]">
点赞数量
</view>
</view>
</view>
<view class="show-more-btn pb-4 text-center text-[24rpx] text-[#999]" @click="statisticsShowMore = !statisticsShowMore">
{{ statisticsShowMore ? '收起' : '展开' }}
</view>
</view>
<!-- 功能导航 -->
<view class="nav-wrap mx-6 mt-6 overflow-hidden rounded-xl bg-white shadow-sm">
<template v-for="nav in navList.filter(n => n.show)" :key="nav.key">
<view class="nav-item flex items-center justify-between border-b-2 border-[#f5f5f5] px-3 py-7" @click="handleOnNav(nav)">
<view class="nav-left flex items-center gap-4">
<wd-icon :name="nav.icon" size="18px" :color="nav.iconColor" />
<text class="nav-title text-[28rpx] text-[#303133]">{{ nav.title }}</text>
</view>
<view class="nav-right flex items-center gap-2">
<text class="nav-right-text text-[24rpx] text-[#c0c4cc]">{{ nav.rightText }}</text>
<wd-icon name="arrow-right" size="12px" color="#c0c4cc" />
</view>
</view>
</template>
</view>
<!-- 版权 -->
<view v-if="copyrightConfig?.enabled" class="copyright mt-10 px-6 text-center text-[22rpx] text-[#c0c4c7]">
<view>{{ copyrightConfig.content }}</view>
</view>
</view>
</template>
<style scoped lang="scss">
.app-page {
.blogger-info {
background-size: cover;
background-repeat: no-repeat;
&::before {
content: '';
width: 100%;
height: 100%;
position: absolute;
background-color: rgb(0 0 0 / 30%);
z-index: 0;
}
}
.nav-wrap {
.nav-item {
&:last-child {
border-bottom: none;
}
}
}
}
</style>
+365 -2
View File
@@ -1,13 +1,376 @@
<script lang="ts" setup>
/**
* 分类页(源自旧项目 pages/tabbar/category/category.vue,新建复刻)
* 两种视图:list(分类卡片网格)/ list-post(左侧分类导航 + 右侧文章列表)
*/
import { computed, ref, watch } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getCategoryList, getCategoryPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkThumbnailUrl } from '@/utils/url'
import { t } from '@/locale'
import type { ICategory, IPost } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '分类',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const categoryConfig = computed(() => haloConfigs.value.pageConfig?.categoryConfig)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({
size: 20,
page: 1,
fieldSelector: ['spec.hideFromList=false'],
})
const hasNext = ref(false)
const dataList = ref<ICategory[]>([])
const categoryList = ref<ICategory[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const currentCategoryConfig = ref<{ type?: string }>({ type: 'list' })
const currentCategoryName = ref('')
const postQueryParams = ref({ size: 10, page: 0 })
const postList = ref<IPost[]>([])
/* ---------------- 计算属性 ---------------- */
const calcShowType = computed(() => currentCategoryConfig.value.type)
/* ---------------- 视图切换 ---------------- */
function handleChangeShowType() {
currentCategoryConfig.value.type = calcShowType.value === 'list-post' ? 'list' : 'list-post'
handleInitPage()
}
function handleResetInit() {
postList.value = []
dataList.value = []
categoryList.value = []
queryParams.value.page = 1
postQueryParams.value.page = 0
hasNext.value = false
isLoadMore.value = false
loadMoreText.value = t('common.loading')
currentCategoryName.value = ''
}
function handleInitPage() {
handleResetInit()
if (calcShowType.value === 'list-post') {
queryParams.value.size = 99999
}
handleGetData()
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
currentCategoryConfig.value.type = 'list'
const categoryMock = mockJson.value.category as { list?: { title?: string, cover?: string }[] } | undefined
dataList.value = (categoryMock?.list || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
displayName: item.title || '',
slug: '',
priority: 0,
cover: checkThumbnailUrl(item.cover, true),
},
postCount: 0,
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getCategoryList({ ...queryParams.value })
if (calcShowType.value === 'list') {
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
const tempItems = res.data.items.map(item => ({
...item,
postCount: item.postCount ?? 0,
spec: { ...item.spec, cover: checkThumbnailUrl(item.spec.cover, true) },
}))
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
}
else {
dataList.value = res.data.items
categoryList.value = res.data.items.map(item => ({
...item,
postCount: item.postCount ?? 0,
}))
loading.value = 'success'
if (dataList.value.length !== 0) {
currentCategoryName.value = dataList.value[0].metadata.name
handleGetPostByCategory()
}
}
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
/** 获取当前分类下的文章 */
async function handleGetPostByCategory(isPulldownRefresh = true) {
if (!isPulldownRefresh) {
if (hasNext.value) {
postQueryParams.value.page += 1
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
}
else {
postQueryParams.value.page = 0
}
try {
const res = await getCategoryPostList(currentCategoryName.value, postQueryParams.value)
hasNext.value = res.data.hasNext
postList.value = isPulldownRefresh
? res.data.items
: postList.value.concat(res.data.items)
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
loadMoreText.value = t('common.loadFailedShort')
console.error(err)
}
}
/* ---------------- 交互 ---------------- */
function handleOnCategoryChange(e: { detail: { current: number } }) {
const index = e.detail.current
if (!dataList.value[index])
return
currentCategoryName.value = dataList.value[index].metadata.name
postList.value = []
handleGetPostByCategory()
}
function handleToCategory(category: ICategory) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToArticleDetail(post: IPost) {
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${post.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleScrollTop() {
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
}
/* ---------------- 生命周期 ---------------- */
watch(categoryConfig, (newVal) => {
if (!newVal)
return
currentCategoryConfig.value = newVal
uni.setNavigationBarTitle({ title: t('page.category.title') })
handleInitPage()
}, { deep: true, immediate: true })
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (hasNext.value) {
if (calcShowType.value === 'list') {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
postQueryParams.value.page += 1
handleGetPostByCategory(false)
}
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
分类
<view class="app-page min-h-screen w-screen flex flex-col" :style="{ padding: calcShowType === 'list-post' ? '0' : '24rpx 0' }">
<!-- 骨架屏 -->
<view v-if="loading !== 'success'" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<!-- 内容区域 -->
<view v-else class="app-page-content flex flex-wrap gap-y-5 px-1.5" :class="[calcShowType === 'list-post' ? 'list-post' : '']">
<view v-if="dataList.length === 0" class="h-[70vh] flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
</view>
<block v-else>
<!-- list 视图:分类卡片网格 -->
<block v-if="calcAuditModeEnabled || calcShowType === 'list'">
<view
v-for="(item, index) in dataList"
:key="index"
class="catgory-card box-border w-1/2 p-1"
:style="{ backgroundImage: `url(${item.spec.cover})` }"
>
<view class="catgory-card-content h-[200rpx] flex flex-col items-center justify-center overflow-hidden rounded-xl shadow-sm" @click="handleToCategory(item)">
<view class="catgory-name z-2 text-[32rpx] text-white">
{{ item.spec.displayName }}
</view>
<view v-if="!calcAuditModeEnabled" class="catgory-count z-2 mt-1 text-[24rpx] text-white">
{{ item.postCount }} 篇文章
</view>
</view>
</view>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
<!-- list-post 视图:左侧分类 + 右侧文章 -->
<view v-else class="list-post-wrapper min-h-screen w-screen flex">
<scroll-view class="left-nav w-[180rpx] shrink-0 bg-white" :scroll-y="true">
<view
v-for="(item, index) in categoryList"
:key="item.metadata.name"
class="left-nav-item border-l-4 px-4 py-8 text-center text-[26rpx] text-[#606266]"
:class="{ active: currentCategoryName === item.metadata.name }"
@click="handleOnCategoryChange({ detail: { current: index } })"
>
{{ item.spec.displayName }}
</view>
</scroll-view>
<scroll-view class="right-content box-border h-screen flex-1" :scroll-y="true">
<view v-if="postList.length === 0" class="article-empty flex items-center justify-center py-10">
<wd-empty description="该分类下暂无文章~" />
</view>
<block v-else>
<uh-article-min-card
v-for="(post, index) in postList"
:key="index"
:article="post"
@on-click="handleToArticleDetail"
/>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</scroll-view>
</view>
</block>
</view>
<!-- 悬浮按钮 -->
<view class="flot-buttons fixed bottom-[100rpx] right-8 z-999 flex flex-col gap-1.5">
<view class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleScrollTop">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view v-if="!calcAuditModeEnabled" class="fab-btn h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleChangeShowType">
<wd-icon :name="calcShowType === 'list' ? 'list' : 'grid'" size="20px" color="#03a9f4" />
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.app-page {
width: 100vw;
}
.app-page-content {
&.list-post {
padding: 0;
gap: 0;
}
}
.catgory-card {
> view {
position: relative;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.catgory-card-content::before {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgb(0 0 0 / 15%);
backdrop-filter: blur(3rpx);
z-index: 1;
}
}
.list-post-wrapper {
.left-nav {
.left-nav-item {
border-left-color: transparent;
&.active {
color: #03a9f4;
border-left-color: #03a9f4;
background-color: #f5f7fa;
font-weight: bold;
}
}
}
}
.flot-buttons {
.fab-btn {
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
}
}
</style>
+266 -2
View File
@@ -1,13 +1,277 @@
<script lang="ts" setup>
/**
* 图库页(源自旧项目 pages/tabbar/gallery/gallery.vue,新建复刻)
* 功能:相册分组切换 + 图片列表(瀑布流/网格) + 图片预览
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getPhotoGroupList, getPhotoListByGroupName } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import type { IPhoto } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '图库',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const galleryConfig = computed(() => haloConfigs.value.pageConfig?.galleryConfig)
/** 依赖插件(plugin-photos) */
const uniHaloPluginId = 'plugin-photos'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const category = ref<{ activeIndex: number, list: { name?: string, displayName: string, priority: number }[] }>({
activeIndex: 0,
list: [],
})
const queryParams = ref({ size: 10, page: 1, group: '' })
const isLoadMore = ref(false)
const loadMoreText = ref('')
const hasNext = ref(false)
const dataList = ref<IPhoto[]>([])
const lock = ref(false)
/* ---------------- 数据加载 ---------------- */
async function handleGetCategory() {
if (calcAuditModeEnabled.value) {
handleGetData(true)
return
}
try {
const res = await getPhotoGroupList({ page: 1, size: 0 })
category.value.list = (res.data.items || [])
.map(item => ({
name: item.metadata.name,
displayName: item.spec.displayName,
priority: item.spec.priority ?? 0,
}))
.sort((a, b) => a.priority - b.priority)
category.value.list.unshift({ name: undefined, displayName: '全部', priority: 0 })
if (category.value.list.length !== 0) {
queryParams.value.group = category.value.list[0].name || ''
handleGetData(true)
}
}
catch (e) {
console.error(e)
loading.value = 'error'
category.value = { activeIndex: 0, list: [] }
}
}
async function handleGetData(isClearList = false) {
if (calcAuditModeEnabled.value) {
const galleryMock = mockJson.value.gallery as { list?: string[] } | undefined
dataList.value = (galleryMock?.list || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
displayName: '',
url: checkImageUrl(item),
},
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
lock.value = false
return
}
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = ''
try {
const res = await getPhotoListByGroupName({ ...queryParams.value })
hasNext.value = res.data.hasNext
loading.value = 'success'
if (res.data.items.length !== 0) {
const list = res.data.items.map(item => ({
...item,
spec: { ...item.spec, url: checkImageUrl(item.spec.url || item.spec.cover) },
}))
dataList.value = isLoadMore.value
? dataList.value.concat(list)
: list
}
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
lock.value = false
}, 500)
}
}
function handleGetDataByCategory(index: number) {
const item = category.value.list[index]
if (!item)
return
queryParams.value.group = item.name || ''
queryParams.value.page = 1
uni.pageScrollTo({ scrollTop: 0, duration: 500 })
dataList.value = []
handleGetData(true)
}
function handleOnCategoryChange(e: { detail: { current: number } }) {
if (lock.value)
return
handleGetDataByCategory(e.detail.current)
}
/* ---------------- 图片预览 ---------------- */
function handlePreview(data: IPhoto) {
const current = dataList.value.findIndex(x => x.metadata.name === data.metadata.name)
uni.previewImage({
current,
urls: dataList.value.map(x => x.spec.url),
indicator: 'number',
loop: true,
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
// 检查插件可用性
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
})
watch(galleryConfig, (newVal) => {
if (!newVal)
return
uni.setNavigationBarTitle({ title: newVal.pageTitle || t('page.gallery.title') })
handleGetCategory()
}, { deep: true, immediate: true })
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
dataList.value = []
isLoadMore.value = false
queryParams.value.page = 1
handleGetData(true)
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData(false)
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
相册图库
<view class="app-page min-h-screen w-screen flex flex-col pb-6" style="background-color: #fafafa;">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用图库功能哦请联系管理员"
@on-refresh="handleGetCategory"
/>
<template v-else>
<!-- 顶部切换 -->
<view v-if="category.list.length > 0" class="category-tabs fixed inset-x-0 top-0 z-6 bg-white">
<wd-tabs
v-model="category.activeIndex"
:tabs="category.list.map(item => ({ title: item.displayName }))"
align="left"
@change="handleOnCategoryChange"
/>
</view>
<!-- 占位区域 -->
<view v-if="category.list.length > 0" class="h-[90rpx] w-screen" />
<!-- 骨架屏 -->
<view v-if="loading === 'loading'" class="loading-wrap box-border p-3">
<wd-skeleton :row="4" :animated="true" />
</view>
<!-- 错误态 -->
<view v-else-if="loading === 'error'" class="error-wrap h-[60vh] w-full flex flex-col items-center justify-center gap-6">
<wd-empty description="阿偶,获取数据失败了~" />
<wd-button size="small" plain type="primary" @click="handleGetCategory()">
刷新试试
</wd-button>
</view>
<!-- 内容区域 -->
<view v-else class="content box-border w-full p-3">
<view v-if="dataList.length === 0" class="h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty description="博主还没有分享图片~" />
</view>
<block v-else>
<!-- 瀑布流(双列) -->
<view class="waterfall flex flex-wrap gap-1.5">
<view
v-for="(item, index) in dataList"
:key="index"
class="waterfall-item h-[250rpx] w-[calc(50%-6rpx)] overflow-hidden rounded-xl"
:class="{ 'is-even mt-3': index % 2 === 1 }"
>
<image
class="waterfall-img h-full w-full"
:src="item.spec.url"
mode="aspectFill"
lazy-load
@click="handlePreview(item)"
/>
</view>
</view>
<view class="load-text w-full py-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.error-wrap {
.error-wrap-inner {
/* 无额外样式 */
}
}
</style>
+549 -3
View File
@@ -1,14 +1,560 @@
<script lang="ts" setup>
/**
* 首页(源自旧项目 pages/tabbar/home/home.vue,新建复刻)
* 功能:顶部栏 + 轮播 Banner + 快捷导航 + 精选分类 + 最新文章列表(分页) + 通知弹窗
*/
import { computed, ref, watch } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { getCategoryList, getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting'
import { checkAvatarUrl, checkImageUrl, checkThumbnailUrl } from '@/utils/url'
import { t } from '@/locale'
import type { ICategory, IPost } from '@/api/types/halo'
import type { IBannerItem } from '@/components/uh-swiper/uh-swiper.vue'
definePage({
style: {
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const articleList = ref<IPost[]>([])
const categoryList = ref<ICategory[]>([])
const bannerList = ref<IBannerItem[]>([])
const result = ref<{ hasNext: boolean }>({ hasNext: false })
const notify = ref({
show: false,
data: {} as IBannerItem,
})
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
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
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcIsShowQuickNavigationEnabled = computed(() => !!haloConfigs.value.pageConfig?.homeConfig?.useQuickNavigation)
const calcIsShowCategory = computed(() => {
if (calcAuditModeEnabled.value)
return false
return !!haloConfigs.value.pageConfig?.homeConfig?.useCategory
})
const calcVotePluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.votePlugin?.enabled)
const calcLinksPluginEnabled = computed(() => !!haloConfigs.value.pluginConfig?.linksPlugin?.enabled)
const bannerConfig = computed(() => haloConfigs.value.pageConfig?.homeConfig?.bannerConfig)
const globalAppSettings = computed(() => settingStore.settings)
/** 快捷导航列表(由配置控制显隐) */
const navList = computed(() => {
const loveEnabled = !!(haloConfigs.value.loveConfig as { loveEnabled?: boolean })?.loveEnabled
const socialEnabled = !!(haloConfigs.value.authorConfig?.social as { enabled?: boolean } | undefined)?.enabled
return [
{
key: 'archives',
title: calcAuditModeEnabled.value ? '内容归档' : '文章归档',
bgColor: 'rgba(3, 169, 244, 0.95)',
icon: 'news',
path: '/pages-blog/archives/archives',
show: true,
},
{
key: 'vote',
title: '投票中心',
bgColor: 'rgba(0, 188, 212, 0.95)',
icon: 'box',
path: '/pages-blog/votes/votes',
show: !calcAuditModeEnabled.value && calcVotePluginEnabled.value,
},
{
key: 'disclaimers',
title: '友情链接',
bgColor: 'rgba(0, 150, 136, 0.95)',
icon: 'link',
path: '/pages-blog/friend-links/friend-links',
show: calcLinksPluginEnabled.value,
},
{
key: 'love',
title: '恋爱日记',
bgColor: 'rgba(255, 76, 103, 0.95)',
icon: 'heart',
path: '/pages-blog/love/love',
show: loveEnabled,
},
{
key: 'contact-blogger',
title: '联系博主',
bgColor: 'rgba(255, 152, 0, 0.95)',
icon: 'message',
path: '/pages-blog/contact/contact',
show: socialEnabled,
},
]
})
/* ---------------- 数据加载 ---------------- */
async function handleQuery() {
handleGetBanner()
await Promise.all([handleGetArticleList(), handleGetCategoryList()])
}
/** 轮播图 */
function handleGetBanner() {
if (calcAuditModeEnabled.value) {
const homeMock = mockJson.value.home as { bannerList?: { title?: string, cover?: string, time?: string }[] } | undefined
bannerList.value = (homeMock?.bannerList || []).map(item => ({
id: Date.now() * Math.random(),
title: item.title,
image: checkThumbnailUrl(item.cover),
src: checkThumbnailUrl(item.cover),
type: 'custom',
content: '',
url: '',
}))
return
}
if (!bannerConfig.value?.enabled)
return
if (bannerConfig.value.type === 'custom') {
bannerList.value = (bannerConfig.value.list as { title?: string, cover?: string, content?: string, url?: string }[]).map(item => ({
id: Date.now() * Math.random(),
title: item.title,
image: checkThumbnailUrl(item.cover),
src: checkThumbnailUrl(item.cover),
type: 'custom',
content: item.content || '',
url: item.url || '',
}))
return
}
// post 类型:取最新文章作为轮播
const list = articleList.value.slice(0, 5).map(item => ({
id: item.metadata.name,
title: item.spec.title,
image: checkThumbnailUrl(item.spec.cover),
src: checkThumbnailUrl(item.spec.cover),
type: 'post',
content: item.status?.excerpt || '',
url: '',
}))
bannerList.value = list
}
/** 精选分类 */
async function handleGetCategoryList() {
if (calcAuditModeEnabled.value || !calcIsShowCategory.value) {
loading.value = 'success'
return
}
try {
const res = await getCategoryList({ fieldSelector: ['spec.hideFromList=false'], size: 10 })
categoryList.value = res.data.items
.map(item => ({ ...item, postCount: item.postCount ?? 0 }))
.sort((a, b) => (b.postCount || 0) - (a.postCount || 0))
loading.value = 'success'
}
catch (err) {
console.error('获取分类失败', err)
loading.value = 'error'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
/** 文章列表 */
async function handleGetArticleList() {
if (calcAuditModeEnabled.value) {
const homeMock = mockJson.value.home as { postList?: { title?: string, cover?: string, time?: string, desc?: string }[] } | undefined
articleList.value = (homeMock?.postList || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
title: item.title || '',
slug: '',
cover: item.cover,
pinned: false,
publishTime: item.time,
deleted: false,
publish: true,
allowComment: true,
visible: 'PUBLIC' as const,
priority: 0,
categories: [],
tags: [],
},
status: { permalink: '', inProgress: false, excerpt: item.desc },
stats: { visit: 0 },
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getPostList({ ...queryParams.value })
result.value.hasNext = res.data.hasNext
articleList.value = isLoadMore.value
? articleList.value.concat(res.data.items)
: res.data.items
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
// post 型轮播依赖文章列表,若启用则刷新
if (bannerConfig.value?.enabled && bannerConfig.value.type !== 'custom') {
handleGetBanner()
}
}
catch (err) {
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
console.error('获取文章失败', err)
}
finally {
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
/* ---------------- 跳转 ---------------- */
function handleToArticleDetail(article: IPost) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/article-detail/article-detail?name=${article.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToCategoryPage() {
uni.switchTab({ url: '/pages/tabbar/category/category' })
}
function handleToArticlesPage() {
uni.navigateTo({ url: '/pages-blog/articles/articles' })
}
function handleToCategoryBy(category: ICategory) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/category-detail/category-detail?name=${category.metadata.name}&title=${category.spec.displayName}`,
})
}
function handleToSearch() {
uni.navigateTo({ url: '/pages-blog/articles/articles' })
}
function handleOnLogoToPage() {
uni.switchTab({ url: '/pages/tabbar/about/about' })
}
function handleClickNav(item: { path: string }) {
uni.navigateTo({ url: item.path })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handleOnBannerClick(item: IBannerItem) {
if (calcAuditModeEnabled.value)
return
if (item.type === 'custom') {
if (item.content) {
notify.value = { show: true, data: item }
return
}
if (item.url) {
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: item.title || t('common.loading'),
url: encodeURIComponent(item.url),
})}`,
})
}
return
}
if (!item.id)
return
handleToArticleDetail({ metadata: { name: String(item.id) } } as IPost)
}
function handleOnNotifyChange(show: boolean) {
notify.value.show = show
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
uni.setNavigationBarTitle({ title: t('page.home.title') })
})
watch(haloConfigs, () => {
// 配置就绪后重新拉取(导航显隐依赖配置)
}, { deep: true })
onPullDownRefresh(() => {
isLoadMore.value = false
queryParams.value.page = 1
handleQuery()
})
onReachBottom(() => {
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (result.value.hasNext) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetArticleList()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
// 首次加载
handleQuery()
</script>
<template>
<view class="mt-10 text-center text-green-500">
首页
<view class="app-page min-h-screen w-screen flex flex-col">
<!-- 顶部栏 -->
<view class="header flex items-center gap-4 px-3 py-1.5">
<image class="logo h-[60rpx] w-[60rpx] rounded-3xl" :src="appInfo.logo" mode="scaleToFill" @click="handleOnLogoToPage" />
<view class="search-input h-[64rpx] flex flex-1 items-center rounded-3xl bg-[#f5f5f5] px-3" @click="handleToSearch">
<view class="search-icon flex items-center">
<wd-icon name="search" size="16px" color="#999" />
</view>
<text class="search-text text-grey ml-3 text-[26rpx] text-[#999]">搜索内容...</text>
</view>
<!-- #ifdef APP-PLUS || H5 -->
<view class="app-name max-w-[140rpx] overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#666]">
{{ appInfo.name }}
</view>
<!-- #endif -->
</view>
<!-- 骨架屏 -->
<view v-if="loading !== 'success' && articleList.length === 0" class="loading-wrap px-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<block v-else>
<!-- 轮播 Banner -->
<view v-if="bannerConfig?.enabled" class="bg-white pb-6">
<view v-if="bannerList.length !== 0" class="banner mx-3 mt-3 overflow-hidden rounded-xl">
<uh-swiper
:height="bannerConfig.height"
:dot-position="bannerConfig.dotPosition"
:autoplay="true"
:use-dot="bannerConfig.showIndicator"
:show-title="bannerConfig.showTitle"
:type="bannerConfig.type"
:list="bannerList"
@on-click="handleOnBannerClick"
/>
</view>
</view>
<!-- 快捷导航 -->
<view v-if="calcIsShowQuickNavigationEnabled && navList.filter(x => x.show).length" class="nav-box mx-6 mb-6 mt-4 overflow-hidden rounded-xl bg-white p-3">
<view class="page-item-title font-bold">
快捷导航
</view>
<view class="nav-list grid grid-cols-4 mt-6 gap-6">
<template v-for="item in navList.filter(x => x.show)" :key="item.key">
<view class="nav-item flex flex-col items-center gap-3" @click="handleClickNav(item)">
<view class="nav-item-icon h-[88rpx] w-[88rpx] flex items-center justify-center rounded-3xl" :style="{ backgroundColor: item.bgColor }">
<wd-icon :name="item.icon" size="24px" color="#fff" />
</view>
<view class="nav-item-text text-[24rpx] text-[#303133]">
{{ item.title }}
</view>
</view>
</template>
</view>
</view>
<!-- 精选分类 -->
<block v-if="calcIsShowCategory">
<view class="mb-6 mt-6 flex items-center justify-between px-3">
<view class="page-item-title font-bold">
精选分类
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToCategoryPage">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
</view>
<scroll-view class="category mx-6 h-[200rpx] whitespace-nowrap" :scroll-x="true">
<view v-if="categoryList.length === 0" class="cate-empty text-grey h-[180rpx] w-full flex items-center justify-center">
还没有任何分类~
</view>
<view
v-for="category in categoryList"
v-else
:key="category.metadata.name"
class="category-item mr-4 inline-block"
@click="handleToCategoryBy(category)"
>
<uh-category-mini-card :category="category" />
</view>
</scroll-view>
</block>
<!-- 最新文章 -->
<view class="mb-6 mt-6 flex items-center justify-between px-3">
<view class="page-item-title font-bold">
最新列表
</view>
<view class="show-more flex items-center justify-center rounded-xl bg-white" @click="handleToArticlesPage">
<wd-icon name="arrow-right" size="12px" color="#909399" />
</view>
</view>
<view v-if="articleList.length === 0" class="article-empty py-10">
<wd-empty description="博主还没有发表任何内容~" />
</view>
<block v-else>
<view :class="globalAppSettings.layout.home">
<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-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</block>
<!-- 通知弹窗 -->
<uh-notify-dialog
v-if="notify.show"
:show="notify.show"
:title="notify.data.title || ''"
:content="notify.data.content || ''"
:url="notify.data.url || ''"
@on-change="handleOnNotifyChange"
/>
</view>
</template>
<style scoped lang="scss">
.app-page {
display: flex;
flex-direction: column;
}
.header {
.logo {
flex-shrink: 0;
}
.search-input {
.search-text {
margin-left: 12rpx;
}
}
}
.page-item-title {
position: relative;
padding-left: 24rpx;
font-size: 32rpx;
color: #303133;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
width: 8rpx;
height: 30rpx;
background-color: rgb(33 150 243);
border-radius: 6rpx;
}
}
.show-more {
width: 42rpx;
height: 42rpx;
box-shadow: 0 0 24rpx rgb(0 0 0 / 3%);
}
.to-top-btn {
position: fixed;
right: 24rpx;
bottom: 120rpx;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgb(0 0 0 / 10%);
z-index: 6;
}
</style>
+368 -2
View File
@@ -1,13 +1,379 @@
<script lang="ts" setup>
/**
* 瞬间页(源自旧项目 pages/tabbar/moments/moments.vue,新建复刻)
* 功能:瞬间卡片列表(头像/内容/图片/音频/视频/标签) + 分页加载
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getMomentList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig'
import { checkAvatarUrl, checkThumbnailUrl } from '@/utils/url'
import { generateUUID } from '@/utils/uuid'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import { randomTagColor } from '@/utils/random'
import { t } from '@/locale'
import { usePluginAvailable } from '@/utils/plugin'
import { markdownConfig } from '@/config/markdown'
import type { IMoment } from '@/api/types/halo'
definePage({
style: {
navigationBarTitleText: '瞬间',
enablePullDownRefresh: true,
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const mockJson = computed(() => appConfigStore.mockJson)
const calcAuditModeEnabled = computed(() => !!haloConfigs.value.auditConfig?.auditModeEnabled)
const calcUseTagRandomColor = computed(() => !!haloConfigs.value.pageConfig?.momentConfig?.useTagRandomColor)
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, avatar?: string } | undefined
return {
nickname: blogger?.nickname || '',
avatar: checkAvatarUrl(blogger?.avatar),
}
})
const startConfig = computed(() => haloConfigs.value.appConfig?.startConfig as { title?: string } | undefined)
/** 依赖插件(plugin-moments) */
const uniHaloPluginId = 'plugin-moments'
const uniHaloPluginAvailable = ref(true)
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
const queryParams = ref({ size: 10, page: 1 })
const hasNext = ref(false)
const dataList = ref<(IMoment & { images?: { type?: string, url: string }[], videos?: { id?: string, url: string }[], audios?: { type?: string, url: string }[], spec: { newHtml?: string } })[]>([])
const isLoadMore = ref(false)
const loadMoreText = ref(t('common.loading'))
const videoContexts = ref<Record<string, UniApp.VideoContext | undefined>>({})
const currentVideoId = ref<string | null>(null)
/** 移除内容中的 tag 链接 */
function removeTagLinksCompletely(htmlString: string): string {
const regex = /<a\b[^>]+class=(['"])[^'"]*\btag\b[^'"]*\1[^>]*>[\s\S]*?<\/a>/gi
return htmlString.replace(regex, '')
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
const momentsMock = mockJson.value.moments as { list?: { content?: string, time?: string, images?: string[] }[] } | undefined
dataList.value = (momentsMock?.list || []).map(item => ({
metadata: { name: String(Date.now() * Math.random()) },
spec: {
content: item.content || '',
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
visible: 'PUBLIC',
allowComment: true,
approved: true,
releaseTime: item.time,
},
images: (item.images || []).map(img => ({ type: 'PHOTO', url: checkThumbnailUrl(img) })),
videos: [],
}))
loading.value = 'success'
loadMoreText.value = t('common.noMore')
uni.hideLoading()
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: t('common.loading') })
if (!isLoadMore.value) {
loading.value = 'loading'
}
loadMoreText.value = t('common.loading')
try {
const res = await getMomentList({ ...queryParams.value })
console.log('获取瞬间数据成功', res)
loading.value = 'success'
loadMoreText.value = res.data.hasNext ? t('common.loadMore') : t('common.noMore')
hasNext.value = res.data.hasNext
const tempItems = res.data.items
.filter(x => x.spec.visible === 'PUBLIC')
.map((item) => {
const medium = (item.spec as unknown as { medium?: { type?: string, url: string }[] }).medium || []
const newItem = {
...item,
spec: {
...item.spec,
owner: {
displayName: bloggerInfo.value.nickname,
avatar: bloggerInfo.value.avatar,
},
newHtml: removeTagLinksCompletely((item.spec as unknown as { content?: { html?: string } }).content?.html || ''),
},
images: medium.filter(x => x.type === 'PHOTO').map(x => ({ ...x, url: checkThumbnailUrl(x.url, true) })),
videos: medium.filter(x => x.type === 'VIDEO').map(x => ({ ...x, id: generateUUID() })),
audios: medium.filter(x => x.type === 'AUDIO'),
}
return newItem
})
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
nextTick(() => {
createVideoContexts(tempItems)
})
}
catch (err) {
console.error(err)
loading.value = 'error'
loadMoreText.value = t('common.loadFailed')
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
/* ---------------- 视频互斥 ---------------- */
function createVideoContexts(list: { videos?: { id?: string }[] }[]) {
stopAllVideos()
list.map(item => item.videos || []).flat().forEach((item) => {
if (item.id) {
videoContexts.value[item.id] = uni.createVideoContext(`video_${item.id}`)
}
})
}
function stopAllVideos(excludesVideoId: string | null = null) {
Object.keys(videoContexts.value).forEach((videoId) => {
if (!excludesVideoId || excludesVideoId !== videoId) {
videoContexts.value[videoId]?.pause()
}
})
}
function onVideoPlay(videoId: string) {
currentVideoId.value = videoId
stopAllVideos(videoId)
}
function onVideoPause(videoId: string) {
if (currentVideoId.value === videoId) {
currentVideoId.value = null
}
}
function onVideoEnded() {
currentVideoId.value = null
}
/* ---------------- 交互 ---------------- */
function handlePreview(index: number, list: { url: string }[]) {
uni.previewImage({
current: index,
urls: list.map(item => item.url),
})
}
function handleToMomentDetail(moment: IMoment) {
if (calcAuditModeEnabled.value)
return
uni.navigateTo({
url: `/pages-blog/moment-detail/moment-detail?name=${moment.metadata.name}`,
animationType: 'slide-in-right',
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/** 格式化瞬间时间 */
function formatMomentTime(time?: string): string {
// 与旧项目一致:yyyy年MM月dd日 星期w
return time ? formatTimeUtil({ d: time, f: 'yyyy年MM月dd日 星期w' }) : ''
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
uni.setNavigationBarTitle({ title: t('page.moments.title') })
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
videoContexts.value = {}
currentVideoId.value = null
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
return
}
if (hasNext.value) {
queryParams.value.page += 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: t('common.noMoreData') })
}
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
瞬间
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6">
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
error-text="检测到当前插件没有安装或者启用无法使用瞬间功能哦请联系管理员"
@on-refresh="handleGetData"
/>
<template v-else>
<view v-if="loading !== 'success'" class="loading-wrap p-3">
<wd-skeleton :row="3" :animated="true" />
</view>
<view v-else class="app-page-content">
<view v-if="dataList.length === 0" class="min-h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
</view>
<block v-else>
<!-- 瞬间卡片 -->
<view v-for="moment in dataList" :key="moment.metadata.name" class="moment-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white shadow-sm">
<view class="head flex items-center p-3 pb-0">
<image class="avatar h-[66rpx] w-[66rpx] shrink-0 rounded-full" :src="moment.spec.owner?.avatar || bloggerInfo.avatar" mode="aspectFill" />
<view class="nickname ml-3">
<view class="nickname-text text-[30rpx] text-[#333] font-bold">
{{ moment.spec.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="release-time mt-1 text-[24rpx] text-[#666]">
{{ formatMomentTime(moment.spec.releaseTime) }}
</view>
</view>
</view>
<view class="moment-content px-3 py-2" @click.stop="handleToMomentDetail(moment)">
<mp-html
class="evan-markdown"
lazy-load
:domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''"
:markdown="true"
:show-line-number="true"
:show-language-name="true"
copy-by-long-press
/>
</view>
<!-- 图片 -->
<view v-if="moment.images && moment.images.length !== 0" class="images flex flex-wrap items-start px-3 pb-6" :class="`images-${moment.images.length}`">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex" class="image-item box-border p-1" :class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image
mode="aspectFill"
class="image-src h-full w-full rounded-lg"
:src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])"
/>
</view>
</view>
<!-- 音频 -->
<view v-if="moment.audios && moment.audios.length !== 0" class="audio-list mb-3 flex flex-col gap-3 px-3">
<uh-audio-player
v-for="audio in moment.audios"
:key="audio.url"
:src="audio.url"
:poster="bloggerInfo.avatar"
:name="`来自${startConfig?.title || bloggerInfo.nickname}的声音`"
:author="bloggerInfo.nickname"
/>
</view>
<!-- 视频 -->
<view v-if="moment.videos && moment.videos.length !== 0" class="video-list mb-3 flex flex-col gap-3 px-3">
<video
v-for="(video, index) in moment.videos"
:id="`video_${video.id}`"
:key="index"
class="video-src h-[400rpx] w-full rounded-xl"
:src="video.url"
:show-mute-btn="true"
:controls="true"
:show-center-play-btn="true"
:enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')"
@pause="onVideoPause(video.id || '')"
@ended="onVideoEnded"
/>
</view>
<!-- 标签 -->
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0" class="tags flex flex-wrap gap-4 px-3 pb-6">
<view v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex" class="tag text-[24rpx]" :style="{ color: randomTagColor() }">
{{ tag }}
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[120rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
<view class="load-text pb-5 text-center text-[24rpx] text-[#999]">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
.moment-card {
.head {
.nickname {
.nickname-text {
/* 无额外样式 */
}
}
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+86
View File
@@ -0,0 +1,86 @@
/**
* 应用配置 store(源自旧项目 store/config.js 的 configs/mockJson 部分)
* 注意:旧 fetchConfigs 中会把 basicConfig.tokenConfig 写入缓存,供 getPersonalToken 使用,此处保留
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getAppConfigs } from '@/api/uni-halo'
import { DefaultAppConfigs } from '@/config/appConfig'
import { deepMerge } from '@/utils/merge'
import { setCache } from '@/utils/storage'
import { checkUrl } from '@/utils/url'
import type { IAppConfig, IMockJson } from '@/api/types/uni-halo'
/** 个人令牌存储 key(与 src/store/token.ts 的 getPersonalToken 保持一致) */
const APP_TOKENS_KEY = 'APP_TOKENS'
export const useAppConfigStore = defineStore(
'appConfig',
() => {
const configs = ref<IAppConfig>(JSON.parse(JSON.stringify(DefaultAppConfigs)))
const mockJson = ref<IMockJson>({})
/** 重置为默认配置 */
const setDefaultAppSettings = () => {
configs.value = JSON.parse(JSON.stringify(DefaultAppConfigs))
}
/** 获取应用配置(与默认值深合并,并存储 token) */
const fetchConfigs = async () => {
try {
const res = await getAppConfigs()
const body = res.data as IAppConfig | undefined
if (body) {
configs.value = deepMerge(JSON.parse(JSON.stringify(DefaultAppConfigs)), body)
// 存储个人令牌(供 getPersonalToken 使用,如非匿名投票)
if (body?.basicConfig?.tokenConfig) {
setCache(APP_TOKENS_KEY, body.basicConfig.tokenConfig)
}
return body
}
setDefaultAppSettings()
return null
}
catch {
setDefaultAppSettings()
return null
}
}
/** 请求模拟数据(审计模式) */
const fetchMockJson = async () => {
const mockJsonUrl = checkUrl(configs.value.auditConfig?.auditModeData?.jsonUrl)
return new Promise<{ ok: boolean, data: unknown }>((resolve) => {
uni.request({
url: mockJsonUrl,
method: 'GET',
success: (res) => {
mockJson.value = res.data as IMockJson
resolve({ ok: true, data: res.data })
},
fail: (err) => {
resolve({ ok: false, data: err })
},
})
})
}
/** 设置模拟数据(jsonData 本地解析场景) */
const setMockJson = (data: IMockJson) => {
mockJson.value = data
}
return {
configs,
mockJson,
fetchConfigs,
setDefaultAppSettings,
fetchMockJson,
setMockJson,
}
},
{
persist: true,
},
)
+48
View File
@@ -0,0 +1,48 @@
/**
* Halo 全局配置 store(源自旧项目 store/config.js 的 haloConfig 部分)
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getHaloGlobalInfo } from '@/api/uni-halo'
import { DefaultHaloGlobalConfigs } from '@/config/haloGlobal'
import { deepMerge } from '@/utils/merge'
import type { IHaloGlobalConfig } from '@/api/types/uni-halo'
export const useHaloStore = defineStore(
'halo',
() => {
const haloConfig = ref<IHaloGlobalConfig>({ ...DefaultHaloGlobalConfigs })
/** 重置为默认配置 */
const setDefaultHaloGlobalConfigs = () => {
haloConfig.value = JSON.parse(JSON.stringify(DefaultHaloGlobalConfigs))
}
/** 获取 Halo 全局配置(请求失败回退默认值) */
const fetchHaloConfigs = async () => {
try {
const res = await getHaloGlobalInfo()
const body = res.data as IHaloGlobalConfig | undefined
if (body) {
haloConfig.value = deepMerge({ ...DefaultHaloGlobalConfigs }, body)
return body
}
setDefaultHaloGlobalConfigs()
return null
}
catch {
setDefaultHaloGlobalConfigs()
return null
}
}
return {
haloConfig,
fetchHaloConfigs,
setDefaultHaloGlobalConfigs,
}
},
{
persist: true,
},
)
+3
View File
@@ -15,6 +15,9 @@ setActivePinia(store)
export default store
export * from './appConfig'
export * from './halo'
export * from './setting'
// 模块统一导出
export * from './token'
export * from './user'
+35
View File
@@ -0,0 +1,35 @@
/**
* 应用设置 store(源自旧项目 store/setting.js)
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { DefaultAppSettings } from '@/config/appSettings'
import type { IAppSettings } from '@/config/appSettings'
export const useSettingStore = defineStore(
'setting',
() => {
const settings = ref<IAppSettings>(JSON.parse(JSON.stringify(DefaultAppSettings)))
/** 重置为默认设置 */
const updateDefaultAppSettings = () => {
settings.value = JSON.parse(JSON.stringify(DefaultAppSettings))
}
/** 检查并设置默认设置(启动时调用,persist 已保证有值,兜底处理) */
const checkAndSetDefaultAppSettings = () => {
if (!settings.value) {
settings.value = JSON.parse(JSON.stringify(DefaultAppSettings))
}
}
return {
settings,
updateDefaultAppSettings,
checkAndSetDefaultAppSettings,
}
},
{
persist: true,
},
)
+13
View File
@@ -13,6 +13,19 @@ import {
} from '@/api/login'
import { isDoubleTokenRes, isSingleTokenRes } from '@/api/types/login'
import { useUserStore } from './user'
import { getCache } from '@/utils/storage'
/** 个人令牌存储 key(源自旧项目 utils/token.js,含 basicConfig.tokenConfig) */
const APP_TOKENS_KEY = 'APP_TOKENS'
/**
* 获取个人令牌(旧项目 getPersonalToken)
* 用于需要个人 token 的接口(如非匿名投票 submitVote)
* @returns 个人令牌,未配置时为空字符串
*/
export function getPersonalToken(): string {
return getCache<{ personalToken?: string }>(APP_TOKENS_KEY)?.personalToken || ''
}
/**
* 是否是双token模式
+12
View File
@@ -0,0 +1,12 @@
/**
* audio 组件默认样式补件
* 说明:@dcloudio/uni-components 3.0.0-4070620250821001 的 style/ 目录未提供 audio.css,
* vite 编译 <audio> 内置组件时按惯例引入该路径会报 Cannot find module。
* 此处以本地文件补齐,并还原旧项目的 .uni-audio-default 定制样式。
*/
/* H5 端 audio 默认控件样式(源自旧项目 moments / moment-detail) */
.uni-audio-default {
width: 100%;
border-radius: 12rpx;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* video 组件默认样式补件
* 说明:@dcloudio/uni-components 3.0.0-4070620250821001 的 style/ 目录未提供 video.css,
* vite 编译 <video> 内置组件时按惯例引入该路径会报 Cannot find module。
* 此处以本地文件补齐(原生 video 控件样式由各平台提供,这里仅占位)。
*/
+1 -1
View File
@@ -15,7 +15,7 @@ const baseItem: CustomTabBarItem = {
icon: 'i-carbon-home',
}
describe('TabbarItem', () => {
describe('tabbarItem', () => {
let wrapper: ReturnType<typeof mount>
afterEach(() => {
+128 -128
View File
@@ -1,5 +1,5 @@
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages';
import type { CustomTabBarItem, NativeTabBarItem } from './types';
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
import type { CustomTabBarItem, NativeTabBarItem } from './types'
/**
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
@@ -10,163 +10,163 @@ import type { CustomTabBarItem, NativeTabBarItem } from './types';
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效
*/
export const TABBAR_STRATEGY_MAP = {
NO_TABBAR: 0,
NATIVE_TABBAR: 1,
CUSTOM_TABBAR: 2
};
NO_TABBAR: 0,
NATIVE_TABBAR: 1,
CUSTOM_TABBAR: 2,
}
// TODO: 1/3. 通过这里切换使用tabbar的策略
// 如果是使用 NO_TABBAR(0)nativeTabbarList 和 customTabbarList 都不生效
// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarListcustomTabbarList 不生效
// 如果是使用 CUSTOM_TABBAR(2),只需要配置 customTabbarListnativeTabbarList 不生效
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.NATIVE_TABBAR;
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.NATIVE_TABBAR
// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置
export const nativeTabbarList: NativeTabBarItem[] = [
{
iconPath: 'static/tabbar/select_home.png',
selectedIconPath: 'static/tabbar/select_home_active.png',
pagePath: 'pages/tabbar/home/home',
text: '首页'
},
{
iconPath: 'static/tabbar/select_category.png',
selectedIconPath: 'static/tabbar/select_category_active.png',
pagePath: 'pages/tabbar/category/category',
text: '分类'
},
{
iconPath: 'static/tabbar/select_gallery.png',
selectedIconPath: 'static/tabbar/select_gallery_active.png',
pagePath: 'pages/tabbar/gallery/gallery',
text: '图库'
},
{
iconPath: 'static/tabbar/select_links.png',
selectedIconPath: 'static/tabbar/select_links_active.png',
pagePath: 'pages/tabbar/moments/moments',
text: '瞬间'
},
{
iconPath: 'static/tabbar/select_mine.png',
selectedIconPath: 'static/tabbar/select_mine_active.png',
pagePath: 'pages/tabbar/about/about',
text: '关于'
}
];
{
iconPath: 'static/tabbar/select_home.png',
selectedIconPath: 'static/tabbar/select_home_active.png',
pagePath: 'pages/tabbar/home/home',
text: '%tabbar.home%',
},
{
iconPath: 'static/tabbar/select_category.png',
selectedIconPath: 'static/tabbar/select_category_active.png',
pagePath: 'pages/tabbar/category/category',
text: '%tabbar.category%',
},
{
iconPath: 'static/tabbar/select_gallery.png',
selectedIconPath: 'static/tabbar/select_gallery_active.png',
pagePath: 'pages/tabbar/gallery/gallery',
text: '%tabbar.gallery%',
},
{
iconPath: 'static/tabbar/select_links.png',
selectedIconPath: 'static/tabbar/select_links_active.png',
pagePath: 'pages/tabbar/moments/moments',
text: '%tabbar.moments%',
},
{
iconPath: 'static/tabbar/select_mine.png',
selectedIconPath: 'static/tabbar/select_mine_active.png',
pagePath: 'pages/tabbar/about/about',
text: '%tabbar.about%',
},
]
// TODO: 3/3. 使用 CUSTOM_TABBAR 时,更新下面的 tabbar 配置
// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片
export const customTabbarList: CustomTabBarItem[] = [
{
text: '%tabbar.home%',
pagePath: 'pages/index/index',
// 注意 unocss 图标需要如下处理:(二选一)
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-home'
// badge: 'dot',
},
// 鼓包配置示例(2025-12-31
// 中间鼓包tabbarItem配置:通常是扫描按钮、发布按钮、更多按钮等,点击触发业务逻辑
// {
// pagePath: 'pages/me/me',
// text: '我的',
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// // 2)配置到 unocss.config.ts 的 safelist 中
// iconType: 'image',
// icon: '/static/tabbar/scan.png',
// isBulge: true,
// },
{
pagePath: 'pages/i18n/index',
text: '%tabbar.i18n%',
iconType: 'unocss',
icon: 'i-carbon-ibm-watson-language-translator'
// badge: 10,
},
{
pagePath: 'pages/about/about',
text: '%tabbar.about%',
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-menu',
// badge: 10,
roles: ['admin']
},
{
pagePath: 'pages/me/me',
text: '%tabbar.me%',
iconType: 'unocss',
icon: 'i-carbon-user'
// badge: 10,
}
{
text: '%tabbar.home%',
pagePath: 'pages/index/index',
// 注意 unocss 图标需要如下处理:(二选一)
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-home',
// badge: 'dot',
},
// 鼓包配置示例(2025-12-31
// 中间鼓包tabbarItem配置:通常是扫描按钮、发布按钮、更多按钮等,点击触发业务逻辑
// {
// pagePath: 'pages/me/me',
// text: '我的',
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// // 2)配置到 unocss.config.ts 的 safelist 中
// iconType: 'image',
// icon: '/static/tabbar/scan.png',
// isBulge: true,
// },
{
pagePath: 'pages/tabbar/home/home',
text: '%tabbar.i18n%',
iconType: 'unocss',
icon: 'i-carbon-ibm-watson-language-translator',
// badge: 10,
},
{
pagePath: 'pages/tabbar/about/about',
text: '%tabbar.about%',
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-menu',
// badge: 10,
roles: ['admin'],
},
{
pagePath: 'pages/tabbar/moments/moments',
text: '%tabbar.me%',
iconType: 'unocss',
icon: 'i-carbon-user',
// badge: 10,
},
// 其他类型演示
// 1、uiLib
// {
// pagePath: 'pages/index/index',
// text: '首页',
// iconType: 'uiLib',
// icon: 'home',
// },
// 2、iconfont
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
// iconType: 'iconfont',
// icon: 'iconfont icon-my',
// },
// 3、image
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 使用 image’时,需要配置 icon + iconActive 2张图片
// iconType: 'image',
// icon: '/static/tabbar/home.png',
// iconActive: '/static/tabbar/homeHL.png',
// },
];
// 其他类型演示
// 1、uiLib
// {
// pagePath: 'pages/index/index',
// text: '首页',
// iconType: 'uiLib',
// icon: 'home',
// },
// 2、iconfont
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
// iconType: 'iconfont',
// icon: 'iconfont icon-my',
// },
// 3、image
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 使用 image’时,需要配置 icon + iconActive 2张图片
// iconType: 'image',
// icon: '/static/tabbar/home.png',
// iconActive: '/static/tabbar/homeHL.png',
// },
]
/**
* 是否启用 tabbar 缓存
* NATIVE_TABBAR(1) 和 CUSTOM_TABBAR(2) 时,需要tabbar缓存
*/
export const tabbarCacheEnable = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy);
export const tabbarCacheEnable = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy)
/**
* 是否启用自定义 tabbar
* CUSTOM_TABBAR(2) 时,启用自定义tabbar
*/
export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy);
export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy)
/**
* 是否需要隐藏原生 tabbar
* CUSTOM_TABBAR(2) 时,需要隐藏原生tabbar
*/
export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR;
export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR
const _tabbarList = customTabbarEnable ? customTabbarList.map((item) => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList;
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList;
const _tabbarList = customTabbarEnable ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList
// NATIVE_TABBAR(1) 时,显示原生Tabbar,在i18n的情况下需要 setTabbarItem (框架已经处理)
export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR;
export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR
const _tabbar: TabBar = {
// 只有微信小程序支持 custom。App 和 H5 不生效
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
color: '#303133',
selectedColor: '#03a9f4',
backgroundColor: '#ffffff',
borderStyle: 'white',
// height: '50px',
// fontSize: '10px',
// iconWidth: '24px',
// spacing: '3px',
list: _tabbarList as unknown as TabBar['list']
};
// 只有微信小程序支持 custom。App 和 H5 不生效
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
color: '#303133',
selectedColor: '#03a9f4',
backgroundColor: '#ffffff',
borderStyle: 'white',
// height: '50px',
// fontSize: '10px',
// iconWidth: '24px',
// spacing: '3px',
list: _tabbarList as unknown as TabBar['list'],
}
export const tabBar = tabbarCacheEnable ? _tabbar : undefined;
export const tabBar = tabbarCacheEnable ? _tabbar : undefined
+22
View File
@@ -0,0 +1,22 @@
## 1.1.02023-07-05
优化APP端生成逻辑
## 1.0.92023-07-04
优化
## 1.0.82023-07-04
增加注意事项
## 1.0.72023-07-04
修改本地图片不显示问题
## 1.0.62023-06-26
优化
## 1.0.52023-06-09
增加子集绘制
## 1.0.42023-06-09
增加子集绘制
## 1.0.32023-06-08
增加预览二维码
## 1.0.22023-05-31
增加license
## 1.0.12023-05-30
增加示例
## 1.0.02023-05-30
初始化发布
@@ -0,0 +1,377 @@
<template>
<view class="canvas-main">
<canvas :style="'width:'+width+'rpx;height:'+height+'rpx;'" class="canvas-item" disable-scroll="true"
canvas-id="canvasId" @error="error"></canvas>
</view>
</template>
<script>
export default {
props: {
//画布宽度(rpx)
width: {
type: Number,
default: 750
},
//画布高度(rpx)
height: {
type: Number,
default: 750
},
//生成的图片格式(jpg或png)
fileType: {
type: String,
default: 'png'
},
},
data() {
return {
pixelRatio: 0,
context: null,
canvasList: []
}
},
methods: {
async init(list) {
uni.showLoading({
title: '正在绘制...'
})
if (this.context) {
await this.clear()
this.canvasList = []
this.context = null
}
this.canvasList = JSON.parse(JSON.stringify(list))
const systemInfo = uni.getSystemInfoSync()
this.pixelRatio = systemInfo.pixelRatio
this.context = uni.createCanvasContext('canvasId', this)
this.start()
},
clear() {
return new Promise(async (resolve, reject) => {
await this.context.clearRect(0, 0, this.width, this.height)
resolve()
})
},
async start() {
await Promise.all(this.canvasList.map(async res => {
if (res.type == 'color') {
await this.drawBg(res)
} else if (res.type == 'image') {
await this.drawImage(res)
} else if (res.type == 'text') {
await this.drawText(res)
} else if (res.type == 'line') {
await this.drawLine(res)
}
}))
this.save()
},
drawBg(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.height = uni.upx2px(item.height)
item.x = uni.upx2px(item.x)
item.y = uni.upx2px(item.y)
item.radius = uni.upx2px(item.radius)
item.lineWidth = uni.upx2px(item.lineWidth)
let gradient = ''
if (item.colorObj && item.colorObj.colorList) {
if (item.colorObj.colorList.length == 1) {
this.context.fillStyle = item.colorObj.colorList[0]
} else {
if (item.colorObj.direction == 1) {
gradient = this.context.createLinearGradient(0, 0, item.height, 0)
} else if (item.colorObj.direction == 2) {
gradient = this.context.createLinearGradient(0, 0, 0, item.height)
} else if (item.colorObj.direction == 3) {
gradient = this.context.createLinearGradient(0, 0, item.width, item.height)
} else if (item.colorObj.direction == 4) {
gradient = this.context.createLinearGradient(item.width, 0, 0, item.height)
}
gradient.addColorStop(0, item.colorObj.colorList[0])
gradient.addColorStop(1, item.colorObj.colorList[1])
this.context.fillStyle = gradient
}
} else {
this.context.fillStyle = '#FFFFFF'
}
this.context.save()
if (item.radius > 0) {
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width, item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x + item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item.radius)
this.context.closePath()
this.context.clip()
}
this.context.fillRect(item.x, item.y, item.width, item.height)
if (item.lineWidth) {
this.context.setLineDash([])
this.context.lineWidth = item.lineWidth
this.context.strokeStyle = item.lineColor
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width, item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x + item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item.radius)
this.context.closePath()
this.context.stroke()
}
this.context.restore()
await this.context.draw(true)
if (item.childs && item.childs.length > 0) {
await Promise.all(item.childs.map(async res => {
if (res.type == 'color') {
await this.drawBg(res)
} else if (res.type == 'image') {
await this.drawImage(res)
} else if (res.type == 'text') {
await this.drawText(res)
} else if (res.type == 'line') {
await this.drawLine(res)
}
}))
}
resolve()
})
},
drawImage(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.height = uni.upx2px(item.height)
item.x = uni.upx2px(item.x)
item.y = uni.upx2px(item.y)
item.radius = uni.upx2px(item.radius)
item.lineWidth = uni.upx2px(item.lineWidth)
await this.getImageInfo(item.path).then(async res => {
this.context.save()
if (item.radius > 0) {
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width,
item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item
.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x +
item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item
.radius)
this.context.closePath()
this.context.clip()
}
await this.context.drawImage(res, item.x, item.y, item.width, item
.height)
if (item.lineWidth) {
this.context.setLineDash([])
this.context.lineWidth = item.lineWidth
this.context.strokeStyle = item.lineColor
this.context.beginPath()
this.context.moveTo(item.x + item.radius, item.y)
this.context.arcTo(item.x + item.width, item.y, item.x + item.width,
item.y +
item.radius, item.radius)
this.context.lineTo(item.x + item.width, item.y + item.height - item
.radius)
this.context.arcTo(item.x + item.width, item.y + item.height, item.x +
item
.width - item.radius, item.y + item.height, item.radius)
this.context.lineTo(item.x + item.radius, item.y + item.height)
this.context.arcTo(item.x, item.y + item.height, item.x, item.y + item
.height - item.radius, item.radius)
this.context.lineTo(item.x, item.y + item.radius)
this.context.arcTo(item.x, item.y, item.x + item.radius, item.y, item
.radius)
this.context.closePath()
this.context.stroke()
}
this.context.restore()
await this.context.draw(true)
if (item.childs && item.childs.length > 0) {
await Promise.all(item.childs.map(async res => {
if (res.type == 'color') {
await this.drawBg(res)
} else if (res.type == 'image') {
await this.drawImage(res)
} else if (res.type == 'text') {
await this.drawText(res)
} else if (res.type == 'line') {
await this.drawLine(res)
}
}))
}
resolve()
})
})
},
drawText(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.height = uni.upx2px(item.height)
item.x = uni.upx2px(item.x)
item.y = uni.upx2px(item.y)
item.fontSize = uni.upx2px(item.fontSize)
item.lineHeight = uni.upx2px(item.lineHeight)
await this.drawTextInfo(item.content, item.x, item.y, item.fontSize, item.color, item
.width, item.height, item.lineHeight, item.bold, true)
resolve()
})
},
drawLine(item) {
return new Promise(async (resolve, reject) => {
item.width = uni.upx2px(item.width)
item.startX = uni.upx2px(item.startX)
item.startY = uni.upx2px(item.startY)
item.endX = uni.upx2px(item.endX)
item.endY = uni.upx2px(item.endY)
this.context.setStrokeStyle(item.color)
this.context.setLineWidth(item.width)
this.context.setLineCap('round')
if (item.lineType == 'dash') this.context.setLineDash([item.width * 5, item.width * 5], 0)
else this.context.setLineDash([])
this.context.beginPath()
this.context.moveTo(item.startX, item.startY)
this.context.lineTo(item.endX, item.endY)
this.context.stroke()
this.context.closePath()
resolve()
})
},
drawTextInfo(text, x, y, fontSize, color, width, height, lineHeight, bold, ellipsis) {
return new Promise(async (resolve, reject) => {
this.context.setFillStyle(color)
if (bold) this.context.font = 'bold ' + fontSize + 'px Arial'
else this.context.font = fontSize + 'px Arial'
this.context.setTextBaseline('bottom')
let textArray = text.split('')
let line = ''
let lines = []
for (let i = 0; i < textArray.length; i++) {
let testLine = line + textArray[i]
let testWidth = this.context.measureText(testLine).width
if (testWidth > width) {
lines.push(line)
line = textArray[i]
} else {
line = testLine
}
}
lines.push(line)
let firstWidth = this.context.measureText(lines[0]).width
if (height >= lineHeight * lines.length) {
await Promise.all(lines.map(async (res, i) => {
let lineText = res
let lineHeights = lineHeight * (i + 1)
await this.context.fillText(lineText, x, y + lineHeights)
}))
} else {
let sNum = parseInt(height / lineHeight)
lines = lines.slice(0, sNum)
await Promise.all(lines.map(async (res, i) => {
let lineText = res
let lineHeights = lineHeight * (i + 1)
if (i == lines.length - 1) {
if (this.context.measureText('...').width < fontSize) {
lineText = lineText.substring(0, lineText.length - 1)
lineText += '...'
} else {
lineText = lineText.substring(0, lineText.length - 2)
lineText += '...'
}
}
await this.context.fillText(lineText, x, y + lineHeights)
}))
}
resolve()
})
},
getImageInfo(src) {
return new Promise(async (resolve, reject) => {
if (src.indexOf('http') == -1) {
setTimeout(() => {
resolve(src)
})
} else {
// #ifdef APP-PLUS
uni.getImageInfo({
src: src,
success: (res) => {
resolve(res.path)
},
fail(err) {
resolve(src)
}
})
// #endif
// #ifndef APP-PLUS
uni.downloadFile({
url: src,
success: (res) => {
if (res.statusCode === 200) resolve(res.tempFilePath)
},
fail: (err) => {
resolve(src)
}
})
// #endif
}
})
},
save() {
let timer = setTimeout(async () => {
await this.context.draw(true, setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'canvasId',
fileType: this.fileType,
quality: 1,
width: this.width,
height: this.height,
destWidth: this.width * this.pixelRatio,
destHeight: this.height * this.pixelRatio,
success: (res) => {
uni.hideLoading()
this.$emit('change', res.tempFilePath)
},
fail: (err) => {
console.log('生成图片失败:', err)
}
}, this)
}, 500))
clearTimeout(timer)
}, 500)
},
error(e) {
console.log('错误信息:', e)
}
}
}
</script>
</script>
<style lang="scss" scoped>
.canvas-main {
position: fixed;
z-index: -999999 !important;
opacity: 0;
top: -5000rpx;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
### 1、本插件可免费下载使用;
### 2、未经许可,严禁复制本插件派生同类插件上传插件市场;
### 3、未经许可,严禁在插件市场恶意复制抄袭本插件进行违规获利;
### 4、对本软件的任何使用都必须遵守这些条款,违反这些条款的个人或组织将面临法律追究。
+100
View File
@@ -0,0 +1,100 @@
{
"id": "liu-poster",
"displayName": "canvas海报画板、海报生成、海报图",
"version": "1.1.0",
"description": "canvas海报画板、海报生成、海报图组件,配置简单,支持绘制背景色、绘制图片、绘制文本、绘制线条,自由生成海报图片",
"keywords": [
"海报",
"生成海报",
"canvas",
"图片合成",
"图片处理"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0",
"uni-app": "^3.1.0",
"uni-app-x": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"darkmode": "-",
"i18n": "-",
"widescreen": "-"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": "-",
"vue3": "-"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "-",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More