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:
@@ -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。
|
||||
# 微信小程序如果没有配置下面的专用地址,也会回退使用这个值。
|
||||
|
||||
Vendored
+3
@@ -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'
|
||||
|
||||
Vendored
+3
@@ -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
|
||||
|
||||
Vendored
+3
@@ -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'
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
}],
|
||||
|
||||
Generated
+19
@@ -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
@@ -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 };
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>').replace(/\xa0/g, ' ') // 编码实体
|
||||
} 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, '"') + '"'
|
||||
}
|
||||
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(/&/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(/&/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:不匹配;1:name 匹配;2:class 匹配;3:id 匹配
|
||||
*/
|
||||
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(/&/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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}公斤",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -15,6 +15,9 @@ setActivePinia(store)
|
||||
|
||||
export default store
|
||||
|
||||
export * from './appConfig'
|
||||
export * from './halo'
|
||||
export * from './setting'
|
||||
// 模块统一导出
|
||||
export * from './token'
|
||||
export * from './user'
|
||||
|
||||
@@ -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,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模式
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* video 组件默认样式补件
|
||||
* 说明:@dcloudio/uni-components 3.0.0-4070620250821001 的 style/ 目录未提供 video.css,
|
||||
* vite 编译 <video> 内置组件时按惯例引入该路径会报 Cannot find module。
|
||||
* 此处以本地文件补齐(原生 video 控件样式由各平台提供,这里仅占位)。
|
||||
*/
|
||||
@@ -15,7 +15,7 @@ const baseItem: CustomTabBarItem = {
|
||||
icon: 'i-carbon-home',
|
||||
}
|
||||
|
||||
describe('TabbarItem', () => {
|
||||
describe('tabbarItem', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
+128
-128
@@ -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),只需要配置 nativeTabbarList,customTabbarList 不生效
|
||||
// 如果是使用 CUSTOM_TABBAR(2),只需要配置 customTabbarList,nativeTabbarList 不生效
|
||||
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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1.1.0(2023-07-05)
|
||||
优化APP端生成逻辑
|
||||
## 1.0.9(2023-07-04)
|
||||
优化
|
||||
## 1.0.8(2023-07-04)
|
||||
增加注意事项
|
||||
## 1.0.7(2023-07-04)
|
||||
修改本地图片不显示问题
|
||||
## 1.0.6(2023-06-26)
|
||||
优化
|
||||
## 1.0.5(2023-06-09)
|
||||
增加子集绘制
|
||||
## 1.0.4(2023-06-09)
|
||||
增加子集绘制
|
||||
## 1.0.3(2023-06-08)
|
||||
增加预览二维码
|
||||
## 1.0.2(2023-05-31)
|
||||
增加license
|
||||
## 1.0.1(2023-05-30)
|
||||
增加示例
|
||||
## 1.0.0(2023-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>
|
||||
@@ -0,0 +1,6 @@
|
||||
### 1、本插件可免费下载使用;
|
||||
### 2、未经许可,严禁复制本插件派生同类插件上传插件市场;
|
||||
### 3、未经许可,严禁在插件市场恶意复制抄袭本插件进行违规获利;
|
||||
### 4、对本软件的任何使用都必须遵守这些条款,违反这些条款的个人或组织将面临法律追究。
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user